Files
Ekklesia/apps/api/tests/grants-routes.test.ts
ekklesiaandClaude Opus 5 5c589d8fec fix: enforce explicit grant invariants
Explicit-grant management could produce states an operator could not reason
about, and pushed request-shaped problems into the database.

Two identical createGrant calls produced two grants; five concurrent identical
calls produced five. Revoking one returned success while the equivalent kept
authorizing the same user — an operator could believe they had removed access
that was still live. Two issuances are now the same authorization source when
user_id, permission_id, scope, source_kind, and source_ref are all equal, with
NULL equal only to NULL; expiry, creation metadata, actor, and grant id do not
distinguish them. Creating one while an equivalent is currently effective fails
with AUTHORIZATION_GRANT_ALREADY_EXISTS (HTTP 409, no-store) naming nothing about
the grant that already exists, and writing no grant and no audit row. A revoked
or expired equivalent does not block a fresh issuance. Conflict rather than
idempotent return: the caller asked to record a new authorization with their own
actor and expiry, and answering "done" while recording neither would hide that
nothing happened.

Enforcement is database-backed, since nothing constrains the number of API
processes: every create and revoke touching one equivalence tuple takes a
transaction-scoped advisory lock keyed by a hash of that tuple, encoded as a JSON
array so NULL stays distinct from the empty string and a crafted scope cannot
forge a separator. A collision can only make unrelated tuples serialize, never
hide a conflict, because the decision is always an exact SQL predicate. Its
single-key lock space is verified disjoint from the two-key space the migration
runner and bootstrap use. A time-dependent partial unique index was rejected:
now() is not immutable and cannot be indexed.

One revocation now clears every unrevoked equivalent — including expired-but-
unrevoked legacy rows, so the history is left in one state — with one audit row
per grant actually revoked, each carrying its own id, in one transaction.
Differently scoped or sourced grants are untouched. Historical duplicates are not
silently revoked by migration; the store prevents new effective equivalents and
an operator's explicit revocation resolves the class.

The request boundary no longer leaks into PostgreSQL. GrantId accepts exactly the
canonical lowercase hyphenated UUID the database emits, with no trimming and no
case folding, so a malformed path parameter is a 400 decided after authorization
but before any store call instead of reaching the UUID comparison and returning
an indistinguishable 503; a canonical unknown id still returns 404. expiresAt
accepts exactly YYYY-MM-DDTHH:mm:ss.sssZ, byte-for-byte equal to the parsed
date's own toISOString, and must be strictly future — bare new Date silently
rewrote 2026-02-31 to March 3rd and accepted locale, date-only, offset, and
millisecond-free forms, and a past expiry was stored as a grant dead on arrival.
The store validates the same thing before any SQL so trusted internal callers get
the guarantee too.

The generic HTTP surface now rejects a scope field in every form, including null.
No route middleware supplies a scope to the evaluator, so a scope accepted here
produced a grant that persisted, listed, and authorized nothing. Core's scope
semantics are unchanged for internal callers; no resolver was added.

Listing was unbounded and returned a user's entire history. It is now keyset
pagination over created_at DESC, id DESC with a strict boundary on both values,
so rows sharing a timestamp are neither skipped nor repeated, at most limit + 1
rows read, explicit-source filtering server-side on every page, and an opaque
unsigned position cursor that is strictly decoded and reveals nothing on
rejection. Core's list method requires a validated finite bound with no
unbounded fallback anywhere.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-26 21:56:32 -05:00

882 lines
32 KiB
TypeScript

