import { ANONYMOUS_PRINCIPAL, createAuthenticatedPrincipal } from "@ekklesia/contracts"; import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity"; import { IdentityRuntimeError } from "@ekklesia/core/identity"; import { describe, expect, it, vi } from "vitest"; import { createApiApp } from "../src/app.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, }; } describe("Ekklesia API Hono application", () => { it("mounts Better Auth exactly under /api/auth/*", async () => { const runtime = fakeRuntime(); const app = createApiApp({ 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({ 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({ 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("returns the authenticated principal shape containing only kind and userId", async () => { const runtime = fakeRuntime({ resolvePrincipal: vi.fn().mockResolvedValue(createAuthenticatedPrincipal("user_123")), }); const app = createApiApp({ 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({ 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({ 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("has deterministic 404 behavior for an unknown route", async () => { const app = createApiApp({ 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({ 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({ 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({ 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({ 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({ 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("*"); }); // "No generalized session/user object stored in Hono context" is verified by construction // rather than by a black-box context probe: unlike Better Auth's own documented Hono example // (which hydrates `c.set("user", session.user)` on every request), createApiApp never calls // `c.set` at all — the only per-request identity data that ever exists is the narrow // EkklesiaPrincipal returned directly from the /api/v1/principal handler above, whose exact // shape (kind + userId only) is already asserted by the principal-shape tests in this file. });