Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
/**
* What `callerMayPerform` answers for a READ of an entity, for every kind of
* caller.
*
* 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
* 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<typeof import("../code-access")>();
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 () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Exercise an access object without a read rule

This “declares no read rule” case only covers getRegisteredAccess returning undefined; it never covers a collection that has an access object for another operation, such as { update: false }. Making the API-key branch deny whenever a registered object lacks the requested member leaves this characterization file and the existing route-agreement suite green, but would refuse a read-scoped key merely because the collection configures writes. Register an unrelated rule here and retain the expected true result to cover the operation-level fallback.

AGENTS.md reference: packages/nextly/AGENTS.md:L46-L48

Useful? React with 👍 / 👎.

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);
Comment on lines +114 to +115

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Cover asynchronous access-rule results

AccessControlFunction permits Promise<boolean>, but every function in this purported “each form” matrix returns synchronously. Removing the await from codeAccessAllows so it compares a returned promise directly with true leaves all 7 tests green, while every async rule that resolves to true would then be denied. Add at least an asynchronously admitting rule as the positive control so the suite distinguishes awaiting the rule from treating its promise as the verdict.

AGENTS.md reference: AGENTS.md:L216-L233

Useful? React with 👍 / 👎.

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"),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Exercise the super-admin branch after the key grant

When the owner is a super-admin, this case still passes solely because keyHolding("create") is rejected by the missing-read-grant check before any owner bypass could run. A plausible regression that returns true for user.roles.includes("super-admin") immediately after the grant check leaves all 7 tests green. Give this key the read grant, explicitly set its own roles to [], and install a refusing read rule so the expected refusal actually distinguishes the scoped-key carve-out from the grant check.

AGENTS.md reference: AGENTS.md:L216-L233

Useful? React with 👍 / 👎.

"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
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
/**
* 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. 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.
*
* 🔴 The key cases come in two halves, and only together do they say what their
* names claim. A key WITHOUT `read-<target>` 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.
*/
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<CollectionsHandler["getEntry"]>[0],
"user" | "authenticatedScope"
>
): Promise<boolean> {
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<string, unknown>).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" },
]);
Comment on lines +102 to +104

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Exercise the target rule with a target-granted key

Both tests reuse a scope that lacks read-pages, so an implementation that denies on the target grant before evaluating either access.read or the scoped-key/super-admin carve-out makes both assertions pass without exercising the behavior their names claim to protect. Add positive-control cases with a read-pages grant (and explicit empty key roles) so ignoring a refusing rule or granting an owner-derived super-admin bypass actually turns the suite red.

AGENTS.md reference: AGENTS.md:L216-L233

Useful? React with 👍 / 👎.


describe("relationship expansion — a scoped API key without read-<target>", () => {
// 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);

expect(
await populates(handler, refId, {
user: { id: "key-owner", roles: [] as string[] },
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);
});
});

/**
* 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-<target>", () => {
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);

// 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
);
});
});
Loading