import {
createAuthenticatedPrincipal,
parseEkklesiaUserId,
parseGrantId,
parsePermissionId,
} from "@ekklesia/contracts";
import {
type AuthorizationEvaluator,
type AuthorizationStore,
AuthorizationStoreError,
CORE_AUTHORIZATION_GRANTS_MANAGE,
CORE_PERMISSION_REGISTRY,
} from "@ekklesia/core/authorization";
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import { Hono } from "hono";
import { describe, expect, it, vi } from "vitest";
import { CORE_MUTATION_MAX_BODY_BYTES } from "../src/body-limits.js";
import { createGrantsRoutes } from "../src/grants-routes.js";
const trustedOrigin = "https://dashboard.example.test";
const untrustedOrigin = "https://evil.example.test";
const manager = createAuthenticatedPrincipal("manager_1");
const subjectUserId = parseEkklesiaUserId("subject_1");
// Well-formed but never declared in `CORE_PERMISSION_REGISTRY` — used to prove the routes reject
// (or, for listing, still return) an unregistered permission id rather than assuming registration.
const permission = parsePermissionId("calendar.events.read");
// A permission `CORE_PERMISSION_REGISTRY` actually declares, so tests that must reach
// `store.createGrant`/`store.revokeGrant` can do so. This suite exercises `createGrantsRoutes` in
// isolation and intentionally injects `CORE_PERMISSION_REGISTRY` directly rather than a composed
// platform registry — the routes have no notion of "Core" versus "module" permissions, they only
// ever consult whichever registry they are given. Coverage proving a module-declared permission
// works through the exact same route code lives in
// `apps/api/tests/authorization-middleware.test.ts` (unit) and the "installed module permission
// end-to-end" scenario in `apps/api/tests/api-http.integration.ts` (live).
const registeredPermission = CORE_AUTHORIZATION_GRANTS_MANAGE;
const grantId = parseGrantId("11111111-1111-1111-1111-111111111111");
function fakeRuntime(overrides: Partial<EkklesiaIdentityRuntime> = {}): EkklesiaIdentityRuntime {
return {
handleAuthRequest: vi.fn(),
resolvePrincipal: vi.fn().mockResolvedValue(manager),
...overrides,
};
}
function fakeStore(overrides: Partial<AuthorizationStore> = {}): AuthorizationStore {
return {
createGrant: vi.fn(),
grantPlatformOwner: vi.fn(),
hasActiveGrant: vi.fn().mockResolvedValue(false),
isPlatformOwner: vi.fn().mockResolvedValue(false),
listGrantsForUser: vi.fn().mockResolvedValue({ grants: [] }),
revokeGrant: vi.fn(),
revokePlatformOwner: vi.fn(),
...overrides,
};
}
function buildApp(options: {
readonly allowed?: boolean;
readonly now?: () => Date;
readonly runtime?: EkklesiaIdentityRuntime;
readonly store?: AuthorizationStore;
}) {
const evaluator: AuthorizationEvaluator = {
authorize: vi.fn().mockResolvedValue({ allowed: options.allowed ?? true }),
};
const runtime = options.runtime ?? fakeRuntime();
const store = options.store ?? fakeStore();
const app = new Hono();
app.route(
"/",
createGrantsRoutes({
evaluator,
...(options.now === undefined ? {} : { now: options.now }),
registry: CORE_PERMISSION_REGISTRY,
runtime,
store,
trustedOrigins: new Set([trustedOrigin]),
}),
);
return { app, evaluator, runtime, store };
}
describe("GET /api/v1/grants", () => {
it("denies an anonymous or non-manager authenticated caller (evaluator says no)", async () => {
const { app, evaluator } = buildApp({ allowed: false });
const response = await app.request(`/api/v1/grants?userId=${subjectUserId}`);
expect(response.status).toBe(403);
expect(evaluator.authorize).toHaveBeenCalledWith(
expect.objectContaining({ baseline: "restricted" }),
);
});
it("allows a caller the evaluator authorizes — whether by platform ownership or a delegated grants.manage grant, the route cannot tell and does not need to (see core/tests/authorization-evaluator.test.ts for that distinction)", async () => {
const grants = [
{
createdAt: new Date("2026-01-01T00:00:00.000Z"),
id: grantId,
permission,
sourceKind: "explicit" as const,
userId: subjectUserId,
},
];
const store = fakeStore({ listGrantsForUser: vi.fn().mockResolvedValue({ grants }) });
const { app } = buildApp({ store });
const response = await app.request(`/api/v1/grants?userId=${subjectUserId}`);
const body = (await response.json()) as { grants: unknown[]; nextCursor: string | null };
expect(response.status).toBe(200);
expect(body.grants).toHaveLength(1);
expect(body.nextCursor).toBeNull();
// The default page size is applied server-side; `sourceKind` filtering stays server-side too.
expect(store.listGrantsForUser).toHaveBeenCalledWith(subjectUserId, {
limit: 50,
sourceKind: "explicit",
});
});
it("rejects a missing or malformed userId query parameter", async () => {
const { app, store } = buildApp({});
const response = await app.request("/api/v1/grants?userId=");
expect(response.status).toBe(400);
expect(store.listGrantsForUser).not.toHaveBeenCalled();
});
it("maps a store infrastructure failure to a sanitized 503", async () => {
const store = fakeStore({
listGrantsForUser: vi
.fn()
.mockRejectedValue(new AuthorizationStoreError("AUTHORIZATION_GRANT_LIST_FAILED")),
});
const { app } = buildApp({ store });
const response = await app.request(`/api/v1/grants?userId=${subjectUserId}`);
expect(response.status).toBe(503);
expect(await response.json()).toEqual({ code: "AUTHORIZATION_GRANT_LIST_FAILED" });
});
});
describe("POST /api/v1/grants", () => {
it("rejects a request with no trusted Origin header before ever checking permission", async () => {
const { app, evaluator } = buildApp({});
const response = await app.request("/api/v1/grants", {
body: JSON.stringify({ permission, subjectUserId }),
headers: { "content-type": "application/json" },
method: "POST",
});
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
expect(evaluator.authorize).not.toHaveBeenCalled();
});
it("rejects an untrusted Origin header the same way", async () => {
const { app } = buildApp({});
const response = await app.request("/api/v1/grants", {
body: JSON.stringify({ permission, subjectUserId }),
headers: { "content-type": "application/json", origin: untrustedOrigin },
method: "POST",
});
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
});
it("denies a caller the evaluator does not authorize", async () => {
const { app, store } = buildApp({ allowed: false });
const response = await app.request("/api/v1/grants", {
body: JSON.stringify({ permission, subjectUserId }),
headers: { "content-type": "application/json", origin: trustedOrigin },
method: "POST",
});
expect(response.status).toBe(403);
expect(store.createGrant).not.toHaveBeenCalled();
});
it("creates an explicit grant, deriving sourceKind and actor server-side from the authenticated principal, never from the request body", async () => {
const createdGrant = {
createdAt: new Date("2026-01-01T00:00:00.000Z"),
id: grantId,
permission: registeredPermission,
sourceKind: "explicit" as const,
userId: subjectUserId,
};
const store = fakeStore({ createGrant: vi.fn().mockResolvedValue(createdGrant) });
const { app } = buildApp({ store });
const response = await app.request("/api/v1/grants", {
body: JSON.stringify({ permission: registeredPermission, subjectUserId }),
headers: { "content-type": "application/json", origin: trustedOrigin },
method: "POST",
});
const body = (await response.json()) as { grant: { id: string } };
expect(response.status).toBe(201);
expect(body.grant.id).toBe(grantId);
expect(store.createGrant).toHaveBeenCalledWith(
expect.objectContaining({
actor: { type: "user", userId: manager.userId },
permission: registeredPermission,
sourceKind: "explicit",
userId: subjectUserId,
}),
);
// The generic HTTP surface creates unscoped explicit grants only.
expect(store.createGrant).toHaveBeenCalledWith(
expect.not.objectContaining({ scope: expect.anything() }),
);
});
it("rejects a well-formed but unregistered permission with the same sanitized denial as an unauthorized caller, and never calls the store", async () => {
const { app, store } = buildApp({});
const response = await app.request("/api/v1/grants", {
body: JSON.stringify({ permission, subjectUserId }),
headers: { "content-type": "application/json", origin: trustedOrigin },
method: "POST",
});
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
expect(store.createGrant).not.toHaveBeenCalled();
});
it.each([
"actor",
"owner",
"isOwner",
"sourceKind",
"sourceRef",
"id",
"grantId",
"createdAt",
"revokedAt",
"createdByActorId",
"effectivePermissions",
])("rejects a request body carrying a disallowed field (%s), even alongside otherwise-valid fields", async (field) => {
const { app, store } = buildApp({});
const response = await app.request("/api/v1/grants", {
body: JSON.stringify({ [field]: "hostile-value", permission, subjectUserId }),
headers: { "content-type": "application/json", origin: trustedOrigin },
method: "POST",
});
expect(response.status).toBe(400);
expect(store.createGrant).not.toHaveBeenCalled();
});
it("rejects a malformed permission, subject id, scope, or expiresAt", async () => {
const { app, store } = buildApp({});
const responses = await Promise.all(
[
{ permission: "not-namespaced", subjectUserId },
{ permission, subjectUserId: "" },
{ permission, scope: "", subjectUserId },
{ expiresAt: "not-a-date", permission, subjectUserId },
].map((body) =>
app.request("/api/v1/grants", {
body: JSON.stringify(body),
headers: { "content-type": "application/json", origin: trustedOrigin },
method: "POST",
}),
),
);
for (const response of responses) {
expect(response.status).toBe(400);
}
expect(store.createGrant).not.toHaveBeenCalled();
});
it("maps a store infrastructure failure to a sanitized 503", async () => {
const store = fakeStore({
createGrant: vi
.fn()
.mockRejectedValue(new AuthorizationStoreError("AUTHORIZATION_GRANT_CREATE_FAILED")),
});
const { app } = buildApp({ store });
const response = await app.request("/api/v1/grants", {
body: JSON.stringify({ permission: registeredPermission, subjectUserId }),
headers: { "content-type": "application/json", origin: trustedOrigin },
method: "POST",
});
expect(response.status).toBe(503);
expect(await response.json()).toEqual({ code: "AUTHORIZATION_GRANT_CREATE_FAILED" });
});
});
describe("POST /api/v1/grants/:grantId/revoke", () => {
it("rejects a request with no trusted Origin header before ever checking permission", async () => {
const { app, evaluator } = buildApp({});
const response = await app.request(`/api/v1/grants/${grantId}/revoke`, { method: "POST" });
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
expect(evaluator.authorize).not.toHaveBeenCalled();
});
it("revokes an explicit grant, deriving the actor server-side from the authenticated principal", async () => {
const store = fakeStore({ revokeGrant: vi.fn().mockResolvedValue(undefined) });
const { app } = buildApp({ store });
const response = await app.request(`/api/v1/grants/${grantId}/revoke`, {
headers: { origin: trustedOrigin },
method: "POST",
});
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ success: true });
expect(store.revokeGrant).toHaveBeenCalledWith({
actor: { type: "user", userId: manager.userId },
grantId,
sourceKind: "explicit",
});
});
it("always constrains revocation to sourceKind explicit, so a relationship-derived grant can never be targeted by id", async () => {
// The store itself enforces this by treating a sourceKind mismatch exactly like a missing
// grant; this test proves the route always asks for that constraint, and correctly surfaces
// the resulting "not found" as 404 rather than leaking that the grant exists but is the wrong
// kind.
const store = fakeStore({
revokeGrant: vi
.fn()
.mockRejectedValue(new AuthorizationStoreError("AUTHORIZATION_GRANT_NOT_FOUND")),
});
const { app } = buildApp({ store });
const response = await app.request(`/api/v1/grants/${grantId}/revoke`, {
headers: { origin: trustedOrigin },
method: "POST",
});
expect(response.status).toBe(404);
expect(await response.json()).toEqual({ code: "AUTHORIZATION_GRANT_NOT_FOUND" });
expect(store.revokeGrant).toHaveBeenCalledWith(
expect.objectContaining({ sourceKind: "explicit" }),
);
});
it("rejects a malformed grant id", async () => {
const { app, store } = buildApp({});
const response = await app.request("/api/v1/grants/%20/revoke", {
headers: { origin: trustedOrigin },
method: "POST",
});
expect(response.status).toBe(400);
expect(store.revokeGrant).not.toHaveBeenCalled();
});
it("maps a store infrastructure failure to a sanitized 503", async () => {
const store = fakeStore({
revokeGrant: vi
.fn()
.mockRejectedValue(new AuthorizationStoreError("AUTHORIZATION_GRANT_REVOKE_FAILED")),
});
const { app } = buildApp({ store });
const response = await app.request(`/api/v1/grants/${grantId}/revoke`, {
headers: { origin: trustedOrigin },
method: "POST",
});
expect(response.status).toBe(503);
expect(await response.json()).toEqual({ code: "AUTHORIZATION_GRANT_REVOKE_FAILED" });
});
});
describe("Core grant mutation request body limit", () => {
it("accepts a comfortably-under-limit grant-creation body and rejects one byte over the exact limit", async () => {
const store = fakeStore();
const { app } = buildApp({ store });
const overSizedBody = "a".repeat(CORE_MUTATION_MAX_BODY_BYTES + 1);
const overResponse = await app.request("/api/v1/grants", {
body: overSizedBody,
headers: {
"content-length": String(overSizedBody.length),
"content-type": "application/json",
origin: trustedOrigin,
},
method: "POST",
});
expect(overResponse.status).toBe(413);
expect(await overResponse.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
expect(store.createGrant).not.toHaveBeenCalled();
// A well-formed, comfortably-under-limit payload still succeeds through the same route.
const underResponse = await app.request("/api/v1/grants", {
body: JSON.stringify({ permission: registeredPermission, subjectUserId }),
headers: { "content-type": "application/json", origin: trustedOrigin },
method: "POST",
});
expect(underResponse.status).not.toBe(413);
});
it("rejects an oversized declared Content-Length on grant creation without invoking permission evaluation", async () => {
const evaluatorAuthorize = vi.fn().mockResolvedValue({ allowed: true });
const evaluator: AuthorizationEvaluator = { authorize: evaluatorAuthorize };
const store = fakeStore();
const app = new Hono();
app.route(
"/",
createGrantsRoutes({
evaluator,
registry: CORE_PERMISSION_REGISTRY,
runtime: fakeRuntime(),
store,
trustedOrigins: new Set([trustedOrigin]),
}),
);
const response = await app.request("/api/v1/grants", {
body: "a",
headers: {
"content-length": String(CORE_MUTATION_MAX_BODY_BYTES * 10),
"content-type": "application/json",
origin: trustedOrigin,
},
method: "POST",
});
expect(response.status).toBe(413);
expect(evaluatorAuthorize).not.toHaveBeenCalled();
expect(store.createGrant).not.toHaveBeenCalled();
});
it("rejects an oversized streamed grant-creation body with no declared Content-Length", async () => {
const store = fakeStore();
const { app } = buildApp({ store });
const chunk = new Uint8Array(1024).fill(97);
let enqueued = 0;
const stream = new ReadableStream<Uint8Array>({
pull(controller) {
if (enqueued > CORE_MUTATION_MAX_BODY_BYTES) {
controller.close();
return;
}
enqueued += chunk.length;
controller.enqueue(chunk);
},
});
const response = await app.request("/api/v1/grants", {
body: stream,
duplex: "half",
headers: { "content-type": "application/json", origin: trustedOrigin },
method: "POST",
} as RequestInit);
expect(response.status).toBe(413);
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
expect(store.createGrant).not.toHaveBeenCalled();
});
it("does not read an oversized body from an untrusted Origin: the trusted-origin rejection wins first", async () => {
const store = fakeStore();
const { app } = buildApp({ store });
const chunk = new Uint8Array(1024).fill(97);
let pullCount = 0;
const stream = new ReadableStream<Uint8Array>({
pull(controller) {
pullCount += 1;
if (pullCount > 3) {
controller.close();
return;
}
controller.enqueue(chunk);
},
});
const response = await app.request("/api/v1/grants", {
body: stream,
duplex: "half",
headers: { "content-type": "application/json", origin: untrustedOrigin },
method: "POST",
} as RequestInit);
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
expect(store.createGrant).not.toHaveBeenCalled();
});
it("also bounds the revoke mutation body", async () => {
const store = fakeStore();
const { app } = buildApp({ store });
const response = await app.request(`/api/v1/grants/${grantId}/revoke`, {
body: "a".repeat(CORE_MUTATION_MAX_BODY_BYTES + 1),
headers: {
"content-length": String(CORE_MUTATION_MAX_BODY_BYTES + 1),
origin: trustedOrigin,
},
method: "POST",
});
expect(response.status).toBe(413);
expect(store.revokeGrant).not.toHaveBeenCalled();
});
});
/**
* Pass 3 request-contract regressions. Every case here asserts both the sanitized public response
* *and* that the store was never reached, because the point of each fix is that a malformed or
* unsupported request is decided at the request boundary rather than turning into database work or
* an indistinguishable infrastructure failure.
*/
describe("explicit-grant request invariants", () => {
const validExpiry = "2099-01-01T00:00:00.000Z";
async function post(app: Hono, body: unknown) {
return await app.request("/api/v1/grants", {
body: JSON.stringify(body),
headers: { "content-type": "application/json", origin: trustedOrigin },
method: "POST",
});
}
it("rejects a scope field in any form, before any store mutation", async () => {
// Core keeps scoped-grant semantics for trusted internal callers, but no HTTP middleware
// supplies a scope to the evaluator, so a scope accepted here could never authorize anything.
for (const scope of ["calendar-event:123", "", " ", null, 42, {}, []]) {
const { app, store } = buildApp({});
const response = await post(app, { permission: registeredPermission, scope, subjectUserId });
expect(response.status, `scope ${JSON.stringify(scope)}`).toBe(400);
expect(await response.json()).toEqual({ code: "INVALID_REQUEST" });
expect(response.headers.get("cache-control")).toBe("no-store");
expect(store.createGrant).not.toHaveBeenCalled();
}
});
it("accepts exactly one canonical expiration representation", async () => {
const createdGrant = {
createdAt: new Date("2026-01-01T00:00:00.000Z"),
expiresAt: new Date(validExpiry),
id: grantId,
permission: registeredPermission,
sourceKind: "explicit" as const,
userId: subjectUserId,
};
const store = fakeStore({ createGrant: vi.fn().mockResolvedValue(createdGrant) });
const { app } = buildApp({ store });
const response = await post(app, {
expiresAt: validExpiry,
permission: registeredPermission,
subjectUserId,
});
expect(response.status).toBe(201);
expect(store.createGrant).toHaveBeenCalledWith(
expect.objectContaining({ expiresAt: new Date(validExpiry) }),
);
});
it("rejects normalized-impossible, locale, offset, imprecise, padded, and past expirations", async () => {
const now = new Date("2026-07-26T12:00:00.000Z");
for (const expiresAt of [
// Normalized impossible date: `new Date` silently rewrote this to March 3rd.
"2026-02-31T00:00:00.000Z",
"2026-13-01T00:00:00.000Z",
// Locale-shaped and date-only forms.
"March 5, 2027",
"2027-01-01",
// Missing milliseconds, excess fractional precision, offsets, lowercase separators.
"2027-01-01T00:00:00Z",
"2027-01-01T00:00:00.123456Z",
"2027-01-01T00:00:00.000+02:00",
"2027-01-01t00:00:00.000z",
"2027-01-01T00:00:00.000+00:00",
// Whitespace padding and trailing text.
" 2027-01-01T00:00:00.000Z",
"2027-01-01T00:00:00.000Z ",
"2027-01-01T00:00:00.000Z extra",
// Past and exactly-now are both dead on arrival.
"2020-01-01T00:00:00.000Z",
"2026-07-26T12:00:00.000Z",
// Non-strings.
42,
null,
true,
]) {
const { app, store } = buildApp({ now: () => now });
const response = await post(app, {
expiresAt,
permission: registeredPermission,
subjectUserId,
});
expect(response.status, `expiresAt ${JSON.stringify(expiresAt)}`).toBe(400);
expect(await response.json()).toEqual({ code: "INVALID_REQUEST" });
expect(store.createGrant).not.toHaveBeenCalled();
}
});
it("returns a sanitized 409 for an equivalent grant that is already effective", async () => {
const store = fakeStore({
createGrant: vi
.fn()
.mockRejectedValue(new AuthorizationStoreError("AUTHORIZATION_GRANT_ALREADY_EXISTS")),
});
const { app } = buildApp({ store });
const response = await post(app, { permission: registeredPermission, subjectUserId });
const raw = await response.text();
expect(response.status).toBe(409);
expect(JSON.parse(raw)).toEqual({ code: "AUTHORIZATION_GRANT_ALREADY_EXISTS" });
expect(response.headers.get("cache-control")).toBe("no-store");
// The body is the stable code and nothing else — no existing grant id, actor, scope, source
// reference, expiry, SQL, constraint name, or driver text.
expect(Object.keys(JSON.parse(raw))).toEqual(["code"]);
for (const leaked of [
grantId,
manager.userId,
subjectUserId,
"calendar-event",
"constraint",
"select ",
"insert ",
"ekklesia_core_grant",
"Error",
"at ",
]) {
expect(raw, `must not leak ${leaked}`).not.toContain(leaked);
}
});
it("rejects a non-canonical grant id on revoke without ever calling the store", async () => {
for (const raw of [
"not-a-uuid",
"0B36B1B6-9A29-4C4E-9E2D-4C0E4C5E6F70",
"%200b36b1b6-9a29-4c4e-9e2d-4c0e4c5e6f70",
"0b36b1b6-9a29-4c4e-9e2d-4c0e4c5e6f7",
"0b36b1b6-9a29-4c4e-9e2d-4c0e4c5e6f700",
"0b36b1b69a294c4e9e2d4c0e4c5e6f70",
]) {
const { app, store } = buildApp({});
const response = await app.request(`/api/v1/grants/${raw}/revoke`, {
headers: { origin: trustedOrigin },
method: "POST",
});
expect(response.status, `grantId ${raw}`).toBe(400);
expect(await response.json()).toEqual({ code: "INVALID_REQUEST" });
expect(response.headers.get("cache-control")).toBe("no-store");
expect(store.revokeGrant).not.toHaveBeenCalled();
}
});
it("still returns the sanitized 404 for a canonical but unknown grant id", async () => {
const store = fakeStore({
revokeGrant: vi
.fn()
.mockRejectedValue(new AuthorizationStoreError("AUTHORIZATION_GRANT_NOT_FOUND")),
});
const { app } = buildApp({ store });
const response = await app.request(`/api/v1/grants/${grantId}/revoke`, {
headers: { origin: trustedOrigin },
method: "POST",
});
expect(response.status).toBe(404);
expect(await response.json()).toEqual({ code: "AUTHORIZATION_GRANT_NOT_FOUND" });
expect(store.revokeGrant).toHaveBeenCalled();
});
});
describe("explicit-grant listing pagination", () => {
function grantAt(index: number, iso: string) {
return {
createdAt: new Date(iso),
id: parseGrantId(`00000000-0000-4000-8000-${String(index).padStart(12, "0")}`),
permission: registeredPermission,
sourceKind: "explicit" as const,
userId: subjectUserId,
};
}
it("applies the default limit of 50 and accepts the 1 and 100 boundaries", async () => {
for (const [query, expected] of [
["", 50],
["&limit=1", 1],
["&limit=50", 50],
["&limit=100", 100],
] as const) {
const { app, store } = buildApp({});
const response = await app.request(`/api/v1/grants?userId=${subjectUserId}${query}`);
expect(response.status, query).toBe(200);
expect(store.listGrantsForUser).toHaveBeenCalledWith(subjectUserId, {
limit: expected,
sourceKind: "explicit",
});
}
});
it("rejects every non-canonical or out-of-range limit before any store work", async () => {
for (const limit of [
"0",
"101",
"-1",
"+5",
"1.0",
"1.5",
"01",
"005",
" 5",
"5 ",
"1e2",
"",
"abc",
"0x10",
"Infinity",
"NaN",
]) {
const { app, store } = buildApp({});
const response = await app.request(
`/api/v1/grants?userId=${subjectUserId}&limit=${encodeURIComponent(limit)}`,
);
expect(response.status, `limit ${JSON.stringify(limit)}`).toBe(400);
expect(await response.json()).toEqual({ code: "INVALID_REQUEST" });
expect(response.headers.get("cache-control")).toBe("no-store");
expect(store.listGrantsForUser).not.toHaveBeenCalled();
}
});
it("rejects unknown and duplicated query keys", async () => {
for (const query of [
`userId=${subjectUserId}&sort=asc`,
`userId=${subjectUserId}&offset=10`,
`userId=${subjectUserId}&sourceKind=relationship`,
`userId=${subjectUserId}&userId=${subjectUserId}`,
`userId=${subjectUserId}&limit=5&limit=6`,
`userId=${subjectUserId}&cursor=a&cursor=b`,
`userId=${subjectUserId}&cursor=`,
]) {
const { app, store } = buildApp({});
const response = await app.request(`/api/v1/grants?${query}`);
expect(response.status, query).toBe(400);
expect(store.listGrantsForUser).not.toHaveBeenCalled();
}
});
it("emits a cursor only when another page exists, and traverses without duplicates or omissions", async () => {
// Three rows sharing one `created_at`, which is exactly the case a timestamp-only keyset
// boundary would skip or repeat.
const sameInstant = "2026-01-01T00:00:00.000Z";
const all = [grantAt(3, sameInstant), grantAt(2, sameInstant), grantAt(1, sameInstant)];
const seen: string[] = [];
let cursor: string | null = null;
let pages = 0;
do {
const store = fakeStore({
listGrantsForUser: vi.fn().mockImplementation((_userId, listOptions) => {
const startIndex =
listOptions.after === undefined
? 0
: all.findIndex((grant) => grant.id === listOptions.after.id) + 1;
const slice = all.slice(startIndex, startIndex + listOptions.limit);
const more = startIndex + listOptions.limit < all.length;
const last = slice.at(-1);
return Promise.resolve({
grants: slice,
...(more && last ? { nextBoundary: { createdAt: last.createdAt, id: last.id } } : {}),
});
}),
});
const { app } = buildApp({ store });
const url: string = `/api/v1/grants?userId=${subjectUserId}&limit=1${
cursor === null ? "" : `&cursor=${encodeURIComponent(cursor)}`
}`;
const response = await app.request(url);
const body = (await response.json()) as {
grants: { id: string }[];
nextCursor: string | null;
};
expect(response.status).toBe(200);
expect(response.headers.get("cache-control")).toBe("no-store");
expect(body.grants).toHaveLength(1);
seen.push(body.grants[0]?.id as string);
cursor = body.nextCursor;
pages += 1;
} while (cursor !== null && pages < 10);
expect(pages).toBe(3);
expect(seen).toEqual(all.map((grant) => grant.id));
expect(new Set(seen).size).toBe(3);
// The final page ends the traversal explicitly.
expect(cursor).toBeNull();
});
it("refuses a cursor issued for a different subject, and every malformed encoding", async () => {
const boundary = { createdAt: new Date("2026-01-01T00:00:00.000Z"), id: grantId };
const store = fakeStore({
listGrantsForUser: vi.fn().mockResolvedValue({
grants: [grantAt(1, boundary.createdAt.toISOString())],
nextBoundary: boundary,
}),
});
const { app: issuingApp } = buildApp({ store });
const issued = (await (
await issuingApp.request(`/api/v1/grants?userId=${subjectUserId}&limit=1`)
).json()) as { nextCursor: string };
expect(issued.nextCursor).toMatch(/^[A-Za-z0-9_-]+$/u);
const encode = (payload: unknown) =>
Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
const canonical = {
g: grantId,
t: "2026-01-01T00:00:00.000Z",
u: subjectUserId,
v: 1,
};
for (const [label, cursor] of [
// A cursor minted for a different subject must not be replayable against this one.
["other subject", encode({ ...canonical, u: "someone_else_1" })],
["wrong version", encode({ ...canonical, v: 2 })],
["extra field", encode({ ...canonical, extra: "x" })],
["missing field", encode({ g: canonical.g, t: canonical.t, u: canonical.u })],
["invalid timestamp", encode({ ...canonical, t: "2026-01-01" })],
["non-canonical timestamp", encode({ ...canonical, t: "2026-01-01T00:00:00Z" })],
["invalid grant id", encode({ ...canonical, g: "not-a-uuid" })],
["array payload", encode([canonical])],
["not json", Buffer.from("nonsense", "utf8").toString("base64url")],
["padded base64", `${Buffer.from(JSON.stringify(canonical), "utf8").toString("base64")}`],
["malformed base64url", "!!!not-base64!!!"],
["trailing data", `${encode(canonical)}AA`],
] as const) {
const { app, store: pagedStore } = buildApp({});
const response = await app.request(
`/api/v1/grants?userId=${subjectUserId}&cursor=${encodeURIComponent(cursor)}`,
);
const raw = await response.text();
expect(response.status, label).toBe(400);
expect(JSON.parse(raw)).toEqual({ code: "INVALID_REQUEST" });
// A rejected cursor reveals nothing it decoded, and does zero store/database work.
expect(raw).not.toContain(grantId);
expect(raw).not.toContain("someone_else_1");
expect(pagedStore.listGrantsForUser).not.toHaveBeenCalled();
}
// The cursor the API itself issued is accepted.
const { app: acceptingApp, store: acceptingStore } = buildApp({});
const accepted = await acceptingApp.request(
`/api/v1/grants?userId=${subjectUserId}&cursor=${encodeURIComponent(issued.nextCursor)}`,
);
expect(accepted.status).toBe(200);
expect(acceptingStore.listGrantsForUser).toHaveBeenCalledWith(subjectUserId, {
after: boundary,
limit: 50,
sourceKind: "explicit",
});
});
});