import { ANONYMOUS_PRINCIPAL, createAuthenticatedPrincipal, type ModuleRouteHandler, type ModuleRouteHandlerContext, parsePermissionId, type PermissionId, type RegisteredModuleHttpRoute, } from "@ekklesia/contracts"; import { type AuthorizationEvaluator, type AuthorizationStore, createPermissionRegistry, type PermissionRegistry, } from "@ekklesia/core/authorization"; import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity"; import { describe, expect, it, vi } from "vitest"; import { createApiApp } from "../src/app.js"; import { mergedVaryHeader, ModuleRouteConfigurationError } from "../src/module-http-routes.js"; const trustedOrigin = "https://dashboard.example.test"; const untrustedOrigin = "https://evil.example.test"; const readPermission = parsePermissionId("calendar.events.read"); const writePermission = parsePermissionId("calendar.events.write"); function fakeRuntime(overrides: Partial = {}): EkklesiaIdentityRuntime { return { handleAuthRequest: vi.fn(), resolvePrincipal: vi.fn().mockResolvedValue(ANONYMOUS_PRINCIPAL), ...overrides, }; } function fakeStore(): 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(), }; } function moduleRoute(fields: RegisteredModuleHttpRoute): RegisteredModuleHttpRoute { return Object.freeze({ ...fields }); } function buildModuleApp(options: { readonly evaluator?: AuthorizationEvaluator; readonly registry: PermissionRegistry; readonly routes: readonly RegisteredModuleHttpRoute[]; readonly runtime?: EkklesiaIdentityRuntime; readonly trustedOrigins?: readonly string[]; }) { const evaluator = options.evaluator ?? { authorize: vi.fn().mockResolvedValue({ allowed: true }), }; const runtime = options.runtime ?? fakeRuntime(); const app = createApiApp({ authorization: { evaluator, registry: options.registry, store: fakeStore() }, moduleRoutes: options.routes, runtime, trustedOrigins: options.trustedOrigins ?? [trustedOrigin], }); return { app, evaluator, runtime }; } function permissionRegistry( declarations: ReadonlyArray<{ readonly id: PermissionId; readonly baseline: "authenticated" | "public" | "restricted"; }>, ): PermissionRegistry { return createPermissionRegistry( declarations.map((declaration) => ({ ...declaration, description: "Test permission." })), ); } describe("module HTTP route mounting", () => { it('mounts a "/" route at exactly the module root, never with an added trailing slash', async () => { const handler = vi.fn(async () => new Response("root", { status: 200 })); const { app } = buildModuleApp({ registry: permissionRegistry([{ id: readPermission, baseline: "public" }]), routes: [ moduleRoute({ handler, method: "GET", moduleId: "calendar", path: "/", permission: readPermission, }), ], }); const rootResponse = await app.request("/api/v1/modules/calendar"); expect(rootResponse.status).toBe(200); expect(await rootResponse.text()).toBe("root"); expect(handler).toHaveBeenCalledTimes(1); const trailingSlashResponse = await app.request("/api/v1/modules/calendar/"); expect(trailingSlashResponse.status).toBe(404); }); it("mounts static and nested paths directly under the module prefix", async () => { const staticHandler = vi.fn(async () => Response.json({ kind: "static" })); const nestedHandler = vi.fn(async () => Response.json({ kind: "nested" })); const { app } = buildModuleApp({ registry: permissionRegistry([{ id: readPermission, baseline: "public" }]), routes: [ moduleRoute({ handler: staticHandler, method: "GET", moduleId: "calendar", path: "/events", permission: readPermission, }), moduleRoute({ handler: nestedHandler, method: "GET", moduleId: "calendar", path: "/events/upcoming/soon", permission: readPermission, }), ], }); expect(await (await app.request("/api/v1/modules/calendar/events")).json()).toEqual({ kind: "static", }); expect( await (await app.request("/api/v1/modules/calendar/events/upcoming/soon")).json(), ).toEqual({ kind: "nested" }); }); it("resolves a same-depth static path and its parameterized sibling by published route order, without silently reordering them", async () => { // Hono's router matches overlapping patterns in registration order, not by specificity — // mounting must therefore preserve `StartedModuleRuntime.routes`' exact publication order // rather than sorting it, or a module author's more-specific-first declaration would silently // stop taking precedence. The static route is declared first here, matching how a module // author would order these two routes to get the static path resolved on its own. const staticHandler = vi.fn(async () => Response.json({ kind: "static" })); const paramHandler = vi.fn(async (_request, context) => Response.json({ id: context.params.eventId, kind: "param" }), ); const { app } = buildModuleApp({ registry: permissionRegistry([{ id: readPermission, baseline: "public" }]), routes: [ moduleRoute({ handler: staticHandler, method: "GET", moduleId: "calendar", path: "/events/upcoming", permission: readPermission, }), moduleRoute({ handler: paramHandler, method: "GET", moduleId: "calendar", path: "/events/:eventId", permission: readPermission, }), ], }); expect(await (await app.request("/api/v1/modules/calendar/events/upcoming")).json()).toEqual({ kind: "static", }); expect(staticHandler).toHaveBeenCalledTimes(1); expect(paramHandler).not.toHaveBeenCalled(); expect(await (await app.request("/api/v1/modules/calendar/events/other-id")).json()).toEqual({ id: "other-id", kind: "param", }); expect(paramHandler).toHaveBeenCalledTimes(1); }); it("mounts every supported method", async () => { const methods = ["GET", "POST", "PUT", "PATCH", "DELETE"] as const; const permissions = new Map( methods.map((method) => [method, parsePermissionId(`multi.${method.toLowerCase()}.action`)]), ); const { app } = buildModuleApp({ registry: permissionRegistry( methods.map((method) => ({ baseline: "public" as const, id: permissions.get(method) as PermissionId, })), ), routes: methods.map((method) => moduleRoute({ handler: async () => Response.json({ method }), method, moduleId: "multi", path: "/action", permission: permissions.get(method) as PermissionId, }), ), }); for (const method of methods) { const response = await app.request("/api/v1/modules/multi/action", { headers: method === "GET" ? {} : { origin: trustedOrigin }, method, }); expect(response.status).toBe(200); expect(await response.json()).toEqual({ method }); } }); it("keeps two modules' identical local method/path routes independent", async () => { const alphaPermission = parsePermissionId("alpha.items.list"); const betaPermission = parsePermissionId("beta.items.list"); const { app } = buildModuleApp({ registry: permissionRegistry([ { id: alphaPermission, baseline: "public" }, { id: betaPermission, baseline: "public" }, ]), routes: [ moduleRoute({ handler: async () => Response.json({ moduleId: "alpha" }), method: "GET", moduleId: "alpha", path: "/items", permission: alphaPermission, }), moduleRoute({ handler: async () => Response.json({ moduleId: "beta" }), method: "GET", moduleId: "beta", path: "/items", permission: betaPermission, }), ], }); expect(await (await app.request("/api/v1/modules/alpha/items")).json()).toEqual({ moduleId: "alpha", }); expect(await (await app.request("/api/v1/modules/beta/items")).json()).toEqual({ moduleId: "beta", }); }); }); describe("module route handler adaptation", () => { it("adapts the request into a raw Fetch Request and a frozen context with decoded parameters", async () => { let capturedRequest: Request | undefined; let capturedContext: ModuleRouteHandlerContext | undefined; const handler: ModuleRouteHandler = async (request, context) => { capturedRequest = request; capturedContext = context; const body = await request.text(); return Response.json({ body, url: request.url }); }; const runtime = fakeRuntime({ resolvePrincipal: vi.fn().mockResolvedValue(createAuthenticatedPrincipal("user_1")), }); const { app } = buildModuleApp({ registry: permissionRegistry([{ id: readPermission, baseline: "public" }]), routes: [ moduleRoute({ handler, method: "POST", moduleId: "calendar", path: "/events/:eventId/rsvp", permission: readPermission, }), ], runtime, }); const response = await app.request("/api/v1/modules/calendar/events/evt-1/rsvp?notify=true", { body: "hello", headers: { origin: trustedOrigin, "x-custom": "abc" }, method: "POST", }); const body = (await response.json()) as { body: string; url: string }; expect(response.status).toBe(200); expect(body.body).toBe("hello"); expect(body.url).toContain("/api/v1/modules/calendar/events/evt-1/rsvp?notify=true"); expect(capturedRequest).toBeInstanceOf(Request); expect(capturedRequest?.headers.get("x-custom")).toBe("abc"); expect(capturedContext).toEqual({ moduleId: "calendar", params: { eventId: "evt-1" }, principal: { kind: "authenticated", userId: "user_1" }, }); expect(Object.isFrozen(capturedContext)).toBe(true); expect(Object.isFrozen(capturedContext?.params)).toBe(true); }); it("gives static and root routes a frozen, empty params object", async () => { let rootContext: ModuleRouteHandlerContext | undefined; let staticContext: ModuleRouteHandlerContext | undefined; const { app } = buildModuleApp({ registry: permissionRegistry([{ id: readPermission, baseline: "public" }]), routes: [ moduleRoute({ handler: async (_request, context) => { rootContext = context; return Response.json({ ok: true }); }, method: "GET", moduleId: "calendar", path: "/", permission: readPermission, }), moduleRoute({ handler: async (_request, context) => { staticContext = context; return Response.json({ ok: true }); }, method: "GET", moduleId: "calendar", path: "/events", permission: readPermission, }), ], }); await app.request("/api/v1/modules/calendar"); await app.request("/api/v1/modules/calendar/events"); expect(rootContext?.params).toEqual({}); expect(Object.isFrozen(rootContext?.params)).toBe(true); expect(staticContext?.params).toEqual({}); expect(Object.isFrozen(staticContext?.params)).toBe(true); }); }); describe("module route authorization", () => { it("uses the registry's exact baseline for public, authenticated, and restricted module permissions", async () => { const publicPermission = parsePermissionId("calendar.events.list"); const authenticatedPermission = parsePermissionId("calendar.events.subscribe"); const restrictedPermission = parsePermissionId("calendar.events.read"); const evaluator: AuthorizationEvaluator = { authorize: vi.fn().mockResolvedValue({ allowed: true }), }; const { app } = buildModuleApp({ evaluator, registry: permissionRegistry([ { id: publicPermission, baseline: "public" }, { id: authenticatedPermission, baseline: "authenticated" }, { id: restrictedPermission, baseline: "restricted" }, ]), routes: [ moduleRoute({ handler: async () => Response.json({ ok: true }), method: "GET", moduleId: "calendar", path: "/public", permission: publicPermission, }), moduleRoute({ handler: async () => Response.json({ ok: true }), method: "GET", moduleId: "calendar", path: "/authenticated", permission: authenticatedPermission, }), moduleRoute({ handler: async () => Response.json({ ok: true }), method: "GET", moduleId: "calendar", path: "/restricted", permission: restrictedPermission, }), ], }); await app.request("/api/v1/modules/calendar/public"); await app.request("/api/v1/modules/calendar/authenticated"); await app.request("/api/v1/modules/calendar/restricted"); expect(evaluator.authorize).toHaveBeenNthCalledWith( 1, expect.objectContaining({ baseline: "public", permission: publicPermission }), ); expect(evaluator.authorize).toHaveBeenNthCalledWith( 2, expect.objectContaining({ baseline: "authenticated", permission: authenticatedPermission }), ); expect(evaluator.authorize).toHaveBeenNthCalledWith( 3, expect.objectContaining({ baseline: "restricted", permission: restrictedPermission }), ); }); it("does not invoke the handler when authorization denies", async () => { const handler = vi.fn(async () => Response.json({ ok: true })); const evaluator: AuthorizationEvaluator = { authorize: vi.fn().mockResolvedValue({ allowed: false }), }; const { app } = buildModuleApp({ evaluator, registry: permissionRegistry([{ id: readPermission, baseline: "restricted" }]), routes: [ moduleRoute({ handler, method: "GET", moduleId: "calendar", path: "/events", permission: readPermission, }), ], }); const response = await app.request("/api/v1/modules/calendar/events"); expect(response.status).toBe(403); expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" }); expect(handler).not.toHaveBeenCalled(); }); it("does not invoke the handler when principal resolution fails", async () => { const handler = vi.fn(async () => Response.json({ ok: true })); const runtime = fakeRuntime({ resolvePrincipal: vi.fn().mockRejectedValue(new Error("connection terminated")), }); const { app } = buildModuleApp({ registry: permissionRegistry([{ id: readPermission, baseline: "restricted" }]), routes: [ moduleRoute({ handler, method: "GET", moduleId: "calendar", path: "/events", permission: readPermission, }), ], runtime, }); const response = await app.request("/api/v1/modules/calendar/events"); expect(response.status).toBe(503); expect(handler).not.toHaveBeenCalled(); }); it("does not invoke the handler when the evaluator itself fails", async () => { const handler = vi.fn(async () => Response.json({ ok: true })); const evaluator: AuthorizationEvaluator = { authorize: vi.fn().mockRejectedValue(new Error("connection terminated")), }; const { app } = buildModuleApp({ evaluator, registry: permissionRegistry([{ id: readPermission, baseline: "restricted" }]), routes: [ moduleRoute({ handler, method: "GET", moduleId: "calendar", path: "/events", permission: readPermission, }), ], }); const response = await app.request("/api/v1/modules/calendar/events"); expect(response.status).toBe(503); expect(handler).not.toHaveBeenCalled(); }); it("fails app construction safely when a route names a permission the registry does not register", () => { expect(() => buildModuleApp({ registry: permissionRegistry([]), routes: [ moduleRoute({ handler: vi.fn(), method: "GET", moduleId: "calendar", path: "/events", permission: readPermission, }), ], }), ).toThrow(ModuleRouteConfigurationError); }); it("never leaks the offending permission, route path, or module id in the configuration error", () => { let captured: unknown; try { buildModuleApp({ registry: permissionRegistry([]), routes: [ moduleRoute({ handler: vi.fn(), method: "GET", moduleId: "secret-module", path: "/secret-path", permission: parsePermissionId("secret-module.secret.permission"), }), ], }); } catch (error) { captured = error; } expect(captured).toBeInstanceOf(ModuleRouteConfigurationError); const text = `${(captured as Error).message}\n${(captured as Error).stack}`; expect(text).not.toContain("secret-module"); expect(text).not.toContain("secret-path"); expect(text).not.toContain("secret.permission"); }); }); describe("module route trusted-origin enforcement", () => { it("rejects a state-changing module route with no or untrusted Origin before authorization runs", async () => { const handler = vi.fn(async () => Response.json({ ok: true })); const evaluator: AuthorizationEvaluator = { authorize: vi.fn().mockResolvedValue({ allowed: true }), }; const methods = ["POST", "PUT", "PATCH", "DELETE"] as const; const { app } = buildModuleApp({ evaluator, registry: permissionRegistry([{ id: writePermission, baseline: "public" }]), routes: methods.map((method) => moduleRoute({ handler, method, moduleId: "calendar", path: "/events", permission: writePermission, }), ), }); for (const method of methods) { const noOriginResponse = await app.request("/api/v1/modules/calendar/events", { method }); expect(noOriginResponse.status).toBe(403); expect(await noOriginResponse.json()).toEqual({ code: "UNTRUSTED_ORIGIN" }); const untrustedResponse = await app.request("/api/v1/modules/calendar/events", { headers: { origin: untrustedOrigin }, method, }); expect(untrustedResponse.status).toBe(403); } expect(handler).not.toHaveBeenCalled(); expect(evaluator.authorize).not.toHaveBeenCalled(); }); it("allows a trusted-origin state-changing request through to the handler", async () => { const handler = vi.fn(async () => Response.json({ ok: true })); const { app } = buildModuleApp({ registry: permissionRegistry([{ id: writePermission, baseline: "public" }]), routes: [ moduleRoute({ handler, method: "POST", moduleId: "calendar", path: "/events", permission: writePermission, }), ], }); const response = await app.request("/api/v1/modules/calendar/events", { headers: { origin: trustedOrigin }, method: "POST", }); expect(response.status).toBe(200); expect(handler).toHaveBeenCalledTimes(1); }); it("does not require an Origin header for a GET module route", async () => { const handler = vi.fn(async () => Response.json({ ok: true })); const { app } = buildModuleApp({ registry: permissionRegistry([{ id: readPermission, baseline: "public" }]), routes: [ moduleRoute({ handler, method: "GET", moduleId: "calendar", path: "/events", permission: readPermission, }), ], }); const response = await app.request("/api/v1/modules/calendar/events"); expect(response.status).toBe(200); expect(handler).toHaveBeenCalledTimes(1); }); }); describe("module route CORS", () => { it("advertises GET, POST, PUT, PATCH, and DELETE on preflight", async () => { const { app } = buildModuleApp({ registry: permissionRegistry([{ id: writePermission, baseline: "public" }]), routes: [ moduleRoute({ handler: vi.fn(), method: "PUT", moduleId: "calendar", path: "/events", permission: writePermission, }), ], }); const response = await app.request("/api/v1/modules/calendar/events", { headers: { "access-control-request-method": "PUT", origin: trustedOrigin }, method: "OPTIONS", }); expect(response.status).toBe(204); const allowedMethods = response.headers.get("access-control-allow-methods") ?? ""; for (const method of ["GET", "POST", "PUT", "PATCH", "DELETE"]) { expect(allowedMethods).toContain(method); } }); it("never invokes authorization or the handler for an OPTIONS preflight", async () => { const handler = vi.fn(async () => Response.json({ ok: true })); const evaluator: AuthorizationEvaluator = { authorize: vi.fn().mockResolvedValue({ allowed: true }), }; const { app } = buildModuleApp({ evaluator, registry: permissionRegistry([{ id: writePermission, baseline: "public" }]), routes: [ moduleRoute({ handler, method: "POST", moduleId: "calendar", path: "/events", permission: writePermission, }), ], }); await app.request("/api/v1/modules/calendar/events", { headers: { "access-control-request-method": "POST", origin: trustedOrigin }, method: "OPTIONS", }); expect(handler).not.toHaveBeenCalled(); expect(evaluator.authorize).not.toHaveBeenCalled(); }); it("never reflects an untrusted origin for a module route", async () => { const { app } = buildModuleApp({ registry: permissionRegistry([{ id: readPermission, baseline: "public" }]), routes: [ moduleRoute({ handler: async () => Response.json({ ok: true }), method: "GET", moduleId: "calendar", path: "/events", permission: readPermission, }), ], }); const response = await app.request("/api/v1/modules/calendar/events", { headers: { origin: untrustedOrigin }, }); expect(response.headers.get("access-control-allow-origin")).toBeNull(); }); }); describe("module route handler failure sanitization", () => { it("returns a fixed sanitized 500 when the handler throws, never leaking the caught message", async () => { const handler = vi.fn(async () => { throw new Error("password=hunter2 host=db.internal.example"); }); const { app } = buildModuleApp({ registry: permissionRegistry([{ id: readPermission, baseline: "public" }]), routes: [ moduleRoute({ handler, method: "GET", moduleId: "calendar", path: "/events", permission: readPermission, }), ], }); const response = await app.request("/api/v1/modules/calendar/events"); const text = await response.text(); expect(response.status).toBe(500); expect(JSON.parse(text)).toEqual({ code: "MODULE_HANDLER_FAILED" }); expect(text).not.toContain("hunter2"); expect(text).not.toContain("db.internal.example"); expect(response.headers.get("cache-control")).toBe("no-store"); }); it("returns the same fixed sanitized 500 when the handler rejects", async () => { const handler = vi.fn(() => Promise.reject(new Error("rejected"))); const { app } = buildModuleApp({ registry: permissionRegistry([{ id: readPermission, baseline: "public" }]), routes: [ moduleRoute({ handler, method: "GET", moduleId: "calendar", path: "/events", permission: readPermission, }), ], }); const response = await app.request("/api/v1/modules/calendar/events"); expect(response.status).toBe(500); expect(await response.json()).toEqual({ code: "MODULE_HANDLER_FAILED" }); }); it("returns the same fixed sanitized 500 when the handler returns a non-Response value", async () => { const handler = vi.fn(async () => ({ ok: true }) as unknown as Response); const { app } = buildModuleApp({ registry: permissionRegistry([{ id: readPermission, baseline: "public" }]), routes: [ moduleRoute({ handler, method: "GET", moduleId: "calendar", path: "/events", permission: readPermission, }), ], }); const response = await app.request("/api/v1/modules/calendar/events"); expect(response.status).toBe(500); expect(await response.json()).toEqual({ code: "MODULE_HANDLER_FAILED" }); }); it("passes through a valid module-owned 4xx/5xx Response unchanged", async () => { const handler = vi.fn(async () => Response.json({ code: "MODULE_NOT_FOUND" }, { status: 404 }), ); const { app } = buildModuleApp({ registry: permissionRegistry([{ id: readPermission, baseline: "public" }]), routes: [ moduleRoute({ handler, method: "GET", moduleId: "calendar", path: "/events", permission: readPermission, }), ], }); const response = await app.request("/api/v1/modules/calendar/events"); expect(response.status).toBe(404); expect(await response.json()).toEqual({ code: "MODULE_NOT_FOUND" }); }); }); describe("module route response header policy", () => { it("strips module-supplied CORS-control headers so the platform policy remains authoritative", async () => { const handler = vi.fn( async () => new Response("body", { headers: { "access-control-allow-credentials": "true", "access-control-allow-methods": "GET, POST, PUT, PATCH, DELETE", "access-control-allow-origin": "*", "access-control-expose-headers": "X-Secret", }, status: 200, }), ); const { app } = buildModuleApp({ registry: permissionRegistry([{ id: readPermission, baseline: "public" }]), routes: [ moduleRoute({ handler, method: "GET", moduleId: "calendar", path: "/events", permission: readPermission, }), ], }); const response = await app.request("/api/v1/modules/calendar/events", { headers: { origin: untrustedOrigin }, }); expect(response.headers.get("access-control-allow-origin")).not.toBe("*"); expect(response.headers.get("access-control-expose-headers")).toBeNull(); }); it("forces Cache-Control: no-store and Vary: Origin, Cookie on a successful module response", async () => { const handler = vi.fn( async () => new Response("body", { headers: { "content-type": "text/plain" }, status: 200 }), ); const { app } = buildModuleApp({ registry: permissionRegistry([{ id: readPermission, baseline: "public" }]), routes: [ moduleRoute({ handler, method: "GET", moduleId: "calendar", path: "/events", permission: readPermission, }), ], }); const response = await app.request("/api/v1/modules/calendar/events"); expect(response.headers.get("cache-control")).toBe("no-store"); const vary = response.headers.get("vary") ?? ""; expect(vary).toContain("Origin"); expect(vary).toContain("Cookie"); }); it("preserves an additional legitimate Vary value from the handler alongside the forced ones", async () => { const handler = vi.fn( async () => new Response("body", { headers: { Vary: "Accept-Language" }, status: 200 }), ); const { app } = buildModuleApp({ registry: permissionRegistry([{ id: readPermission, baseline: "public" }]), routes: [ moduleRoute({ handler, method: "GET", moduleId: "calendar", path: "/events", permission: readPermission, }), ], }); const response = await app.request("/api/v1/modules/calendar/events"); const vary = response.headers.get("vary") ?? ""; expect(vary).toContain("Accept-Language"); expect(vary).toContain("Origin"); expect(vary).toContain("Cookie"); }); // The following exercise `mergedVaryHeader` directly rather than through the full app: the // outer, pre-existing `cors()` middleware (`apps/api/src/app.ts`, unchanged by module routes) // unconditionally appends its own "Vary: Origin" to every response with a dynamic-function // origin, on top of whatever this module already set — a real Hono behavior, present on every // route including `/api/v1/principal` since Phase 4B, not something module routing introduces // or can suppress. Testing the merge function directly proves this module's own logic is // correct in isolation, without coupling the assertion to that separate middleware's behavior. describe("mergedVaryHeader", () => { it("collapses an existing * to a bare * instead of the invalid *, Origin, Cookie", () => { // RFC 9110 §12.5.5: a Vary field-value of "*" must never be combined with any other value. expect(mergedVaryHeader("*")).toBe("*"); expect(mergedVaryHeader("*, Accept-Language")).toBe("*"); expect(mergedVaryHeader(" * ")).toBe("*"); }); it("merges case-insensitively so a differently-cased Origin/Cookie never appears as a redundant duplicate", () => { expect(mergedVaryHeader("origin, Accept-Language, COOKIE, origin")).toBe( "Origin, Accept-Language, Cookie", ); }); it("forces canonical Origin/Cookie casing even when absent, and tolerates irregular whitespace", () => { expect(mergedVaryHeader(null)).toBe("Origin, Cookie"); expect(mergedVaryHeader("")).toBe("Origin, Cookie"); expect(mergedVaryHeader(" Accept-Language , ORIGIN ")).toBe( "Accept-Language, Origin, Cookie", ); }); it("de-duplicates a literal repeated token with identical casing", () => { expect(mergedVaryHeader("Accept-Language, Accept-Language")).toBe( "Accept-Language, Origin, Cookie", ); }); }); it("still forces Origin and Cookie into Vary end-to-end even when a handler supplies its own casing", async () => { const handler = vi.fn( async () => new Response("body", { headers: { Vary: "origin" }, status: 200 }), ); const { app } = buildModuleApp({ registry: permissionRegistry([{ id: readPermission, baseline: "public" }]), routes: [ moduleRoute({ handler, method: "GET", moduleId: "calendar", path: "/events", permission: readPermission, }), ], }); const response = await app.request("/api/v1/modules/calendar/events"); const vary = response.headers.get("vary") ?? ""; expect(vary).toContain("Origin"); expect(vary).toContain("Cookie"); }); it("does not read or buffer a streaming response body while sanitizing headers", async () => { const chunks = ["first-chunk", "second-chunk"]; const handler = vi.fn(async () => { const stream = new ReadableStream({ start(controller) { for (const chunk of chunks) { controller.enqueue(new TextEncoder().encode(chunk)); } controller.close(); }, }); return new Response(stream, { status: 200 }); }); const { app } = buildModuleApp({ registry: permissionRegistry([{ id: readPermission, baseline: "public" }]), routes: [ moduleRoute({ handler, method: "GET", moduleId: "calendar", path: "/events", permission: readPermission, }), ], }); const response = await app.request("/api/v1/modules/calendar/events"); expect(await response.text()).toBe(chunks.join("")); }); }); describe("module route HEAD semantics", () => { it("traverses the GET route's permission middleware and returns no body when authorized", async () => { const handler = vi.fn( async () => new Response("body-text", { status: 200 }), ); const { app } = buildModuleApp({ registry: permissionRegistry([{ id: readPermission, baseline: "public" }]), routes: [ moduleRoute({ handler, method: "GET", moduleId: "calendar", path: "/events", permission: readPermission, }), ], }); const response = await app.request("/api/v1/modules/calendar/events", { method: "HEAD" }); expect(response.status).toBe(200); expect(await response.text()).toBe(""); expect(handler).toHaveBeenCalledTimes(1); }); it("denies a HEAD request exactly like the GET route would, without invoking the handler", async () => { const handler = vi.fn( async () => new Response("body-text", { status: 200 }), ); const evaluator: AuthorizationEvaluator = { authorize: vi.fn().mockResolvedValue({ allowed: false }), }; const { app } = buildModuleApp({ evaluator, registry: permissionRegistry([{ id: readPermission, baseline: "restricted" }]), routes: [ moduleRoute({ handler, method: "GET", moduleId: "calendar", path: "/events", permission: readPermission, }), ], }); const response = await app.request("/api/v1/modules/calendar/events", { method: "HEAD" }); expect(response.status).toBe(403); expect(await response.text()).toBe(""); expect(handler).not.toHaveBeenCalled(); }); }); describe("module routes and the rest of the API", () => { it("does not invoke a handler for an unmatched module path", async () => { const handler = vi.fn(async () => Response.json({ ok: true })); const { app } = buildModuleApp({ registry: permissionRegistry([{ id: readPermission, baseline: "public" }]), routes: [ moduleRoute({ handler, method: "GET", moduleId: "calendar", path: "/events", permission: readPermission, }), ], }); const response = await app.request("/api/v1/modules/calendar/does-not-exist"); expect(response.status).toBe(404); expect(await response.json()).toEqual({ code: "NOT_FOUND" }); expect(handler).not.toHaveBeenCalled(); }); it("never invokes a handler merely by constructing the app", () => { const handler = vi.fn(async () => Response.json({ ok: true })); buildModuleApp({ registry: permissionRegistry([{ id: readPermission, baseline: "public" }]), routes: [ moduleRoute({ handler, method: "GET", moduleId: "calendar", path: "/events", permission: readPermission, }), ], }); expect(handler).not.toHaveBeenCalled(); }); it("preserves existing API behavior with zero module routes", async () => { const { app } = buildModuleApp({ registry: permissionRegistry([]), routes: [] }); const response = await app.request("/api/v1/principal"); expect(response.status).toBe(200); }); });