From 70daedd7f244a9f4cb5cbb4163bcd7e535cda16f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 09:05:14 +0000 Subject: [PATCH] =?UTF-8?q?fix(console):=20=E6=8A=8A=20system/permissions?= =?UTF-8?q?=20=E4=B8=8E=20Permissions=20=E8=AE=A1=E6=95=B0=E4=B8=80?= =?UTF-8?q?=E5=B9=B6=E6=8E=A5=E5=88=B0=20sys=5Fpermission=5Fset=20(#3655)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fifth and last of the five `system/*` navigation targets. `system/permissions` and the hub's Permissions count were both held back (PR #3673 / #3680) because the framework splits this console's "Permissions" into `sys_capability` (ADR-0066 layer 1, the definition registry) and `sys_permission_set` (layer 2, the grant container the permissions docs call "the only capability container"). objectui#3655 decided it as `sys_permission_set`: the card reads "Manage permission rules and assignments", and rules-and-assignments is layer 2. - route: `system/permissions` -> `SystemObjectRedirect objectName="sys_permission_set"` - count: `dataSource.find('sys_permission')` -> `'sys_permission_set'` - the two MEASUREMENT pins written to be replaced when this landed are replaced; the length-split fact they also carried is re-pinned on segments that are still undeclared in both route tables. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GTRjn8xBqp75dk7kFupVRt --- .changeset/system-hub-permissions-leg-3655.md | 17 +++ apps/console/src/AppContent.tsx | 56 ++++--- .../AppContent.systemHubRoutes.test.tsx | 91 +++++++---- .../src/pages/system/SystemHubPage.tsx | 41 ++--- .../__tests__/SystemHubPage.counts.test.tsx | 144 +++++++++++------- 5 files changed, 224 insertions(+), 125 deletions(-) create mode 100644 .changeset/system-hub-permissions-leg-3655.md diff --git a/.changeset/system-hub-permissions-leg-3655.md b/.changeset/system-hub-permissions-leg-3655.md new file mode 100644 index 000000000..98bf93d17 --- /dev/null +++ b/.changeset/system-hub-permissions-leg-3655.md @@ -0,0 +1,17 @@ +--- +'@object-ui/console': patch +--- + +Point System Hub's Permissions card — both its link and its count — at `sys_permission_set`, closing the last of the five `system/*` navigation targets (objectui#3655). + +Four of those URLs became redirects in an earlier change; `system/permissions` was deliberately held back, and so was the count beside it, because the framework splits what this console calls "Permissions" into two Setup entries and picking one would have silently bound every click, bookmark and badge to a surface nobody chose: + +- `sys_capability` — ADR-0066 layer 1, the definition registry of "what can be done". Its own docblock notes it is what the ADR "loosely floats" as `sys_permission`, which is the name the retired page and the count query both used, so lineage pointed here. +- `sys_permission_set` — ADR-0066 layer 2, the grant container the permissions docs call "the only capability container" (object CRUD, field security, access depth, system capabilities), so function pointed here. + +It is decided as `sys_permission_set`: the card reads "Manage permission rules and assignments", and rules-and-assignments is layer 2 — a capability is what a permission set references by name, not what an administrator is assigned. Two user-visible consequences: + +- `/apps/:app/system/permissions` now forwards in one hop to `/apps/:app/sys_permission_set` instead of being rewritten to `…/system/record/permissions` and rendering a record detail page for an object literally named `system` — a dead link that read as a backend fault. +- The Permissions card's badge shows the real number of permission sets. It previously counted `sys_permission`, an object the framework does not register; the adapter absorbs that `404` into an empty page on purpose, so the card printed a confident `0` no administrator could tell apart from "there really are none". + +Recorded as a transitional alias. Retiring this hand-written card wall along with the hub (already `@deprecated` in favour of the metadata-driven navigation) remains open and does not conflict — a redirect keeps old bookmarks resolving either way. diff --git a/apps/console/src/AppContent.tsx b/apps/console/src/AppContent.tsx index 0caa449c3..f3216dcc4 100644 --- a/apps/console/src/AppContent.tsx +++ b/apps/console/src/AppContent.tsx @@ -102,11 +102,11 @@ function MetadataRedirect() { } /** - * Forwards the retired `system/{users,organizations,roles,positions}` console - * pages onto the canonical object routes served by the generic + * Forwards the retired `system/{users,organizations,roles,positions,permissions}` + * console pages onto the canonical object routes served by the generic * `…/:objectName` route in `@object-ui/app-shell` (objectui#3655). * - * These four were real routes until `apps/console` was slimmed for third-party + * These five were real routes until `apps/console` was slimmed for third-party * customisation (cccdf84d7): "Delete bespoke /system/* wrapper pages * (User/Role/Permission/Audit/Org) … these objects are now contributed by * framework plugins (plugin-auth, -security, -audit) into the Setup app @@ -134,9 +134,11 @@ function MetadataRedirect() { * and the hub's "Positions" are the same * surface under old/new vocabulary) * positions -> sys_position (`nav_positions`) - * - * `system/permissions` is deliberately NOT redirected here — see the route - * block below. + * permissions -> sys_permission_set(`nav_permission_sets`; this one was + * held back in PR #3673 and is resolved + * by objectui#3655's decision A — the + * reasoning is recorded at the route + * block below) * * Same shape as `ObjectRedirect` / `MetadataRedirect` above (a legacy URL is * translated, the page is not resurrected), including their treatment of @@ -188,22 +190,40 @@ export const systemRoutes = ( } /> } /> {/* Legacy URL redirects → the framework-owned system objects (objectui#3655). - `system/permissions` is absent on purpose: unlike the four above it has - no single measured equivalent. The framework splits what this console - calls "Permissions" into TWO Setup entries — `sys_capability` - (Capabilities: the definition registry, and the object whose own - docblock says it is what the ADR "loosely floats" as `sys_permission`, - which is the name the retired page and `SystemHubPage`'s count query - both use) and `sys_permission_set` (Permission Sets: the grant - container, which is what "Manage permission rules and assignments" and - admin CRUD describe). Picking one silently sends every future click and - bookmark to a surface the maintainer never chose, so it is left to be - decided rather than guessed; the test file pins its unchanged landing so - the gap stays visible instead of reading as an oversight. */} + All five resolve now. `system/permissions` was the one held back in PR + #3673: the framework splits what this console calls "Permissions" into + TWO Setup entries, and picking one on a hunch would have bound every + future click and bookmark to a surface nobody chose. + + `sys_capability` (nav "Capabilities") — ADR-0066 layer 1, the + DEFINITION registry of "what can be done". Its own docblock says it + is what the ADR "loosely floats" as `sys_permission` — the name the + retired page used — so LINEAGE points here. + `sys_permission_set` (nav "Permission Sets") — ADR-0066 layer 2, the + grant/assignment container the permissions docs call "the only + capability container" (object CRUD + field security + access depth + + system capabilities). FUNCTION points here. + + Decided as A, `sys_permission_set` (objectui#3655): the card that emits + this URL reads "Manage permission rules and assignments", and + rules-and-assignments is layer 2 — the definition catalog is what you + reference BY NAME from a set, not what you assign. Deliberately a + transitional alias: option C (retiring this bespoke card wall together + with the hub, already `@deprecated`) stays open and does not conflict, + because a redirect keeps old bookmarks resolving either way. + + One correction worth leaving here, since it circulated while this was + open: the "capabilities are platform-locked, permission sets are the + admin-CRUD one" reading does NOT survive a re-read of the framework. + BOTH objects are `managedBy: 'config'` with `protection.lock: + 'no-overlay'`, and both docblocks say the lock is on the SCHEMA while + tenants/admins may add rows. The layer-1 / layer-2 split above is the + distinction that actually holds. */} } /> } /> } /> } /> + } /> ); diff --git a/apps/console/src/__tests__/AppContent.systemHubRoutes.test.tsx b/apps/console/src/__tests__/AppContent.systemHubRoutes.test.tsx index f110b4d06..6e1e1a4d6 100644 --- a/apps/console/src/__tests__/AppContent.systemHubRoutes.test.tsx +++ b/apps/console/src/__tests__/AppContent.systemHubRoutes.test.tsx @@ -39,11 +39,11 @@ * * `ChainRecorder` records every location the router settles on, so "one hop" is * an assertion rather than an inference; the pre-fix landings above are - * reachable by deleting the four `system/*` routes from `systemRoutes` (the + * reachable by deleting the five `system/*` routes from `systemRoutes` (the * `object-view` probes go red and the chain returns to `Page not found` / * `…/system/record/`). * - * ## Two branches, and the `permissions` gap + * ## Two branches * * `AppContent` has two route tables and this host passes the same fragment to * both. With an active app the redirect target resolves (`:objectName` renders @@ -52,10 +52,24 @@ * and pinned below rather than asserted away, because a deployment with no apps * in its metadata also has no `sys_user` to show. * - * `system/permissions` is deliberately still broken: the framework splits this - * console's "Permissions" into `sys_capability` and `sys_permission_set` and - * nothing in the issue picks one. Its unchanged landing is pinned so the gap is - * visible, and so whichever mapping is chosen has to come here to change it. + * ## The `permissions` leg, closed + * + * Four of the five landed in PR #3673. `system/permissions` was held back and + * its broken landing PINNED, because the framework splits this console's + * "Permissions" into two Setup entries (`sys_capability`, ADR-0066 layer 1 — + * the definition registry; `sys_permission_set`, layer 2 — the grant container + * the permissions docs call "the only capability container") and guessing would + * have bound every click and bookmark to a surface nobody chose. objectui#3655 + * decided it as `sys_permission_set`: the card that emits this URL says "Manage + * permission rules and assignments", which is layer 2. Those pins are therefore + * REPLACED here, not merely kept passing — that was their stated purpose. + * + * What the pins measured beyond the gap itself — that an undeclared segment + * fails in two different ways depending on its LENGTH — is still true and still + * measured, now on a hypothetical pair that is undeclared in both branches + * (`system/teams`, 5 chars, and `system/workgroups`, 10). Re-pointing them was + * the alternative to letting the length asymmetry lose its only coverage the + * moment its last real specimen got a route. */ import '@testing-library/jest-dom/vitest'; @@ -214,16 +228,18 @@ beforeEach(() => { describe('system-hub entries reach the framework system objects (objectui#3655)', () => { /** - * The four whose equivalent the framework names unambiguously. `roles` and - * `positions` converge on ONE object on purpose: ADR-0090 D3 renamed - * `sys_role` -> `sys_position`, so the sidebar's "Roles" and the hub's - * "Positions" are the same surface in old and new vocabulary. + * All five. `roles` and `positions` converge on ONE object on purpose: + * ADR-0090 D3 renamed `sys_role` -> `sys_position`, so the sidebar's "Roles" + * and the hub's "Positions" are the same surface in old and new vocabulary. + * `permissions` -> `sys_permission_set` is objectui#3655's decision A (see + * the file docblock); it landed one PR after the other four. */ it.each([ ['/apps/setup/system/users', '/apps/setup/sys_user', 'sys_user'], ['/apps/setup/system/organizations', '/apps/setup/sys_organization', 'sys_organization'], ['/apps/setup/system/roles', '/apps/setup/sys_position', 'sys_position'], ['/apps/setup/system/positions', '/apps/setup/sys_position', 'sys_position'], + ['/apps/setup/system/permissions', '/apps/setup/sys_permission_set', 'sys_permission_set'], ])('%s reaches %s in ONE hop', async (url, target, objectName) => { renderConsoleAt(url); @@ -253,34 +269,34 @@ describe('system-hub entries reach the framework system objects (objectui#3655)' }); /** - * MEASUREMENT — deliberately unresolved. "Permissions" has TWO candidate - * equivalents in the framework (`sys_capability`, the definition registry and - * the object its own docblock identifies as the ADR's `sys_permission`; and - * `sys_permission_set`, the grant container the permissions docs call "the - * only capability container"). Choosing one here would silently commit every - * click and bookmark to a surface nobody picked, so this leg keeps its - * pre-fix landing until the mapping is decided. This assertion is expected to - * be REPLACED, not merely to keep passing. + * MEASUREMENT — the length-dependent split this issue is really about, kept + * alive now that its last real specimen has a route. `permissions` used to + * stand here: 11 chars, judged a record id, rewritten to + * `…/system/record/permissions` and rendered as a record of an object + * literally named `system`. That pin was written to be REPLACED once the + * mapping was decided (objectui#3655 → `sys_permission_set`), and the one-hop + * case above is its replacement. + * + * The asymmetry itself outlives it, so it is re-pinned on a hypothetical pair + * that is undeclared in both route tables: `workgroups` (10 chars) here and + * `teams` (5) below — the same concept spelled two lengths, producing two + * unrelated failure screens. Nothing here asks for `looksLikeRecordId` to + * change; that is a separate question. The point is that the next reader + * still finds it measured against the shipped routes rather than described. */ - it('MEASUREMENT: system/permissions still lands on a record of the object `system`', async () => { - renderConsoleAt('/apps/setup/system/permissions'); + it('MEASUREMENT: an undeclared LONG system segment lands on a record of the object `system`', async () => { + renderConsoleAt('/apps/setup/system/workgroups'); const probe = await screen.findByTestId('record-detail-view'); expect(probe).toHaveTextContent('"objectName":"system"'); - expect(probe).toHaveTextContent('"recordId":"permissions"'); + expect(probe).toHaveTextContent('"recordId":"workgroups"'); expect(chain).toEqual([ - '/apps/setup/system/permissions', - '/apps/setup/system/record/permissions', + '/apps/setup/system/workgroups', + '/apps/setup/system/record/workgroups', ]); }); - /** - * MEASUREMENT — the length-dependent split this issue is really about, taken - * on the one URL the fix does not cover. `permissions` (11 chars) is judged a - * record id; a hypothetical 5-char sibling would not be. Nothing here asks - * for `looksLikeRecordId` to change — it is a separate question — but the - * asymmetry is recorded so the next reader does not have to rediscover it. - */ + /** MEASUREMENT — the short half of the pair described just above. */ it('MEASUREMENT: an undeclared SHORT system segment still reaches Page not found', async () => { renderConsoleAt('/apps/setup/system/teams'); @@ -310,6 +326,7 @@ describe('zero-app branch — measured, not asserted away (objectui#3655)', () = ['/apps/setup/system/organizations', '/apps/setup/sys_organization'], ['/apps/setup/system/roles', '/apps/setup/sys_position'], ['/apps/setup/system/positions', '/apps/setup/sys_position'], + ['/apps/setup/system/permissions', '/apps/setup/sys_permission_set'], ])('%s still redirects, and the target is the no-apps empty state', async (url, target) => { metadataApps = []; renderConsoleAt(url); @@ -319,12 +336,22 @@ describe('zero-app branch — measured, not asserted away (objectui#3655)', () = expect(screen.queryByTestId('object-view')).not.toBeInTheDocument(); }); + /** + * MEASUREMENT — same re-pointing as the with-app half. This case used to run + * on `system/permissions`, which is now declared and so redirects here too + * (the row added above). Its subject was never that URL, though: it is that + * on a zero-app deployment even a LONG undeclared word reaches + * `RouteNotFound` and never a record page, because `ShorthandRecordRedirect` + * is not declared in this branch at all — i.e. the length split measured in + * the with-app describe does not exist here. `workgroups` (10 chars) keeps + * that fact pinned on a segment that is still undeclared. + */ it('MEASUREMENT: with no apps, an undeclared system URL reaches Page not found, never a record page', async () => { metadataApps = []; - renderConsoleAt('/apps/setup/system/permissions'); + renderConsoleAt('/apps/setup/system/workgroups'); expect(await screen.findByText('Page not found')).toBeInTheDocument(); expect(screen.queryByTestId('record-detail-view')).not.toBeInTheDocument(); - expect(chain).toEqual(['/apps/setup/system/permissions']); + expect(chain).toEqual(['/apps/setup/system/workgroups']); }); }); diff --git a/apps/console/src/pages/system/SystemHubPage.tsx b/apps/console/src/pages/system/SystemHubPage.tsx index c79ab2fe2..579eb55cf 100644 --- a/apps/console/src/pages/system/SystemHubPage.tsx +++ b/apps/console/src/pages/system/SystemHubPage.tsx @@ -101,22 +101,25 @@ export function SystemHubPage() { // it only covers non-404 rejections. // // Verified against the framework's object registry: - // sys_user packages/platform-objects/src/identity/sys-user.object.ts - // sys_organization packages/platform-objects/src/identity/sys-organization.object.ts - // sys_position packages/plugins/plugin-security/src/objects/sys-position.object.ts - // sys_audit_log packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts + // sys_user packages/platform-objects/src/identity/sys-user.object.ts + // sys_organization packages/platform-objects/src/identity/sys-organization.object.ts + // sys_position packages/plugins/plugin-security/src/objects/sys-position.object.ts + // sys_permission_set packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts + // sys_audit_log packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts // - // `sys_permission` is the one exception and is deliberately left alone. - // The framework has no such object; it splits that surface into - // `sys_capability` (lineage — its docblock names itself "not - // sys_permission as the ADR loosely floats") and `sys_permission_set` - // (function — the admin-managed grant container). Both would render, so - // picking one here would silently commit this card to a surface the - // maintainer has not chosen; that call is pending on objectui#3655 - // (A: sys_permission_set / B: sys_capability / C: retire this bespoke - // card with the hub itself, which is already `@deprecated` above). Until - // it lands the Permissions count stays a known-wrong `0` — pinned by a - // MEASUREMENT case in this page's test rather than quietly re-aimed. + // Permissions used to ask for `sys_permission`, which the framework does + // NOT register, so this card read a known-wrong `0` on every deployment + // (objectui#3670 left it pinned rather than re-aimed). The framework + // splits that surface in two — `sys_capability` (ADR-0066 layer 1, the + // definition registry, and the object whose docblock says it is what the + // ADR "loosely floats" as `sys_permission`) and `sys_permission_set` + // (ADR-0066 layer 2, the grant container the permissions docs call "the + // only capability container"). Both exist, so either would have rendered + // a plausible number and quietly decided which surface this card means. + // objectui#3655 decided it: `sys_permission_set`, because the card says + // "Manage permission rules and assignments" and rules-and-assignments is + // layer 2. The same decision aimed this page's `system/permissions` link + // at that object, so badge and destination now describe one thing. // // TODO: Replace with count-specific API endpoint when available // @@ -136,13 +139,15 @@ export function SystemHubPage() { // // A 404 still does not reach here and still renders `0`: the adapter // resolves unregistered objects as an empty page on purpose (see above). - // That is its contract, not a failure — the one card still riding on it - // is Permissions, which is objectui#3655's decision to close. + // That is its contract, not a failure. No card rides on it by mistake any + // more — every name below is registered — but a deployment that does not + // install a plugin (e.g. plugin-security, which owns `sys_position` and + // `sys_permission_set`) still gets `0` rather than "unavailable". const [usersRes, orgsRes, positionsRes, permsRes, logsRes] = await Promise.all([ dataSource.find('sys_user').catch(() => null), dataSource.find('sys_organization').catch(() => null), dataSource.find('sys_position').catch(() => null), - dataSource.find('sys_permission').catch(() => null), + dataSource.find('sys_permission_set').catch(() => null), dataSource.find('sys_audit_log').catch(() => null), ]); setCounts({ diff --git a/apps/console/src/pages/system/__tests__/SystemHubPage.counts.test.tsx b/apps/console/src/pages/system/__tests__/SystemHubPage.counts.test.tsx index fb11cb37a..3f6fd842f 100644 --- a/apps/console/src/pages/system/__tests__/SystemHubPage.counts.test.tsx +++ b/apps/console/src/pages/system/__tests__/SystemHubPage.counts.test.tsx @@ -14,16 +14,20 @@ * none" — on a single-org deployment where `sys_organization` always has at * least one row. * - * This file therefore asserts two different things, and the difference is the - * point: - * - Organizations is FIXED — the count now travels through `sys_organization` - * and shows the real number. - * - Permissions is only PINNED — the query still says `sys_permission`, an - * object the framework does not have, so that card still reads 0. Which - * object it should read is a maintainer decision open on objectui#3655 - * (A `sys_permission_set` / B `sys_capability` / C retire the card). The - * MEASUREMENT cases below hold that gap visible instead of letting it read - * like an oversight. + * Both halves are now FIXED, one issue apart: + * - Organizations travels through `sys_organization` (objectui#3670). + * - Permissions travelled through `sys_permission`, an object the framework + * does not register, so that card read a confident `0` on every deployment. + * PR #3680 pinned it rather than re-aiming it, because the framework splits + * that surface in two and picking one is a product decision: `sys_capability` + * (ADR-0066 layer 1, the definition registry — and the object whose docblock + * says it is what the ADR "loosely floats" as `sys_permission`) versus + * `sys_permission_set` (layer 2, the grant container the permissions docs + * call "the only capability container"). objectui#3655 decided it as + * `sys_permission_set`, matching the card's own copy ("Manage permission + * rules and assignments") and the destination its link now redirects to. + * The MEASUREMENT that held that gap open is REPLACED below — which is what + * it was written for — not left to keep passing on a stale expectation. * * jsdom integration test — no backend. The adapter is stubbed at its real * contract boundary (see the `find` stub: unknown object RESOLVES empty, it @@ -40,8 +44,15 @@ * class the adapter does not absorb) leaves that card's count `null`, and the * badge's existing `count !== null` branch drops the badge. The MEASUREMENT * case that pinned the old collapse-into-0 was rewritten there, as its own - * comment asked for; the other two MEASUREMENTs still pin objectui#3655's gap - * and are untouched. + * comment asked for. + * + * That block keeps asserting Permissions as a NEIGHBOUR — the card that must + * still show its real number while another card's lookup fails — so its + * expectation moved from `0 permissions` to `5 permissions` when objectui#3655 + * re-aimed the query. Its three-row matrix case also needed a fresh specimen + * for "unregistered object": there is no longer a misspelled name to supply + * one, so it drops `sys_permission_set` from the fixture registry, i.e. a + * deployment without plugin-security. Both are noted at their call sites. */ import '@testing-library/jest-dom/vitest'; @@ -66,7 +77,9 @@ const { state, ADAPTER, FRAMEWORK_OBJECT_NAMES } = vi.hoisted(() => { * * `sys_org` and `sys_permission` are absent on purpose: a repo-wide grep for * either as an object name returns zero hits in the framework, which is - * exactly what makes them unqueryable. + * exactly what makes them unqueryable. Neither is asked for any more — the + * audit case below asserts that the set of queried-but-unregistered names is + * now EMPTY. */ const FRAMEWORK_OBJECT_NAMES = [ 'sys_user', @@ -190,15 +203,15 @@ const COUNTED_CARDS: ReadonlyArray = [ ]; /** - * The object names the page asks for, in call order — including - * `sys_permission`, which the framework does not register (objectui#3655) and - * so is absent from the fixture registry above. + * The object names the page asks for, in call order. Every one of them is a + * name the framework registers — see `FRAMEWORK_OBJECT_NAMES` and the audit + * case that ties the two lists together. */ const QUERIED_OBJECT_NAMES = [ 'sys_user', 'sys_organization', 'sys_position', - 'sys_permission', + 'sys_permission_set', 'sys_audit_log', ]; @@ -221,7 +234,7 @@ describe('System Hub card counts — object names (objectui#3670)', () => { expect(within(screen.getByTestId('hub-card-audit-log')).getByText('6 entries')).toBeInTheDocument(); }); - it('asks for exactly five names, and only one of them is missing from the framework', async () => { + it('asks for exactly five names, every one of them registered by the framework', async () => { renderHub(); // Settle on a card this audit does not judge, so a wrong name shows up as a // diff on the call list below rather than as a missing badge elsewhere. @@ -231,50 +244,59 @@ describe('System Hub card counts — object names (objectui#3670)', () => { 'sys_user', 'sys_organization', 'sys_position', - 'sys_permission', + 'sys_permission_set', 'sys_audit_log', ]); - // The whole audit in one assertion: after this change the only name the - // framework does not register is the one parked on objectui#3655. - expect(state.calls.filter((name) => !FRAMEWORK_OBJECT_NAMES.includes(name))).toEqual([ - 'sys_permission', - ]); + // The whole audit in one assertion. It read `['sys_permission']` while + // objectui#3655 was open and `['sys_org', 'sys_permission']` before + // objectui#3670; with the permissions leg decided, no card asks for a name + // the framework does not have. + expect(state.calls.filter((name) => !FRAMEWORK_OBJECT_NAMES.includes(name))).toEqual([]); }); - // ── MEASUREMENT ──────────────────────────────────────────────────────────── - // The two cases below pin the CURRENT behaviour, not the desired one. They - // exist so the remaining gap is visible in the suite instead of being read as - // a missed line, and so whoever resolves objectui#3655 has a failing anchor - // to rewrite rather than a silent pass. - // - // There were three. The third — "a non-404 failure is collapsed into 0 as - // well, with no error affordance" — named objectui#3679 as the work that - // would rewrite it, and that work is done: it now lives in the next describe - // block with its expectation inverted. These two stay measurements because - // objectui#3655 is still open. - - it('MEASUREMENT: Permissions still reads 0 while both candidate objects hold rows', async () => { + it('counts Permissions through sys_permission_set, the container the card describes', async () => { renderHub(); - // `sys_capability` (7 rows) and `sys_permission_set` (5 rows) both exist in - // this fixture, and the card shows neither — it asks for `sys_permission`, - // which the framework does not have. Aiming it at either candidate here - // would decide objectui#3655's A/B/C on the maintainer's behalf, so the - // query is deliberately untouched. When that decision lands, THIS is the - // case to rewrite (expected: `7 permissions` for B, `5 permissions` for A, - // or the card gone entirely for C). - expect(await badge('hub-card-permissions', '0 permissions')).toBeInTheDocument(); - expect(state.calls).toContain('sys_permission'); + // The replacement for "MEASUREMENT: Permissions still reads 0 while both + // candidate objects hold rows", which pinned the pre-decision landing and + // said in so many words that it was written to be rewritten. Same fixture: + // `sys_permission_set` (5 rows) and `sys_capability` (7 rows) both exist, + // so the badge's number identifies WHICH one the page chose — a fixture + // where only one had rows would let either name pass. 5, not 7, and not the + // `0` that `sys_permission` produced. + expect(await badge('hub-card-permissions', '5 permissions')).toBeInTheDocument(); + expect(state.calls).toContain('sys_permission_set'); + expect(state.calls).not.toContain('sys_permission'); + // Layer 1 stays unqueried: capabilities are what a permission set + // REFERENCES by name, not what an admin is assigned (ADR-0066). expect(state.calls).not.toContain('sys_capability'); - expect(state.calls).not.toContain('sys_permission_set'); }); + // ── MEASUREMENT ──────────────────────────────────────────────────────────── + // The case below pins CURRENT behaviour that no open issue is fixing, so it + // is a measurement rather than an acceptance: it records a consequence of the + // adapter's absorb-the-404 contract, which this page does not own. + // + // There were three others. "A non-404 failure is collapsed into 0 as well" + // was rewritten by objectui#3679 (next describe block). The two that held + // objectui#3655's permissions gap open are gone with the gap: one became the + // `5 permissions` case above, the other is this one, re-pointed — see below. + it('MEASUREMENT: an unregistered object and a genuinely empty one render the identical badge', async () => { - // `sys_audit_log` exists but has no rows; `sys_permission` does not exist - // at all. Two different facts, one indistinguishable pixel — this is why - // the wrong name survived so long, and it is unchanged by this PR (fixing - // it means changing the error handling, a separate class of work). + // Two different facts, one indistinguishable pixel. This is why a wrong + // object name survived so long, and nothing here fixes it — the 404 is + // absorbed inside the adapter by design, so telling the two apart would + // mean changing that contract, not this page. + // + // It used to be demonstrated with `sys_permission`, a name that existed + // nowhere. Now that all five names are real, the unregistered case needs a + // real occasion, and there is one: `sys_permission_set` belongs to + // plugin-security, so a deployment that does not install that plugin has no + // such object. Dropping it from the fixture registry models exactly that — + // and the card is indistinguishable from the genuinely empty audit log + // beside it. state.registry.sys_audit_log = []; + delete state.registry.sys_permission_set; renderHub(); expect(await badge('hub-card-audit-log', '0 entries')).toBeInTheDocument(); @@ -323,20 +345,28 @@ describe('System Hub card counts — a lookup that failed is not a `0` (objectui '2 organizations', ); expect(countBadge('hub-card-positions', 'positions')).toHaveTextContent('4 positions'); + // `5 permissions` since objectui#3655 aimed this card at + // `sys_permission_set`; it read `0` here while the query still named the + // unregistered `sys_permission`. expect(countBadge('hub-card-permissions', 'permissions')).toHaveTextContent( - '0 permissions', + '5 permissions', ); }); it('keeps `0` for the two things that really are zero, and blanks only the failure', async () => { // All three rows of the issue's behaviour matrix in one render. Only the // third moves; the first two are the adapter's contract and stay as they - // are (whether Permissions should be riding on row two at all is - // objectui#3655, not this change): - // sys_audit_log registered, genuinely empty -> `0 entries` - // sys_permission unregistered, adapter resolves empty -> `0 permissions` - // sys_position 403, adapter rethrows -> no badge + // are: + // sys_audit_log registered, genuinely empty -> `0 entries` + // sys_permission_set unregistered, adapter resolves empty -> `0 permissions` + // sys_position 403, adapter rethrows -> no badge + // + // Row two used to ride on `sys_permission`, a name the framework never had + // (objectui#3655 has since aimed the card at `sys_permission_set`). It now + // rides on a deployment without plugin-security, which owns that object — + // the row still needs a specimen, and this is the honest one. state.registry.sys_audit_log = []; + delete state.registry.sys_permission_set; state.failures.sys_position = Object.assign(new Error('Forbidden'), { status: 403 }); renderHub();