feat: expose core identity transport
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
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> = {}): 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<string, unknown>;
|
||||
|
||||
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.
|
||||
});
|
||||
Reference in New Issue
Block a user