diff --git a/.changeset/6723-inline-data-fls.md b/.changeset/6723-inline-data-fls.md new file mode 100644 index 000000000..248b793ee --- /dev/null +++ b/.changeset/6723-inline-data-fls.md @@ -0,0 +1,54 @@ +--- +'@object-ui/plugin-grid': patch +--- + +`ObjectGrid` re-applies field-level security on its inline-data column path too, +so whether an object-bound grid re-checks FLS no longer depends on who fetched +the rows (objectui#6723, maintainer ruling 2026-08-29). + +`generateColumns()` re-applied FLS at exactly one place — the object-schema +path. The inline-data path, taken when a host hands rows down as `data` **and** +the author declared a `fields` projection, had no equivalent check. Both paths +serve object-bound grids, so the same object with the same authored projection +did or did not go through the field gate purely according to provenance: + +| rows from | `fields` declared | path taken | FLS re-applied | +| --- | --- | --- | --- | +| grid fetches | no | object-schema | yes | +| grid fetches | yes | object-schema | yes | +| host passes `data` | no | object-schema (since objectui#6677) | yes | +| host passes `data` | yes | inline-data | **no, until now** | + +The inline-data path now filters each column through +`perms.checkField(objectName, fieldName, 'read')` when `perms.isLoaded && +schema.objectName`, the same gate and the same deferral condition the +object-schema path has always used. + +⚠️ **Only keys the OBJECT DECLARES are judged, and that limit is load-bearing +rather than an optimisation.** Host-joined and derived keys pass through +untouched, because keeping them is this path's whole reason to exist — the +object-schema path drops them outright (`if (!field) return;`). A field policy +that enumerates readable fields answers "no" for a key it has never heard of, so +judging derived keys would silently drop them, which is the failure the issue's +own analysis warned about. Declaration is read with `hasOwnProperty`, so an +inherited name (`constructor`) is not mistaken for a declared field. + +**Defence in depth, not a reachable exploit through the shipped hosts.** +`ListView` — the dominant host — already filters its own `effectiveFields` +through this same gate before forwarding, and that redundancy is the point: the +invariant must not rest on every future host having read the docs. The exposure +this closes is a direct +`` composition, or a +future host that forwards an authored projection unfiltered. + +Deliberately unchanged, and refused by name in the ruling: the two paths' other +differences stay as they are — the schema path's `resolveFieldLabel` (i18n) vs +the inline path's local humanisation, and the schema path's drop of names the +object does not declare. Converging those is a separate decision. + +Pinned in `packages/plugin-grid/src/__tests__/inlineDataFls-6723.test.tsx` (a +readable declared field renders; an unreadable declared field does not, even +with host data for it; a derived key is unaffected; plus the perms-not-loaded, +no-`objectName` and schema-in-flight boundaries and a case through the real +`PermissionProvider`) and, as a measured no-op on the `ListView` path, in +`packages/plugin-list/src/__tests__/ListView.inlineFlsNoop-6723.test.tsx`. diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index 0988ddca0..2b55805e4 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -2299,7 +2299,42 @@ export const ObjectGrid: React.FC = ({ if (hasInlineData && !rowKeysWouldOutrankSchemaPolicy) { const inlineData = dataConfig?.provider === 'value' ? dataConfig.items as any[] : []; if (inlineData.length > 0) { - const fieldsToShow = schemaFields || Object.keys(inlineData[0]); + // FLS on the inline-data path (objectui#6723 — maintainer ruling + // 2026-08-29: the NARROW defence-in-depth fix, not a convergence). + // + // The object-schema path below re-applies field-level security to the + // columns it derives; this path did not. So whether an object-bound + // grid re-checked FLS depended on WHO FETCHED THE ROWS: same object, + // same authored projection, rows the grid fetched went through the + // gate and rows a host handed down did not. That is the asymmetry, and + // a security invariant may not be decided by the data's provenance. + // + // ⭐ THE LIMIT IS LOAD-BEARING, NOT AN OPTIMISATION. Only keys the + // OBJECT DECLARES are judged; everything else passes through + // untouched. A host may legitimately join or derive columns + // (`computed_score`, a flattened `account.name`), and keeping those is + // this path's whole reason to exist — the object-schema path drops + // them outright (`if (!field) return;`). Judging an undeclared key + // would silently drop derived columns, which is the failure + // objectui#6723's own analysis warned about and which the ruling + // refuses by name. `checkField` answers `false` for a field the + // policy has never heard of, so asking it about a derived key is not + // a stricter reading of the same rule — it is a different, wrong one. + // + // Redundant through `ListView`, which filters its own `effectiveFields` + // through this same gate before forwarding (its source says so), and + // that redundancy IS the point: the invariant must not rest on every + // future host having read the docs. Pinned as a byte-for-byte no-op on + // that path in `inlineDataFls-6723.test.tsx`. + const fieldsToShow = (schemaFields || Object.keys(inlineData[0])).filter((fieldName) => { + if (!perms?.isLoaded || !schema.objectName) return true; + // Undeclared ⇒ host-joined / derived ⇒ not this gate's business. + // `hasOwnProperty` rather than a truthiness read so an inherited + // name (`constructor`, `toString`) cannot be mistaken for a declared + // field and dropped. + if (!Object.prototype.hasOwnProperty.call(objectSchema?.fields ?? {}, fieldName)) return true; + return perms.checkField(schema.objectName, fieldName, 'read'); + }); return fieldsToShow.map((fieldName) => { const fieldDef = objectSchema?.fields?.[fieldName]; // Annotated for the same reason as paths A and B (objectui#6004). diff --git a/packages/plugin-grid/src/__tests__/inlineDataFls-6723.test.tsx b/packages/plugin-grid/src/__tests__/inlineDataFls-6723.test.tsx new file mode 100644 index 000000000..0612ede75 --- /dev/null +++ b/packages/plugin-grid/src/__tests__/inlineDataFls-6723.test.tsx @@ -0,0 +1,353 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#6723 — field-level security on `generateColumns()`'s INLINE-DATA + * path, and the limit that makes it safe. + * + * ## The defect + * + * `generateColumns()` re-applied FLS at exactly one place: the object-schema + * path (`if (perms?.isLoaded && schema.objectName && !perms.checkField(...)) + * return;`). The inline-data path — taken when a host hands rows down as `data` + * AND the author declared a `fields` projection — had no equivalent check. Both + * paths serve object-bound grids, so the same object with the same authored + * projection did or did not re-check FLS purely according to WHO FETCHED THE + * ROWS: + * + * grid fetches, `fields` declared -> object-schema path -> FLS re-applied + * host passes `data`, `fields` -> inline-data path -> FLS SKIPPED + * + * A security invariant may not be decided by the data's provenance. Maintainer + * ruling 2026-08-29: take the NARROW defence-in-depth fix. + * + * ## The limit is the point, not an optimisation + * + * Only keys the OBJECT DECLARES are judged. Host-joined and derived keys pass + * through untouched, because keeping them is this path's whole reason to exist + * (the object-schema path drops them outright: `if (!field) return;`). Judging + * them would silently drop derived columns — the failure the issue's own + * analysis warned about, and the one the ruling refuses by name. + * + * ## Why the stub `checkField` is an ALLOWLIST + * + * `PermissionProvider` (role-based) answers `true` for a field no policy + * mentions, so under it a derived key survives whether or not the guard judges + * it — the limit above would be untestable, and PIN 3 would be green in both + * worlds for the wrong reason. The stub therefore models the shape a server + * that ENUMERATES readable fields produces: deny anything not listed. That is + * the only policy shape under which the limit is load-bearing, so it is the one + * the limit is pinned against. The real provider still gets a case of its own + * (WIRING below), so nothing here rests solely on an imitation. + * + * ## ABLATION — guard removed (this file restored to d06059f24), this file only + * + * 2 red / 6 green. Both reds are the same assertion asked twice, once of the + * stub and once of the real provider — which is what says the fix is wired to + * the actual gate and not only to the shape of a double: + * + * x PIN 2 — a declared field the principal cannot read is NOT rendered + * -> AssertionError: expected [ 'Opportunity Name', 'Salary' ] to deeply + * equal [ 'Opportunity Name' ] + * x WIRING — the REAL PermissionProvider gate drops the denied column + * -> AssertionError: expected [ 'Opportunity Name', 'Salary' ] to deeply + * equal [ 'Opportunity Name' ] + * + * PIN 1, PIN 3, PIN 3b and the three controls DID NOT MOVE, which is the half + * that says the guard is narrow: they are the boundaries it must not cross, not + * restatements of it. Restoring the guard returns the file to 8 green. + * + * `vitest.config.mts` aliases every `@object-ui/*` specifier to that package's + * `src`, and this file imports `../ObjectGrid` relatively, so no build step + * stands between the edit and the run — the ablation reads source directly, and + * the mutation was confirmed on disk by blob hash before the run. + */ +import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from 'vitest'; +import { render, screen, waitFor, cleanup } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; + +/** + * Stable stub identity: `ObjectGrid` carries `perms` in `useCallback` / + * `useMemo` dependency arrays, so a fresh object per call would churn those + * memos on every render. Same hoisted-state shape as + * `inlineEditPermissionGate.test.tsx`. + */ +const { permsStub, state } = vi.hoisted(() => { + const state: { + /** Has `/me/permissions` answered yet? `false` = defer, filter nothing. */ + isLoaded: boolean; + /** Fields this principal may READ. Anything absent is denied. */ + readable: string[]; + /** Bypass the stub and run the REAL provider-backed hook instead. */ + useRealProvider: boolean; + } = { isLoaded: true, readable: [], useRealProvider: false }; + return { + state, + permsStub: { + get isLoaded() { return state.isLoaded; }, + checkField: (_object: string, field: string, action: string) => + action === 'read' ? state.readable.includes(field) : true, + check: () => ({ allowed: true }), + getFieldPermissions: () => [], + getRowFilter: () => undefined, + getObjectApiOperations: () => undefined, + roles: [], + userId: null, + systemPermissions: undefined, + hasCapabilities: () => true, + can: () => true, + cannot: () => false, + }, + }; +}); + +// The real module stays reachable so the WIRING case below exercises the ACTUAL +// `PermissionProvider` gate rather than a hand-written imitation of it. The +// real hook is invoked on every render so hook order is stable whichever branch +// is returned. +vi.mock('@object-ui/permissions', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + usePermissions: () => { + const real = actual.usePermissions(); + return state.useRealProvider ? real : (permsStub as any); + }, + }; +}); + +import { ObjectGrid } from '../ObjectGrid'; +import { registerAllFields } from '@object-ui/fields'; +import { ActionProvider } from '@object-ui/react'; +import { PermissionProvider } from '@object-ui/permissions'; +import type { ObjectPermissionConfig, RoleDefinition } from '@object-ui/types'; + +registerAllFields(); + +beforeAll(() => { + if (!Element.prototype.hasPointerCapture) { + Element.prototype.hasPointerCapture = vi.fn(() => false) as any; + } + if (!Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = vi.fn() as any; + } +}); + +beforeEach(() => { + state.isLoaded = true; + state.readable = []; + state.useRealProvider = false; +}); +afterEach(() => cleanup()); + +const OBJECT = 'opportunity'; + +/** + * `salary` is the field under test: DECLARED by the object and denied to this + * principal. `computed_score` is deliberately NOT declared — it is the + * host-joined / derived key the limit protects. + */ +const OPPORTUNITY_SCHEMA = { + name: OBJECT, + label: 'Opportunity', + fields: { + name: { type: 'text', label: 'Opportunity Name' }, + amount: { type: 'currency', label: 'Amount', currency: 'USD' }, + salary: { type: 'number', label: 'Salary' }, + }, +}; + +/** + * Rows exactly as a host hands them down — INCLUDING a payload for the denied + * field. That is what makes PIN 2 about the grid and not about the fetch: the + * value is right there in memory and must still not reach the screen. + */ +const HOST_ROWS = [ + { id: 'o-1', name: 'Acme expansion', amount: 42000, salary: 120000, computed_score: 'A+' }, +]; + +function makeDataSource(overrides: Record = {}) { + return { + // A host owns the fetch, so the grid must never call this. + find: vi.fn(async () => ({ data: [], total: 0 })), + getObjectSchema: vi.fn(async () => OPPORTUNITY_SCHEMA), + ...overrides, + } as any; +} + +/** + * The DATA columns' header labels, in render order. Two kinds of furniture are + * dropped: cells with no header text (selection checkbox, row-action kebab) and + * the row-index column, whose header is a literal `#`. + */ +function dataHeaders(container: HTMLElement): string[] { + return Array.from(container.querySelectorAll('thead th')) + .map((th) => (th.textContent ?? '').trim()) + .filter((text) => text.length > 0 && text !== '#'); +} + +function renderHostFedGrid( + schemaOverrides: Record = {}, + dataSource?: any, + wrap?: (el: React.ReactElement) => React.ReactElement, +) { + const ds = dataSource ?? makeDataSource(); + const schema: any = { type: 'object-grid', objectName: OBJECT, ...schemaOverrides }; + const inner = ( + + + + ); + const utils = render(wrap ? wrap(inner) : inner); + return { ...utils, ds }; +} + +describe('ObjectGrid — FLS on the inline-data path (#6723)', () => { + it('PIN 1 — a declared field the principal CAN read renders its column', async () => { + state.readable = ['name', 'amount']; + const { container, ds } = renderHostFedGrid({ fields: ['name', 'amount'] }); + + await waitFor(() => expect(ds.getObjectSchema).toHaveBeenCalledWith(OBJECT)); + await waitFor(() => + expect(dataHeaders(container)).toEqual(['Opportunity Name', 'Amount'])); + + // The positive half: a guard that dropped everything would also satisfy + // PIN 2, so the readable columns have to be pinned present. + expect(screen.getByText('Acme expansion')).toBeInTheDocument(); + expect(ds.find).not.toHaveBeenCalled(); + }); + + it('PIN 2 — a declared field the principal CANNOT read does not render, even with host data for it', async () => { + state.readable = ['name']; + const { container, ds } = renderHostFedGrid({ fields: ['name', 'salary'] }); + + await waitFor(() => expect(ds.getObjectSchema).toHaveBeenCalledWith(OBJECT)); + await waitFor(() => + expect(dataHeaders(container)).toEqual(['Opportunity Name'])); + + // The absence half, twice over: not the header, and not the VALUE the host + // already put in the payload. `120000` is `salary` on the only row. + expect(dataHeaders(container)).not.toContain('Salary'); + expect(screen.queryByText('120000')).toBeNull(); + }); + + it('PIN 3 — a key the object does not declare is unaffected by the gate', async () => { + // `computed_score` is not an object field, and the allowlist does not name + // it, so a guard that judged undeclared keys would drop it here. That drop + // is the failure mode the ruling refuses by name. + state.readable = ['name']; + const { container, ds } = renderHostFedGrid({ fields: ['name', 'computed_score'] }); + + await waitFor(() => expect(ds.getObjectSchema).toHaveBeenCalledWith(OBJECT)); + await waitFor(() => + expect(dataHeaders(container)).toEqual(['Opportunity Name', 'Computed score'])); + expect(screen.getByText('A+')).toBeInTheDocument(); + }); + + it('PIN 3b — an INHERITED name is not mistaken for a declared field', async () => { + // `objectSchema.fields.constructor` resolves through the prototype chain, + // so a truthiness read (`fields?.[name]`) would call it declared, ask the + // allowlist about it, and drop a derived column named `constructor`. The + // guard reads `hasOwnProperty` for exactly this reason. + state.readable = ['name']; + const rows = [{ ...HOST_ROWS[0], constructor: 'derived-value' }]; + const ds = makeDataSource(); + const { container } = render( + + + , + ); + + await waitFor(() => expect(ds.getObjectSchema).toHaveBeenCalledWith(OBJECT)); + await waitFor(() => expect(dataHeaders(container)).toContain('Constructor')); + }); + + /* ------------------------------------------------------------------ * + * Boundaries — green in BOTH worlds. Controls, not restatements. * + * ------------------------------------------------------------------ */ + + it('CONTROL — permissions not loaded yet: nothing is filtered', async () => { + // `/me/permissions` has not answered. The existing object-schema gate defers + // in exactly this case (`perms?.isLoaded &&`), and so must this one: a grid + // that blanked its columns while perms were in flight would be a worse + // defect than the one being fixed. + state.isLoaded = false; + state.readable = []; + const { container, ds } = renderHostFedGrid({ fields: ['name', 'salary'] }); + + await waitFor(() => expect(ds.getObjectSchema).toHaveBeenCalledWith(OBJECT)); + await waitFor(() => + expect(dataHeaders(container)).toEqual(['Opportunity Name', 'Salary'])); + }); + + it('CONTROL — no `objectName`: a pure inline-data grid is untouched', async () => { + // No object behind the data means no policy to apply. `checkField` needs an + // object to answer about, and the existing gate reads `schema.objectName` + // for the same reason. + state.readable = []; + const { container } = render( + + + , + ); + + await waitFor(() => expect(screen.getByText('Acme expansion')).toBeInTheDocument()); + expect(dataHeaders(container)).toEqual(['Name', 'Salary']); + }); + + it('CONTROL — object schema still in flight: the row-key fallback is untouched', async () => { + // `objectSchema` is `null`, so NOTHING is declared yet and every key is a + // derived key as far as this gate can tell. First paint keeps the row keys + // rather than blanking (the boundary #6677 pinned), and the gate must not + // move that. + state.readable = []; + const pending = new Promise(() => { /* never resolves */ }); + const ds = makeDataSource({ getObjectSchema: vi.fn(() => pending) }); + const { container } = renderHostFedGrid({}, ds); + + await waitFor(() => expect(screen.getByText('Acme expansion')).toBeInTheDocument()); + expect(dataHeaders(container)).toEqual(['Id', 'Name', 'Amount', 'Salary', 'Computed score']); + }); + + it('WIRING — the REAL PermissionProvider gate drops the denied declared column', async () => { + // Everything above runs against the stub. This case runs the actual + // `@object-ui/permissions` provider end to end, so the fix is pinned to the + // real `checkField` and not only to the shape of a double. + state.useRealProvider = true; + const roles: RoleDefinition[] = [{ name: 'restricted', label: 'Restricted' }]; + const permissions: ObjectPermissionConfig[] = [ + { + object: OBJECT, + roles: { + restricted: { + actions: ['read'], + fieldPermissions: [{ field: 'salary', read: false, write: false }], + }, + }, + }, + ]; + + const { container, ds } = renderHostFedGrid({ fields: ['name', 'salary'] }, undefined, (el) => ( + + {el} + + )); + + await waitFor(() => expect(ds.getObjectSchema).toHaveBeenCalledWith(OBJECT)); + await waitFor(() => expect(dataHeaders(container)).toEqual(['Opportunity Name'])); + expect(screen.queryByText('120000')).toBeNull(); + }); +}); diff --git a/packages/plugin-list/src/__tests__/ListView.inlineFlsNoop-6723.test.tsx b/packages/plugin-list/src/__tests__/ListView.inlineFlsNoop-6723.test.tsx new file mode 100644 index 000000000..9cd3043de --- /dev/null +++ b/packages/plugin-list/src/__tests__/ListView.inlineFlsNoop-6723.test.tsx @@ -0,0 +1,185 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#6723, PIN 4 — the grid's new inline-data FLS gate is a NO-OP through + * `ListView`, and that is measured here rather than assumed. + * + * ## Why this pin exists at all + * + * `ListView` filters its own `effectiveFields` through `checkField` before + * forwarding, so the grid-side gate #6723 adds is redundant on this path. The + * ruling keeps both ("redundancy is depth"), which makes "redundant" a claim + * about behaviour that has to hold: adding a second gate must not change one + * pixel of what this host renders. + * + * ## Why it cannot fire here — the mechanism, pinned by the assertions + * + * `ListView` reaches the grid two ways, and neither one arrives at the + * inline-data branch carrying a declared-but-denied key: + * + * - AUTHORED columns: it forwards `columns: effectiveFields` (already + * FLS-filtered) alongside the rows, and `generateColumns()` returns from + * the `normalizeColumns(schemaColumns)` branch before the inline-data + * branch is reached. Pinned below by exact header equality with the + * filtered projection. + * - UNAUTHORED: it forwards `fields: undefined, columns: undefined` + * (objectui#6598), so `rowKeysWouldOutrankSchemaPolicy` is true once the + * schema has loaded and the grid falls through to the OBJECT-SCHEMA path — + * the one that has always re-applied FLS. Pinned below by the absence of + * `Id`: `id` is `hidden: true`, which the object-schema policy drops and + * the inline-data path (row keys) would keep. That absence is what makes + * this case a mechanism assertion and not just a header count. + * + * ## Reading this file under ablation + * + * Every case here is green with the #6723 guard present AND with it removed — + * that identity IS pin 4. The CONTROL case is what keeps the two denials above + * from being vacuous: with no permission policy mounted, `Amount` renders in + * both shapes, so the assertions measure the field gate rather than a render + * that never produced the column. + * + * The grid here is the REAL `@object-ui/plugin-grid` renderer, not a stub: this + * file is listed in `heavyDomTests` (vitest.config.mts), whose setup imports + * plugin-grid for its side-effect registration — the same route + * `ListView.crossPageSelectAll.test.tsx` and the two #6598 files take. A stub + * grid cannot observe a no-op inside the grid. + */ +import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from 'vitest'; +import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import React from 'react'; +import { I18nProvider, SchemaRendererProvider } from '@object-ui/react'; +import { PermissionProvider } from '@object-ui/permissions'; +import { ListView } from '../ListView'; +import type { ListViewSchema, ObjectPermissionConfig, RoleDefinition } from '@object-ui/types'; + +const OBJECT = 'opportunity'; + +beforeAll(() => { + if (!Element.prototype.hasPointerCapture) { + Element.prototype.hasPointerCapture = vi.fn(() => false) as any; + } + if (!Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = vi.fn() as any; + } +}); +beforeEach(() => { vi.clearAllMocks(); }); +afterEach(() => { cleanup(); }); + +/** + * `id` is `hidden: true` on purpose: the object-schema default-columns policy + * drops it and the inline-data row-key derivation keeps it, so its presence or + * absence tells the two grid paths apart from the outside. + */ +const OPPORTUNITY_FIELDS = { + id: { type: 'text', label: 'Id', hidden: true }, + name: { type: 'text', label: 'Opportunity Name' }, + amount: { type: 'currency', label: 'Amount' }, +}; + +function makeDataSource() { + return { + find: vi.fn(async () => ({ + data: [{ id: 'o-1', name: 'Acme expansion', amount: 1000 }], + total: 1, + hasMore: false, + })), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: async (name: string) => ({ name, label: 'Opportunity', fields: OPPORTUNITY_FIELDS }), + } as any; +} + +const listSchema = (over: Record = {}): ListViewSchema => + ({ type: 'list-view', objectName: OBJECT, ...over }) as unknown as ListViewSchema; + +/** Denies `amount` — a field the object DECLARES — to the current principal. */ +const ROLES: RoleDefinition[] = [{ name: 'restricted', label: 'Restricted' }]; +const DENY_AMOUNT: ObjectPermissionConfig[] = [ + { + object: OBJECT, + roles: { + restricted: { + actions: ['read'], + fieldPermissions: [{ field: 'amount', read: false, write: false }], + }, + }, + }, +]; + +function renderList(schema: ListViewSchema, permissions?: ObjectPermissionConfig[]) { + const ds = makeDataSource(); + const inner = ( + + + + + + ); + const utils = render( + permissions + ? ( + + {inner} + + ) + : inner, + ); + return { ...utils, ds }; +} + +/** Data column headers in render order — furniture and the `#` index dropped. */ +function dataHeaders(container: HTMLElement): string[] { + return Array.from(container.querySelectorAll('thead th')) + .map((th) => (th.textContent ?? '').trim()) + .filter((text) => text.length > 0 && text !== '#'); +} + +describe('ListView-hosted grid — the #6723 inline-data FLS gate is a no-op here', () => { + it('AUTHORED columns: the host pre-filtered, and the grid renders exactly what it was handed', async () => { + const { container } = renderList(listSchema({ columns: ['name', 'amount'] }), DENY_AMOUNT); + + await waitFor(() => expect(screen.getByText('Acme expansion')).toBeInTheDocument()); + // `effectiveFields` already dropped `amount`, so the grid never sees it. + await waitFor(() => expect(dataHeaders(container)).toEqual(['Opportunity Name'])); + expect(screen.queryByText('1,000.00')).toBeNull(); + }); + + it('UNAUTHORED: the grid takes the OBJECT-SCHEMA path, whose own FLS gate is the one that answers', async () => { + const { container } = renderList(listSchema(), DENY_AMOUNT); + + await waitFor(() => expect(screen.getByText('Acme expansion')).toBeInTheDocument()); + const headers = await waitFor(() => { + const h = dataHeaders(container); + expect(h.length).toBeGreaterThan(0); + return h; + }); + + expect(headers).toContain('Opportunity Name'); + // The pre-existing object-schema gate dropped it. + expect(headers).not.toContain('Amount'); + // MECHANISM: `id` is `hidden`, so the object-schema policy excludes it while + // the inline-data row-key derivation would include it. Its absence is what + // shows the inline branch (the one #6723 touches) did not run here. + expect(headers).not.toContain('Id'); + }); + + it('CONTROL: with no permission policy mounted, `Amount` renders in both shapes', async () => { + // Without this the two denials above would also pass on a grid that failed + // to render the column for some unrelated reason. + const authored = renderList(listSchema({ columns: ['name', 'amount'] })); + await waitFor(() => expect(dataHeaders(authored.container)).toEqual(['Opportunity Name', 'Amount'])); + cleanup(); + + const unauthored = renderList(listSchema()); + await waitFor(() => expect(dataHeaders(unauthored.container)).toContain('Amount')); + expect(dataHeaders(unauthored.container)).toContain('Opportunity Name'); + }); +}); diff --git a/vitest.config.mts b/vitest.config.mts index 5c1a76e06..96383af6b 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -139,6 +139,12 @@ const heavyDomTests = [ // all eight at once, and a stub grid stands in for one side of the // ListView/ObjectGrid disagreement it has to observe. 'packages/plugin-list/src/__tests__/htmlTierColumnSpellings-6598.test.tsx', + // objectui#6723 — PIN 4: the grid's new inline-data FLS gate must be a + // no-op through this host, which pre-filters its own fields. "No-op" + // is a claim about the REAL grid's rendered headers, so a stub grid + // (what the sibling handoff pins register) cannot observe it. Same + // reason and same route as the three entries above. + 'packages/plugin-list/src/__tests__/ListView.inlineFlsNoop-6723.test.tsx', ]; export default defineConfig({