import { createAuthenticatedPrincipal, parseAuthorizationScope, 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 { return { handleAuthRequest: vi.fn(), resolvePrincipal: vi.fn().mockResolvedValue(manager), ...overrides, }; } function fakeStore(overrides: Partial = {}): AuthorizationStore { return { createGrant: vi.fn(), grantPlatformOwner: vi.fn(), hasActiveGrant: vi.fn().mockResolvedValue(false), isPlatformOwner: vi.fn().mockResolvedValue(false), listGrantsForUser: vi.fn().mockResolvedValue([]), revokeGrant: vi.fn(), revokePlatformOwner: vi.fn(), ...overrides, }; } function buildApp(options: { readonly allowed?: boolean; 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, 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[] }; expect(response.status).toBe(200); expect(body.grants).toHaveLength(1); expect(store.listGrantsForUser).toHaveBeenCalledWith(subjectUserId, { 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, scope: parseAuthorizationScope("calendar-event:123"), 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, scope: "calendar-event:123", 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, scope: "calendar-event:123", sourceKind: "explicit", userId: subjectUserId, }), ); }); 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({ 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({ 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(); }); });