From b9aab4da83d1e3bc94cfd6795883ee0d78267c1a Mon Sep 17 00:00:00 2001 From: Mobeen Abdullah Date: Sun, 13 Sep 2026 19:18:05 +0300 Subject: [PATCH 1/5] test(nextly): pin what relationship expansion and callerMayPerform answer for a read Two of the functions that decide whether a caller may read an entity had no test naming them, so a change to either while folding them into a shared read decision could not be seen. These cases pin today's answer cell by cell. callerMayPerform (unit): no identity refuses; a session defers to RBAC; a key without the grant refuses; a key with it follows the entity rule in every form; a super-admin owner gives the key no bypass; with no RBAC service a session is refused and a key holding the grant is admitted. Relationship expansion (integration): a key without read- is populated where the target has no rule or its rule admits, which the direct read refuses; a super-admin session bypasses a refusing rule and a super-admin owner's key does not; a throwing rule withholds. --- ...aller-may-perform.characterization.test.ts | 158 +++++++++++++++ ...ccess-characterization.integration.test.ts | 187 ++++++++++++++++++ 2 files changed, 345 insertions(+) create mode 100644 packages/nextly/src/auth/__tests__/caller-may-perform.characterization.test.ts create mode 100644 packages/nextly/src/domains/collections/__tests__/related-row-collection-access-characterization.integration.test.ts diff --git a/packages/nextly/src/auth/__tests__/caller-may-perform.characterization.test.ts b/packages/nextly/src/auth/__tests__/caller-may-perform.characterization.test.ts new file mode 100644 index 0000000000..6b36268b92 --- /dev/null +++ b/packages/nextly/src/auth/__tests__/caller-may-perform.characterization.test.ts @@ -0,0 +1,158 @@ +/** + * What `callerMayPerform` answers for a READ of an entity, for every kind of + * caller, as it behaves today. + * + * It is one of several functions that each decide "may this caller read this + * entity", and the only one no test named directly: its read decision was + * reached only through `PluginRouteCaller.can()`. These cases pin each cell so + * that folding it into a shared read decision cannot change an answer unseen. + * + * The RBAC service is a stub that says yes to everything a session asks. A + * scoped API key is judged on its own grant and the entity's code rule, never + * on that service, so an implementation that consulted the owner's authority + * would answer `true` where these expect `false`. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { CollectionAccessControl } from "../../shared/types/access"; +import { apiKeyScope } from "../authenticated-scope"; + +const rbac = vi.hoisted(() => ({ + registered: undefined as { read?: unknown } | undefined, + available: true, + checkAccess: vi.fn(async () => true), +})); + +vi.mock("../code-access", async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + getRBACService: () => + rbac.available + ? { + getRegisteredAccess: () => rbac.registered, + checkAccess: rbac.checkAccess, + } + : undefined, + }; +}); + +const { callerMayPerform } = await import("../authenticated-scope"); + +const OWNER = { id: "owner-1", roles: [] as string[] }; +const SUPER_ADMIN_OWNER = { id: "root-1", roles: ["super-admin"] }; + +/** A scoped key holding exactly these grants on `posts`. */ +function keyHolding(...actions: string[]) { + return apiKeyScope( + actions.map(action => ({ + slug: `${action}-posts`, + action, + resource: "posts", + })) + ); +} + +function rule(read: CollectionAccessControl["read"]): void { + rbac.registered = { read }; +} + +beforeEach(() => { + rbac.registered = undefined; + rbac.available = true; + rbac.checkAccess.mockClear(); + rbac.checkAccess.mockResolvedValue(true); +}); + +describe("callerMayPerform(read) — a caller with no identity", () => { + it("refuses, and asks nothing", async () => { + expect(await callerMayPerform(undefined, "read", "posts", { id: "" })).toBe( + false + ); + expect(rbac.checkAccess).not.toHaveBeenCalled(); + }); +}); + +describe("callerMayPerform(read) — a session caller", () => { + it("answers exactly what the RBAC service answers", async () => { + expect(await callerMayPerform(undefined, "read", "posts", OWNER)).toBe( + true + ); + rbac.checkAccess.mockResolvedValue(false); + expect(await callerMayPerform(undefined, "read", "posts", OWNER)).toBe( + false + ); + expect(rbac.checkAccess).toHaveBeenCalledWith({ + userId: "owner-1", + operation: "read", + resource: "posts", + }); + }); +}); + +describe("callerMayPerform(read) — a scoped API key", () => { + it("refuses a key without the read grant, however the service answers", async () => { + rule(true); + expect( + await callerMayPerform(keyHolding("create"), "read", "posts", OWNER) + ).toBe(false); + expect(rbac.checkAccess).not.toHaveBeenCalled(); + }); + + it("admits a key holding the grant when the entity declares no read rule", async () => { + expect( + await callerMayPerform(keyHolding("read"), "read", "posts", OWNER) + ).toBe(true); + expect(rbac.checkAccess).not.toHaveBeenCalled(); + }); + + it("holds a key with the grant to the entity's rule in each form", async () => { + const key = keyHolding("read"); + + rule(true); + expect(await callerMayPerform(key, "read", "posts", OWNER)).toBe(true); + rule(false); + expect(await callerMayPerform(key, "read", "posts", OWNER)).toBe(false); + rule(() => true); + expect(await callerMayPerform(key, "read", "posts", OWNER)).toBe(true); + rule(() => false); + expect(await callerMayPerform(key, "read", "posts", OWNER)).toBe(false); + rule(() => { + throw new Error("rule failed"); + }); + expect(await callerMayPerform(key, "read", "posts", OWNER)).toBe(false); + }); + + it("gives a key owned by a super-admin no bypass", async () => { + // Same answer as any key without the grant: the owner's role is not read. + expect( + await callerMayPerform( + keyHolding("create"), + "read", + "posts", + SUPER_ADMIN_OWNER + ) + ).toBe(false); + expect(rbac.checkAccess).not.toHaveBeenCalled(); + }); +}); + +describe("callerMayPerform(read) — no RBAC service registered", () => { + it("refuses a session caller", async () => { + rbac.available = false; + expect(await callerMayPerform(undefined, "read", "posts", OWNER)).toBe( + false + ); + }); + + it("admits a key holding the grant, because no rule can be read", async () => { + // With no service there is nowhere to look a rule up, so the grant alone + // decides. Pinned as the current answer so that changing it is a visible + // decision rather than a side effect. + rbac.available = false; + rbac.registered = { read: false }; + expect( + await callerMayPerform(keyHolding("read"), "read", "posts", OWNER) + ).toBe(true); + }); +}); diff --git a/packages/nextly/src/domains/collections/__tests__/related-row-collection-access-characterization.integration.test.ts b/packages/nextly/src/domains/collections/__tests__/related-row-collection-access-characterization.integration.test.ts new file mode 100644 index 0000000000..2377a94344 --- /dev/null +++ b/packages/nextly/src/domains/collections/__tests__/related-row-collection-access-characterization.integration.test.ts @@ -0,0 +1,187 @@ +/** + * What populating a relationship answers about its TARGET, for the callers the + * existing relationship-access suite does not name, as it behaves today. + * + * Expansion decides read access to the target by itself rather than through + * the collection read gate, and it decides differently in two documented ways: + * it never asks whether the caller holds `read-`, and a target with no + * read rule admits. These cases pin those answers cell by cell, beside the + * direct read of the same target where the two doors disagree, so that folding + * expansion into a shared read decision cannot change either answer unseen. + * + * Not pinned here: expansion with no RBAC service registered. Every instance + * `createTestNextly` builds registers one. + */ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { apiKeyScope } from "../../../auth/authenticated-scope"; +import { defineCollection, relationship, text } from "../../../config"; +import { + createTestNextly, + type TestNextly, +} from "../../../plugins/test-nextly"; +import type { CollectionsHandler } from "../../../services/collections-handler"; + +let current: TestNextly | undefined; +afterEach(async () => { + vi.restoreAllMocks(); + await current?.destroy(); + current = undefined; +}); + +type ReadRule = (ctx: { user?: { id?: string } | null }) => boolean; + +/** `refs.plain` points at one `pages` row whose read rule is `pagesRead`. */ +async function boot(pagesRead: ReadRule | undefined): Promise<{ + handler: CollectionsHandler; + refId: string; + pageId: string; +}> { + current = await createTestNextly({ + collections: [ + defineCollection({ + slug: "pages", + ...(pagesRead ? { access: { read: pagesRead } } : {}), + fields: [text({ name: "title" })], + }), + defineCollection({ + slug: "refs", + fields: [ + text({ name: "name" }), + relationship({ name: "plain", relationTo: "pages" }), + ], + }), + ], + }); + const handler = current.getService("collectionsHandler"); + const page = await handler.createEntry( + { collectionName: "pages", overrideAccess: true }, + { title: "Target page" } + ); + const pageId = (page.data as { id: string }).id; + const ref = await handler.createEntry( + { collectionName: "refs", overrideAccess: true }, + { name: "r", plain: pageId } + ); + return { handler, refId: (ref.data as { id: string }).id, pageId }; +} + +/** Whether the populated relationship on `refs` carries the target row. */ +async function populates( + handler: CollectionsHandler, + refId: string, + caller: Pick< + Parameters[0], + "user" | "authenticatedScope" + > +): Promise { + const result = await handler.getEntry({ + collectionName: "refs", + entryId: refId, + depth: 1, + routeAuthorized: true, + ...caller, + }); + // The parent read is served in every case; only the target varies. + expect(result.success).toBe(true); + const plain = (result.data as Record).plain; + return JSON.stringify(plain ?? null).includes("Target page"); +} + +/** A scoped key that may read `refs` but holds no grant on `pages`. */ +const keyWithoutTargetGrant = apiKeyScope([ + { slug: "read-refs", action: "read", resource: "refs" }, +]); + +describe("relationship expansion — a scoped API key without read-", () => { + it("populates a target that declares no read rule, which the direct read refuses", async () => { + const { handler, refId, pageId } = await boot(undefined); + const key = { id: "key-owner", roles: [] as string[] }; + + // The direct door judges the key's grant and refuses it. + const direct = await handler.getEntry({ + collectionName: "pages", + entryId: pageId, + user: key, + authenticatedScope: keyWithoutTargetGrant, + }); + expect(direct.success).toBe(false); + + // Expansion never asks about the grant, so the same row is populated. + expect( + await populates(handler, refId, { + user: key, + authenticatedScope: keyWithoutTargetGrant, + }) + ).toBe(true); + }); + + it("populates when the target's rule admits, and withholds when it refuses", async () => { + const key = { id: "key-owner", roles: [] as string[] }; + + const admits = await boot(() => true); + expect( + await populates(admits.handler, admits.refId, { + user: key, + authenticatedScope: keyWithoutTargetGrant, + }) + ).toBe(true); + await current?.destroy(); + current = undefined; + + const refuses = await boot(() => false); + expect( + await populates(refuses.handler, refuses.refId, { + user: key, + authenticatedScope: keyWithoutTargetGrant, + }) + ).toBe(false); + }); + + it("gives a key owned by a super-admin no bypass", async () => { + const { handler, refId } = await boot(() => false); + + expect( + await populates(handler, refId, { + user: { id: "root-owner", roles: ["super-admin"] }, + authenticatedScope: keyWithoutTargetGrant, + }) + ).toBe(false); + }); +}); + +describe("relationship expansion — session callers", () => { + it("populates a target that declares no read rule for a caller holding no grant", async () => { + const { handler, refId } = await boot(undefined); + + expect(await populates(handler, refId, { user: { id: "no-grants" } })).toBe( + true + ); + }); + + it("populates a target whose rule refuses, for a super-admin session", async () => { + const { handler, refId } = await boot(() => false); + + // Control first: the same rule withholds the target from an ordinary + // session, so the populated relationship below is the bypass at work. + expect(await populates(handler, refId, { user: { id: "ordinary" } })).toBe( + false + ); + expect( + await populates(handler, refId, { + user: { id: "root", roles: ["super-admin"] }, + }) + ).toBe(true); + }); + + it("withholds a target whose rule throws", async () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + const { handler, refId } = await boot(() => { + throw new Error("rule failed"); + }); + + expect(await populates(handler, refId, { user: { id: "ordinary" } })).toBe( + false + ); + }); +}); From ac70d6e8264f123fc8b28bfd6089c719036da8a7 Mon Sep 17 00:00:00 2001 From: Mobeen Abdullah Date: Sun, 13 Sep 2026 20:17:36 +0300 Subject: [PATCH 2/5] test(nextly): describe what the read-gate characterization tests pin, not why now --- .../__tests__/caller-may-perform.characterization.test.ts | 8 +++----- ...collection-access-characterization.integration.test.ts | 8 ++++---- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/packages/nextly/src/auth/__tests__/caller-may-perform.characterization.test.ts b/packages/nextly/src/auth/__tests__/caller-may-perform.characterization.test.ts index 6b36268b92..040af5788d 100644 --- a/packages/nextly/src/auth/__tests__/caller-may-perform.characterization.test.ts +++ b/packages/nextly/src/auth/__tests__/caller-may-perform.characterization.test.ts @@ -1,11 +1,9 @@ /** * What `callerMayPerform` answers for a READ of an entity, for every kind of - * caller, as it behaves today. + * caller. * - * It is one of several functions that each decide "may this caller read this - * entity", and the only one no test named directly: its read decision was - * reached only through `PluginRouteCaller.can()`. These cases pin each cell so - * that folding it into a shared read decision cannot change an answer unseen. + * Each case pins one cell of that decision — a kind of caller against a state + * of the entity's rule — so a change to any single answer fails a named test. * * The RBAC service is a stub that says yes to everything a session asks. A * scoped API key is judged on its own grant and the entity's code rule, never diff --git a/packages/nextly/src/domains/collections/__tests__/related-row-collection-access-characterization.integration.test.ts b/packages/nextly/src/domains/collections/__tests__/related-row-collection-access-characterization.integration.test.ts index 2377a94344..d1b8072e5d 100644 --- a/packages/nextly/src/domains/collections/__tests__/related-row-collection-access-characterization.integration.test.ts +++ b/packages/nextly/src/domains/collections/__tests__/related-row-collection-access-characterization.integration.test.ts @@ -1,13 +1,13 @@ /** - * What populating a relationship answers about its TARGET, for the callers the - * existing relationship-access suite does not name, as it behaves today. + * What populating a relationship answers about its TARGET, for each kind of + * caller. * * Expansion decides read access to the target by itself rather than through * the collection read gate, and it decides differently in two documented ways: * it never asks whether the caller holds `read-`, and a target with no * read rule admits. These cases pin those answers cell by cell, beside the - * direct read of the same target where the two doors disagree, so that folding - * expansion into a shared read decision cannot change either answer unseen. + * direct read of the same target where the two disagree, so a change to either + * answer fails a named test. * * Not pinned here: expansion with no RBAC service registered. Every instance * `createTestNextly` builds registers one. From ce210ae80e5bb7e60d1ef1978699da2a7d59cab6 Mon Sep 17 00:00:00 2001 From: Mobeen Abdullah Date: Sun, 13 Sep 2026 21:39:32 +0300 Subject: [PATCH 3/5] test(nextly): pin only the key refusals relationship expansion keeps either way The relationship characterization test required a scoped API key without read- to be shown the target's rows. That is an inconsistency with the direct read of the same row, not behaviour to keep, so it is no longer pinned: the two populate cases for such a key are removed. What remains for a key holds whether or not expansion also asks for the target grant: a refusing rule withholds, and a super-admin owner lends no bypass. The session cases are unchanged. --- ...ccess-characterization.integration.test.ts | 51 ++++--------------- 1 file changed, 10 insertions(+), 41 deletions(-) diff --git a/packages/nextly/src/domains/collections/__tests__/related-row-collection-access-characterization.integration.test.ts b/packages/nextly/src/domains/collections/__tests__/related-row-collection-access-characterization.integration.test.ts index d1b8072e5d..1e24caafde 100644 --- a/packages/nextly/src/domains/collections/__tests__/related-row-collection-access-characterization.integration.test.ts +++ b/packages/nextly/src/domains/collections/__tests__/related-row-collection-access-characterization.integration.test.ts @@ -3,11 +3,10 @@ * caller. * * Expansion decides read access to the target by itself rather than through - * the collection read gate, and it decides differently in two documented ways: - * it never asks whether the caller holds `read-`, and a target with no - * read rule admits. These cases pin those answers cell by cell, beside the - * direct read of the same target where the two disagree, so a change to either - * answer fails a named test. + * the collection read gate, and for a session caller it decides differently in + * two documented ways: it never asks whether the caller holds `read-`, + * and a target with no read rule admits. These cases pin those answers cell by + * cell, so a change to any one fails a named test. * * Not pinned here: expansion with no RBAC service registered. Every instance * `createTestNextly` builds registers one. @@ -94,45 +93,15 @@ const keyWithoutTargetGrant = apiKeyScope([ ]); describe("relationship expansion — a scoped API key without read-", () => { - it("populates a target that declares no read rule, which the direct read refuses", async () => { - const { handler, refId, pageId } = await boot(undefined); - const key = { id: "key-owner", roles: [] as string[] }; - - // The direct door judges the key's grant and refuses it. - const direct = await handler.getEntry({ - collectionName: "pages", - entryId: pageId, - user: key, - authenticatedScope: keyWithoutTargetGrant, - }); - expect(direct.success).toBe(false); + // Only refusals are pinned for a key without the target grant. Both hold + // whether or not expansion also asks for that grant: a refusing rule + // withholds, and an owner's role lends the key no bypass. + it("withholds a target whose rule refuses", async () => { + const { handler, refId } = await boot(() => false); - // Expansion never asks about the grant, so the same row is populated. expect( await populates(handler, refId, { - user: key, - authenticatedScope: keyWithoutTargetGrant, - }) - ).toBe(true); - }); - - it("populates when the target's rule admits, and withholds when it refuses", async () => { - const key = { id: "key-owner", roles: [] as string[] }; - - const admits = await boot(() => true); - expect( - await populates(admits.handler, admits.refId, { - user: key, - authenticatedScope: keyWithoutTargetGrant, - }) - ).toBe(true); - await current?.destroy(); - current = undefined; - - const refuses = await boot(() => false); - expect( - await populates(refuses.handler, refuses.refId, { - user: key, + user: { id: "key-owner", roles: [] as string[] }, authenticatedScope: keyWithoutTargetGrant, }) ).toBe(false); From 37d1cf277783b1f1bdf6200b1df3fc3e0ca7e136 Mon Sep 17 00:00:00 2001 From: Mobeen Abdullah Date: Sun, 13 Sep 2026 22:05:03 +0300 Subject: [PATCH 4/5] test(nextly): stop pinning the read answers ruled to change A session with no grant populating a rule-less target, and a key admitted by its grant alone when no RBAC service is registered, are both ruled to change. Pinning them would make the suite reject those fixes, so the cases are removed and the preamble names what remains. --- .../caller-may-perform.characterization.test.ts | 11 ----------- ...-access-characterization.integration.test.ts | 17 +++++------------ 2 files changed, 5 insertions(+), 23 deletions(-) diff --git a/packages/nextly/src/auth/__tests__/caller-may-perform.characterization.test.ts b/packages/nextly/src/auth/__tests__/caller-may-perform.characterization.test.ts index 040af5788d..67ad90b627 100644 --- a/packages/nextly/src/auth/__tests__/caller-may-perform.characterization.test.ts +++ b/packages/nextly/src/auth/__tests__/caller-may-perform.characterization.test.ts @@ -142,15 +142,4 @@ describe("callerMayPerform(read) — no RBAC service registered", () => { false ); }); - - it("admits a key holding the grant, because no rule can be read", async () => { - // With no service there is nowhere to look a rule up, so the grant alone - // decides. Pinned as the current answer so that changing it is a visible - // decision rather than a side effect. - rbac.available = false; - rbac.registered = { read: false }; - expect( - await callerMayPerform(keyHolding("read"), "read", "posts", OWNER) - ).toBe(true); - }); }); diff --git a/packages/nextly/src/domains/collections/__tests__/related-row-collection-access-characterization.integration.test.ts b/packages/nextly/src/domains/collections/__tests__/related-row-collection-access-characterization.integration.test.ts index 1e24caafde..be8a1f4916 100644 --- a/packages/nextly/src/domains/collections/__tests__/related-row-collection-access-characterization.integration.test.ts +++ b/packages/nextly/src/domains/collections/__tests__/related-row-collection-access-characterization.integration.test.ts @@ -3,10 +3,11 @@ * caller. * * Expansion decides read access to the target by itself rather than through - * the collection read gate, and for a session caller it decides differently in - * two documented ways: it never asks whether the caller holds `read-`, - * and a target with no read rule admits. These cases pin those answers cell by - * cell, so a change to any one fails a named test. + * the collection read gate. These cases pin the answers that hold whether or + * not expansion also consults the caller's grants: a refusing or throwing rule + * withholds the target, a super-admin session bypasses the rule, and a key + * owned by a super-admin gets no bypass. Each is pinned cell by cell, so a + * change to any one fails a named test. * * Not pinned here: expansion with no RBAC service registered. Every instance * `createTestNextly` builds registers one. @@ -120,14 +121,6 @@ describe("relationship expansion — a scoped API key without read-", () }); describe("relationship expansion — session callers", () => { - it("populates a target that declares no read rule for a caller holding no grant", async () => { - const { handler, refId } = await boot(undefined); - - expect(await populates(handler, refId, { user: { id: "no-grants" } })).toBe( - true - ); - }); - it("populates a target whose rule refuses, for a super-admin session", async () => { const { handler, refId } = await boot(() => false); From 3ab0ac3cf86d8aa492143cc4623e7733f11ae769 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 00:11:05 +0000 Subject: [PATCH 5/5] test(nextly): exercise the target rule with a key that holds the grant Both key cases used a scope without read-pages, so an implementation refusing on the missing grant, before it evaluates access.read or reaches the super-admin carve-out, answered both correctly without reaching the behaviour their names claim. Nothing separated the rule deciding from the grant deciding. Three cases hold read-pages as well, with the key's roles passed as an explicit empty list so the owner's roles cannot stand in for them through scope.roles ?? user.roles. One requires the target to be POPULATED, which every existing case accepts an implementation withholding every target from every key. --- ...ccess-characterization.integration.test.ts | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/packages/nextly/src/domains/collections/__tests__/related-row-collection-access-characterization.integration.test.ts b/packages/nextly/src/domains/collections/__tests__/related-row-collection-access-characterization.integration.test.ts index be8a1f4916..14f8341e4c 100644 --- a/packages/nextly/src/domains/collections/__tests__/related-row-collection-access-characterization.integration.test.ts +++ b/packages/nextly/src/domains/collections/__tests__/related-row-collection-access-characterization.integration.test.ts @@ -9,6 +9,16 @@ * owned by a super-admin gets no bypass. Each is pinned cell by cell, so a * change to any one fails a named test. * + * 🔴 The key cases come in two halves, and only together do they say what their + * names claim. A key WITHOUT `read-` cannot separate the rule deciding + * from the grant deciding: an implementation refusing on the missing grant, + * before it evaluates `access.read` or reaches the super-admin carve-out, + * answers every one of them correctly for the wrong reason. The cases holding + * the grant leave the rule as the only thing left to decide, and one of them + * requires the target to be POPULATED — so an implementation that withholds + * every target from every key, which every without-grant case accepts, fails + * there and only there. + * * Not pinned here: expansion with no RBAC service registered. Every instance * `createTestNextly` builds registers one. */ @@ -120,6 +130,75 @@ describe("relationship expansion — a scoped API key without read-", () }); }); +/** + * A scoped key holding `read-pages` as well as `read-refs`. + * + * 🔴 The control the cases above cannot supply for themselves. Both of them use + * a scope WITHOUT the target grant, so an implementation that refuses on the + * missing grant — before it evaluates `access.read` or reaches the + * scoped-key/super-admin carve-out — answers both correctly without ever + * reaching the behaviour their names claim. Holding the grant leaves the + * target's own rule as the only thing left to decide, so these cases separate + * the two. + * + * Roles are passed as an EXPLICIT empty list rather than omitted. `apiKeyScope` + * leaves `roles` off the scope when given nothing, and the key decision reads + * `scope.roles ?? user.roles`, so an omitted list lets the owner's roles stand + * in for the key's — which is the very substitution the super-admin case below + * exists to rule out. + */ +const keyWithTargetGrant = apiKeyScope( + [ + { slug: "read-refs", action: "read", resource: "refs" }, + { slug: "read-pages", action: "read", resource: "pages" }, + ], + [] +); + +describe("relationship expansion — a scoped API key WITH read-", () => { + it("populates a target whose rule admits", async () => { + // The must-move control for this whole file. Every other key case asserts + // a target is WITHHELD, and an implementation that withholds every target + // from every key satisfies all of them. This one must come back populated, + // so that implementation fails here. + const { handler, refId } = await boot(() => true); + + expect( + await populates(handler, refId, { + user: { id: "key-owner", roles: [] as string[] }, + authenticatedScope: keyWithTargetGrant, + }) + ).toBe(true); + }); + + it("withholds a target whose rule refuses", async () => { + // With the grant held, the refusal can only have come from the target's + // rule. The same assertion on a key without the grant cannot say that. + const { handler, refId } = await boot(() => false); + + expect( + await populates(handler, refId, { + user: { id: "key-owner", roles: [] as string[] }, + authenticatedScope: keyWithTargetGrant, + }) + ).toBe(false); + }); + + it("gives a key owned by a super-admin no bypass, grant or no grant", async () => { + // The owner carries the role and the key does not, so a decision reading + // the owner's roles admits this caller. Pinned with the grant held so the + // refusal is the carve-out at work rather than the missing grant. + const { handler, refId } = await boot(() => false); + + expect( + await populates(handler, refId, { + user: { id: "root-owner", roles: ["super-admin"] }, + authenticatedScope: keyWithTargetGrant, + }) + ).toBe(false); + }); +}); + describe("relationship expansion — session callers", () => { it("populates a target whose rule refuses, for a super-admin session", async () => { const { handler, refId } = await boot(() => false);