import { ANONYMOUS_PRINCIPAL, createAuthenticatedPrincipal, type ModuleDatabase, type ModuleRuntimeServices, } from "@ekklesia/contracts"; import { type AuthorizationEvaluator, type AuthorizationStore, CORE_PERMISSION_REGISTRY, type PermissionRegistry, } from "@ekklesia/core/authorization"; import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity"; import { createModuleUiPublicationRegistry, type ModuleUiPublicationRegistry, } from "@ekklesia/core/modules"; import { IdentityRuntimeError } from "@ekklesia/core/identity"; import { describe, expect, it, vi } from "vitest"; import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; import { createApiApp, type CreateApiAppAuthorizationOptions } from "../src/app.js"; import { AUTH_REQUEST_MAX_BODY_BYTES } from "../src/body-limits.js"; import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js"; import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js"; const trustedOrigin = "https://dashboard.example.test"; const untrustedOrigin = "https://evil.example.test"; function fakeRuntime(overrides: Partial = {}): EkklesiaIdentityRuntime { return { handleAuthRequest: vi.fn().mockResolvedValue(new Response("auth-ok", { status: 200 })), resolvePrincipal: vi.fn().mockResolvedValue(ANONYMOUS_PRINCIPAL), ...overrides, }; } /** A small typed fake `ModuleRuntimeServices` — these tests exercise `createApiApp`'s own wiring, * never real database behavior, so no PostgreSQL is required. */ function fakeModuleServices(): ModuleRuntimeServices { const database = Object.freeze({ delete: vi.fn(), execute: vi.fn(), insert: vi.fn(), select: vi.fn(), selectDistinct: vi.fn(), selectDistinctOn: vi.fn(), transaction: vi.fn(), update: vi.fn(), }) as unknown as ModuleDatabase; return Object.freeze({ database }); } /** No module publishes UI in these wiring tests, so the publication route is mounted with an * empty registry built by Core's own factory — never a hand-written stub, so the route under test * always sees the real policy object production hands it. */ function emptyModuleUiPublication(): ModuleUiPublicationRegistry { return createModuleUiPublicationRegistry([]); } /** A fully stubbed authorization dependency, denying everything by default — tests that need a * specific decision or store behavior override the relevant method explicitly. The registry * defaults to Core's own (containing `core.authorization.grants.manage`, exactly what the * mounted grants routes require) rather than an app-composed one — these tests exercise * `createApiApp`'s wiring, not permission composition itself. */ function fakeAuthorization(overrides: { readonly evaluator?: Partial; readonly registry?: PermissionRegistry; readonly store?: Partial; }): CreateApiAppAuthorizationOptions { const evaluator: AuthorizationEvaluator = { authorize: vi.fn().mockResolvedValue({ allowed: false }), ...overrides.evaluator, }; const store: AuthorizationStore = { 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.store, }; return { evaluator, registry: overrides.registry ?? CORE_PERMISSION_REGISTRY, store }; } describe("Ekklesia API Hono application", () => { it("mounts Better Auth exactly under /api/auth/*", async () => { const runtime = fakeRuntime(); const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), moduleLifecycle: emptyModuleLifecycle(), moduleConfiguration: emptyModuleConfiguration(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime, trustedOrigins: [trustedOrigin], }); const response = await app.request("/api/auth/sign-in/email", { method: "POST" }); expect(response.status).toBe(200); expect(await response.text()).toBe("auth-ok"); expect(runtime.handleAuthRequest).toHaveBeenCalledTimes(1); }); it("does not route requests outside /api/auth/* to Better Auth", async () => { const runtime = fakeRuntime(); const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), moduleLifecycle: emptyModuleLifecycle(), moduleConfiguration: emptyModuleConfiguration(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime, trustedOrigins: [trustedOrigin], }); const response = await app.request("/sign-in/email", { method: "POST" }); expect(response.status).toBe(404); expect(runtime.handleAuthRequest).not.toHaveBeenCalled(); }); it("returns the anonymous principal shape with no-store caching", async () => { const runtime = fakeRuntime({ resolvePrincipal: vi.fn().mockResolvedValue(ANONYMOUS_PRINCIPAL), }); const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), moduleLifecycle: emptyModuleLifecycle(), moduleConfiguration: emptyModuleConfiguration(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime, trustedOrigins: [trustedOrigin], }); const response = await app.request("/api/v1/principal"); expect(response.status).toBe(200); expect(await response.json()).toEqual({ kind: "anonymous" }); expect(response.headers.get("cache-control")).toBe("no-store"); expect(response.headers.get("vary")).toContain("Cookie"); }); it("never duplicates Origin in Vary even though the route sets it directly and Hono's own CORS middleware also appends it", async () => { const runtime = fakeRuntime({ resolvePrincipal: vi.fn().mockResolvedValue(ANONYMOUS_PRINCIPAL), }); const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), moduleLifecycle: emptyModuleLifecycle(), moduleConfiguration: emptyModuleConfiguration(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime, trustedOrigins: [trustedOrigin], }); const response = await app.request("/api/v1/principal", { headers: { origin: trustedOrigin }, }); const vary = response.headers.get("vary") ?? ""; const tokens = vary.split(",").map((token) => token.trim().toLowerCase()); expect(tokens.filter((token) => token === "origin")).toHaveLength(1); expect(tokens).toContain("cookie"); }); it("returns the authenticated principal shape containing only kind and userId", async () => { const runtime = fakeRuntime({ resolvePrincipal: vi.fn().mockResolvedValue(createAuthenticatedPrincipal("user_123")), }); const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), moduleLifecycle: emptyModuleLifecycle(), moduleConfiguration: emptyModuleConfiguration(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime, trustedOrigins: [trustedOrigin], }); const response = await app.request("/api/v1/principal"); const body = (await response.json()) as Record; expect(response.status).toBe(200); expect(body).toEqual({ kind: "authenticated", userId: "user_123" }); expect(Object.keys(body).sort()).toEqual(["kind", "userId"]); }); it("returns a stable sanitized 503 when principal resolution fails, never a false anonymous 200", async () => { const runtime = fakeRuntime({ resolvePrincipal: vi .fn() .mockRejectedValue(new IdentityRuntimeError("PRINCIPAL_RESOLUTION_FAILED")), }); const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), moduleLifecycle: emptyModuleLifecycle(), moduleConfiguration: emptyModuleConfiguration(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime, trustedOrigins: [trustedOrigin], }); const response = await app.request("/api/v1/principal"); expect(response.status).toBe(503); expect(await response.json()).toEqual({ code: "PRINCIPAL_RESOLUTION_FAILED" }); expect(response.headers.get("cache-control")).toBe("no-store"); }); it("returns a stable sanitized 500 for an unexpected auth-handler failure, never a raw message", async () => { const runtime = fakeRuntime({ handleAuthRequest: vi.fn().mockRejectedValue(new Error("postgresql://admin:secret@db/x")), }); const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), moduleLifecycle: emptyModuleLifecycle(), moduleConfiguration: emptyModuleConfiguration(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime, trustedOrigins: [trustedOrigin], }); const response = await app.request("/api/auth/sign-in/email", { method: "POST" }); const body = await response.json(); expect(response.status).toBe(500); expect(body).toEqual({ code: "INTERNAL_ERROR" }); expect(JSON.stringify(body)).not.toContain("secret"); expect(JSON.stringify(body)).not.toContain("postgresql://"); }); it("forces Cache-Control: no-store on every Better Auth response, including a successful one", async () => { const runtime = fakeRuntime({ handleAuthRequest: vi.fn().mockResolvedValue(new Response("auth-ok", { status: 200 })), }); const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), moduleLifecycle: emptyModuleLifecycle(), moduleConfiguration: emptyModuleConfiguration(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime, trustedOrigins: [trustedOrigin], }); const response = await app.request("/api/auth/sign-in/email", { method: "POST" }); expect(response.status).toBe(200); expect(await response.text()).toBe("auth-ok"); expect(response.headers.get("cache-control")).toBe("no-store"); }); describe("auth request body limit", () => { it("accepts a request exactly at the auth body limit", async () => { const runtime = fakeRuntime(); const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), moduleLifecycle: emptyModuleLifecycle(), moduleConfiguration: emptyModuleConfiguration(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime, trustedOrigins: [trustedOrigin], }); const response = await app.request("/api/auth/sign-in/email", { body: "a".repeat(AUTH_REQUEST_MAX_BODY_BYTES), headers: { "content-length": String(AUTH_REQUEST_MAX_BODY_BYTES) }, method: "POST", }); expect(response.status).toBe(200); expect(runtime.handleAuthRequest).toHaveBeenCalledTimes(1); }); it("rejects a request one byte over the auth body limit with a sanitized 413, never invoking Better Auth", async () => { const runtime = fakeRuntime(); const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), moduleLifecycle: emptyModuleLifecycle(), moduleConfiguration: emptyModuleConfiguration(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime, trustedOrigins: [trustedOrigin], }); const response = await app.request("/api/auth/sign-in/email", { body: "a".repeat(AUTH_REQUEST_MAX_BODY_BYTES + 1), headers: { "content-length": String(AUTH_REQUEST_MAX_BODY_BYTES + 1) }, method: "POST", }); expect(response.status).toBe(413); expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" }); expect(response.headers.get("cache-control")).toBe("no-store"); expect(runtime.handleAuthRequest).not.toHaveBeenCalled(); }); it("rejects an oversized declared Content-Length from its header alone, without invoking Better Auth", async () => { // A declared-oversized Content-Length is rejected synchronously from the header value itself // (`hono/body-limit`'s pinned implementation never reads the stream for this case) — proven // here by the outcome (413, Better Auth never invoked) rather than by instrumenting the // stream, since this in-memory test harness's own `Request`/body plumbing may touch the // stream for reasons unrelated to `bodyLimit`'s own logic. const runtime = fakeRuntime(); const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), moduleLifecycle: emptyModuleLifecycle(), moduleConfiguration: emptyModuleConfiguration(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime, trustedOrigins: [trustedOrigin], }); const stream = new ReadableStream({ start(controller) { controller.enqueue(new TextEncoder().encode("a")); controller.close(); }, }); const response = await app.request("/api/auth/sign-in/email", { body: stream, duplex: "half", headers: { "content-length": String(AUTH_REQUEST_MAX_BODY_BYTES * 10) }, method: "POST", } as RequestInit); expect(response.status).toBe(413); expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" }); expect(runtime.handleAuthRequest).not.toHaveBeenCalled(); }); it("rejects an oversized streamed body with no declared Content-Length", async () => { const runtime = fakeRuntime(); const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), moduleLifecycle: emptyModuleLifecycle(), moduleConfiguration: emptyModuleConfiguration(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime, trustedOrigins: [trustedOrigin], }); const chunk = new Uint8Array(1024).fill(97); let enqueued = 0; const stream = new ReadableStream({ pull(controller) { if (enqueued > AUTH_REQUEST_MAX_BODY_BYTES) { controller.close(); return; } enqueued += chunk.length; controller.enqueue(chunk); }, }); const response = await app.request("/api/auth/sign-in/email", { body: stream, duplex: "half", method: "POST", } as RequestInit); expect(response.status).toBe(413); expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" }); expect(runtime.handleAuthRequest).not.toHaveBeenCalled(); }); }); it("has deterministic 404 behavior for an unknown route", async () => { const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), moduleLifecycle: emptyModuleLifecycle(), moduleConfiguration: emptyModuleConfiguration(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime: fakeRuntime(), trustedOrigins: [trustedOrigin], }); const response = await app.request("/does-not-exist"); expect(response.status).toBe(404); expect(await response.json()).toEqual({ code: "NOT_FOUND" }); }); it("reflects an allowed origin in Access-Control-Allow-Origin with credentials support", async () => { const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), moduleLifecycle: emptyModuleLifecycle(), moduleConfiguration: emptyModuleConfiguration(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime: fakeRuntime(), trustedOrigins: [trustedOrigin], }); const response = await app.request("/api/v1/principal", { headers: { origin: trustedOrigin }, }); expect(response.headers.get("access-control-allow-origin")).toBe(trustedOrigin); expect(response.headers.get("access-control-allow-credentials")).toBe("true"); }); it("never returns Access-Control-Allow-Origin for a disallowed origin", async () => { const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), moduleLifecycle: emptyModuleLifecycle(), moduleConfiguration: emptyModuleConfiguration(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime: fakeRuntime(), trustedOrigins: [trustedOrigin], }); const response = await app.request("/api/v1/principal", { headers: { origin: untrustedOrigin }, }); expect(response.headers.get("access-control-allow-origin")).toBeNull(); }); it("supports requests with no Origin header at all (native/server clients)", async () => { const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), moduleLifecycle: emptyModuleLifecycle(), moduleConfiguration: emptyModuleConfiguration(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime: fakeRuntime(), trustedOrigins: [trustedOrigin], }); const response = await app.request("/api/v1/principal"); expect(response.status).toBe(200); }); it("handles preflight OPTIONS requests deterministically for an allowed origin", async () => { const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), moduleLifecycle: emptyModuleLifecycle(), moduleConfiguration: emptyModuleConfiguration(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime: fakeRuntime(), trustedOrigins: [trustedOrigin], }); const response = await app.request("/api/v1/principal", { headers: { "access-control-request-method": "GET", origin: trustedOrigin, }, method: "OPTIONS", }); expect(response.status).toBe(204); expect(response.headers.get("access-control-allow-origin")).toBe(trustedOrigin); }); it("never wildcards Access-Control-Allow-Origin", async () => { const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), moduleLifecycle: emptyModuleLifecycle(), moduleConfiguration: emptyModuleConfiguration(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime: fakeRuntime(), trustedOrigins: [trustedOrigin], }); const response = await app.request("/api/v1/principal", { headers: { origin: trustedOrigin }, }); expect(response.headers.get("access-control-allow-origin")).not.toBe("*"); }); // Unlike Better Auth's own documented Hono example (which hydrates `c.set("user", session.user)` // on every request), createApiApp only ever calls `c.set` once, for the narrow EkklesiaPrincipal // (kind + userId only), and only on routes `requirePermission` actually protects — proven by the // grants-routes tests, not by a black-box context probe here. `/api/v1/principal` itself still // never touches `c.set`; its response shape is asserted directly above. describe("grants routes mounting (see grants-routes.test.ts for full coverage)", () => { it("mounts the v1 grants routes under /api/v1/grants", async () => { const evaluatorAuthorize = vi.fn().mockResolvedValue({ allowed: false }); const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), moduleLifecycle: emptyModuleLifecycle(), moduleConfiguration: emptyModuleConfiguration(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({ evaluator: { authorize: evaluatorAuthorize } }), runtime: fakeRuntime(), trustedOrigins: [trustedOrigin], }); const response = await app.request("/api/v1/grants?userId=user_123"); // Reaching the permission check (and being denied by it) proves the route is mounted and // wired to the injected evaluator, rather than falling through to the 404 handler. expect(response.status).toBe(403); expect(evaluatorAuthorize).toHaveBeenCalledTimes(1); }); it("uses exactly the injected registry, never an internal Core-only fallback", async () => { // An evaluator that would allow anything, paired with a registry that does not register // core.authorization.grants.manage at all — if createApiApp silently fell back to some // internal registry containing it, this request would reach the evaluator and succeed // (empty grant list, 200). It must instead fail closed on the registry lookup itself. const evaluatorAuthorize = vi.fn().mockResolvedValue({ allowed: true }); const emptyRegistry: PermissionRegistry = { declarations: [], get: () => undefined, has: () => false, }; const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), moduleLifecycle: emptyModuleLifecycle(), moduleConfiguration: emptyModuleConfiguration(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({ evaluator: { authorize: evaluatorAuthorize }, registry: emptyRegistry, }), runtime: fakeRuntime(), trustedOrigins: [trustedOrigin], }); const response = await app.request("/api/v1/grants?userId=user_123"); expect(response.status).toBe(403); expect(evaluatorAuthorize).not.toHaveBeenCalled(); }); it("applies the same CORS policy to /api/v1/grants as the rest of /api/v1/*", async () => { const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), moduleLifecycle: emptyModuleLifecycle(), moduleConfiguration: emptyModuleConfiguration(), dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime: fakeRuntime(), trustedOrigins: [trustedOrigin], }); const response = await app.request("/api/v1/grants?userId=user_123", { headers: { origin: untrustedOrigin }, }); expect(response.headers.get("access-control-allow-origin")).toBeNull(); }); }); });