Add GET /api/v1/health/live and GET /api/v1/health/ready per ADR 0044.
- live returns a fixed 200 {"status":"live"} whenever the process serves.
- ready returns 200 {"status":"ready"} only when module startup has
completed (and shutdown has not begun) and a database ping succeeds
within 2s; otherwise a fixed 503 {"code":"NOT_READY"}.
- The ping runs on the shared pool as one simple-protocol message with
a transaction-local statement_timeout, so PostgreSQL cancels a stalled
statement; the pool discards the connection on failure.
- At most one ping is ever unsettled: probes share its outcome and, once
its deadline passes, answer 503 immediately until it settles.
- Both routes set Cache-Control: no-store, accept GET only (405 with
Allow: GET otherwise, HEAD included), never resolve a principal, and
are mounted in app.ts beside, never inside, /api/v1/modules/.
- createApiApp gains a required `health` option; startApi supplies it
and clears module readiness when shutdown begins.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
890 lines
34 KiB
TypeScript
890 lines
34 KiB
TypeScript
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 { IdentityRuntimeError } from "@ekklesia/core/identity";
|
|
import {
|
|
createModuleUiPublicationRegistry,
|
|
EMPTY_MODULE_REALTIME_REGISTRY,
|
|
type ModuleUiPublicationRegistry,
|
|
} from "@ekklesia/core/modules";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import {
|
|
type CreateApiAppAuthorizationOptions,
|
|
type CreateApiAppOptions,
|
|
createApiApp as createApiAppWithModules,
|
|
} from "../src/app.js";
|
|
import { AUTH_REQUEST_MAX_BODY_BYTES } from "../src/body-limits.js";
|
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
|
import { readyHealth } from "./support/ready-health.js";
|
|
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
|
|
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
|
|
import { emptyModuleStorage } from "./support/in-memory-module-storage.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,
|
|
};
|
|
}
|
|
|
|
/** 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([]);
|
|
}
|
|
|
|
/** Most application-wiring cases exercise no modules. Keep that fact explicit in one test helper
|
|
* while production composition still requires the realtime registry with no implicit fallback. */
|
|
function createApiApp(options: Omit<CreateApiAppOptions, "moduleRealtime">) {
|
|
return createApiAppWithModules({ ...options, moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY });
|
|
}
|
|
|
|
/** 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<AuthorizationEvaluator>;
|
|
readonly registry?: PermissionRegistry;
|
|
readonly store?: Partial<AuthorizationStore>;
|
|
}): 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 };
|
|
}
|
|
|
|
function createAuthWiringApp(runtime: EkklesiaIdentityRuntime) {
|
|
return createApiApp({
|
|
moduleRoutes: Object.freeze([]),
|
|
moduleUi: emptyModuleUiPublication(),
|
|
moduleLifecycle: emptyModuleLifecycle(),
|
|
moduleConfiguration: emptyModuleConfiguration(),
|
|
moduleStorage: emptyModuleStorage(),
|
|
dashboard: createInMemoryDashboardStore(),
|
|
health: readyHealth(),
|
|
moduleServices: fakeModuleServices(),
|
|
authorization: fakeAuthorization({}),
|
|
runtime,
|
|
trustedOrigins: [trustedOrigin],
|
|
});
|
|
}
|
|
|
|
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(),
|
|
moduleStorage: emptyModuleStorage(),
|
|
dashboard: createInMemoryDashboardStore(),
|
|
health: readyHealth(),
|
|
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.json()).toEqual({ ok: true });
|
|
expect(runtime.handleAuthRequest).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("replaces only a successful email sign-in body without exposing Better Auth's token or user", async () => {
|
|
const nativeCookie =
|
|
"__Secure-better-auth.session_token=opaque-cookie-value; Path=/; HttpOnly; Secure; SameSite=Lax";
|
|
const nativeBody = JSON.stringify({
|
|
token: "javascript-visible-session-token",
|
|
user: { email: "[email protected]", id: "user_123", name: "Owner" },
|
|
});
|
|
const nativeResponse = new Response(nativeBody, {
|
|
headers: {
|
|
"Access-Control-Allow-Origin": trustedOrigin,
|
|
"Content-Length": "4096",
|
|
"Content-Type": "application/json",
|
|
"Set-Cookie": nativeCookie,
|
|
"X-Native-Auth-Header": "preserved",
|
|
},
|
|
status: 201,
|
|
statusText: "Signed In",
|
|
});
|
|
const runtime = fakeRuntime({
|
|
handleAuthRequest: vi.fn().mockResolvedValue(nativeResponse),
|
|
});
|
|
const app = createAuthWiringApp(runtime);
|
|
|
|
const response = await app.request("/api/auth/sign-in/email", {
|
|
headers: { origin: trustedOrigin },
|
|
method: "POST",
|
|
});
|
|
const visibleBody = await response.text();
|
|
|
|
expect(response.status).toBe(201);
|
|
expect(response.statusText).toBe("Signed In");
|
|
expect(JSON.parse(visibleBody)).toEqual({ ok: true });
|
|
expect(visibleBody).not.toContain("token");
|
|
expect(visibleBody).not.toContain("user");
|
|
expect(visibleBody).not.toContain("session");
|
|
expect(visibleBody).not.toContain("[email protected]");
|
|
expect(response.headers.get("set-cookie")).toBe(nativeCookie);
|
|
expect(response.headers.get("access-control-allow-origin")).toBe(trustedOrigin);
|
|
expect(response.headers.get("access-control-allow-credentials")).toBe("true");
|
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
|
expect(response.headers.get("content-type")).toBe("application/json; charset=UTF-8");
|
|
expect(response.headers.get("content-length")).not.toBe("4096");
|
|
expect(response.headers.get("x-native-auth-header")).toBe("preserved");
|
|
expect(nativeResponse.bodyUsed).toBe(false);
|
|
});
|
|
|
|
it.each([
|
|
"/api/auth/%73ign-in/email",
|
|
"/api/auth/sign-in%2Femail",
|
|
])("normalizes the router-equivalent encoded sign-in path %s", async (path) => {
|
|
const runtime = fakeRuntime({
|
|
handleAuthRequest: vi.fn().mockResolvedValue(
|
|
Response.json({
|
|
token: "javascript-visible-session-token",
|
|
user: { email: "[email protected]" },
|
|
}),
|
|
),
|
|
});
|
|
const app = createAuthWiringApp(runtime);
|
|
|
|
const response = await app.request(path, { method: "POST" });
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(await response.json()).toEqual({ ok: true });
|
|
expect(runtime.handleAuthRequest).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("preserves a failed email sign-in response body and status unchanged", async () => {
|
|
const nativeFailure = JSON.stringify({ code: "INVALID_EMAIL_OR_PASSWORD" });
|
|
const runtime = fakeRuntime({
|
|
handleAuthRequest: vi.fn().mockResolvedValue(
|
|
new Response(nativeFailure, {
|
|
headers: { "Content-Type": "application/json" },
|
|
status: 401,
|
|
statusText: "Unauthorized",
|
|
}),
|
|
),
|
|
});
|
|
const app = createAuthWiringApp(runtime);
|
|
|
|
const response = await app.request("/api/auth/sign-in/email", { method: "POST" });
|
|
|
|
expect(response.status).toBe(401);
|
|
expect(response.statusText).toBe("Unauthorized");
|
|
expect(await response.text()).toBe(nativeFailure);
|
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
|
});
|
|
|
|
it.each([
|
|
["a GET to the sign-in path", "GET", "/api/auth/sign-in/email"],
|
|
["a sign-out POST", "POST", "/api/auth/sign-out"],
|
|
["a trailing-slash sign-in POST", "POST", "/api/auth/sign-in/email/"],
|
|
])("leaves %s on Better Auth's native success contract", async (_label, method, path) => {
|
|
const runtime = fakeRuntime({
|
|
handleAuthRequest: vi
|
|
.fn()
|
|
.mockResolvedValue(new Response("native-auth-body", { status: 200 })),
|
|
});
|
|
const app = createAuthWiringApp(runtime);
|
|
|
|
const response = await app.request(path, { method });
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(await response.text()).toBe("native-auth-body");
|
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
|
});
|
|
|
|
describe("unused native session-token endpoints", () => {
|
|
const disabledRequests = [
|
|
["GET", "/api/auth/get-session"],
|
|
["POST", "/api/auth/get-session"],
|
|
["GET", "/api/auth/list-sessions"],
|
|
["POST", "/api/auth/update-session"],
|
|
["POST", "/api/auth/change-password"],
|
|
] as const;
|
|
|
|
it.each(
|
|
disabledRequests,
|
|
)("returns a fixed 404 for %s %s, including query and trailing-slash spellings, without invoking Better Auth", async (method, path) => {
|
|
const runtime = fakeRuntime({
|
|
handleAuthRequest: vi.fn().mockResolvedValue(
|
|
Response.json({
|
|
session: { token: "javascript-visible-session-token" },
|
|
token: "another-session-token",
|
|
}),
|
|
),
|
|
});
|
|
const app = createAuthWiringApp(runtime);
|
|
|
|
for (const requestPath of [path, `${path}?include=session`, `${path}/`]) {
|
|
const response = await app.request(requestPath, {
|
|
headers: { origin: trustedOrigin },
|
|
method,
|
|
});
|
|
const body = await response.text();
|
|
|
|
expect(response.status).toBe(404);
|
|
expect(JSON.parse(body)).toEqual({ code: "NOT_FOUND" });
|
|
expect(body).not.toContain("token");
|
|
expect(body).not.toContain("session");
|
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
|
expect(response.headers.get("set-cookie")).toBeNull();
|
|
expect(response.headers.get("access-control-allow-origin")).toBe(trustedOrigin);
|
|
expect(response.headers.get("access-control-allow-credentials")).toBe("true");
|
|
}
|
|
|
|
expect(runtime.handleAuthRequest).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([
|
|
["GET", "/api/auth/%67et-session"],
|
|
["GET", "/api/auth/%6Cist-sessions"],
|
|
["POST", "/api/auth/%75pdate-session"],
|
|
["POST", "/api/auth/change-password%2F"],
|
|
["GET", "/api/auth/get-session%2F"],
|
|
["GET", "/api/auth%2Fget-session"],
|
|
["GET", "/api/auth/get-session%"],
|
|
])("does not delegate the encoded or malformed spelling %s %s", async (method, path) => {
|
|
const runtime = fakeRuntime({
|
|
handleAuthRequest: vi
|
|
.fn()
|
|
.mockResolvedValue(Response.json({ session: { token: "exposed" } })),
|
|
});
|
|
const app = createAuthWiringApp(runtime);
|
|
|
|
const response = await app.request(path, { method });
|
|
|
|
expect(response.status).toBe(404);
|
|
expect(await response.json()).toEqual({ code: "NOT_FOUND" });
|
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
|
expect(runtime.handleAuthRequest).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("denies an oversized update-session body before the body limiter or Better Auth reads it", async () => {
|
|
const runtime = fakeRuntime();
|
|
const app = createAuthWiringApp(runtime);
|
|
|
|
const response = await app.request("/api/auth/update-session", {
|
|
body: "x",
|
|
headers: {
|
|
"content-length": String(AUTH_REQUEST_MAX_BODY_BYTES + 1),
|
|
"content-type": "application/json",
|
|
},
|
|
method: "POST",
|
|
});
|
|
|
|
expect(response.status).toBe(404);
|
|
expect(await response.json()).toEqual({ code: "NOT_FOUND" });
|
|
expect(runtime.handleAuthRequest).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([
|
|
["POST", "/api/auth/list-sessions"],
|
|
["GET", "/api/auth/update-session"],
|
|
["GET", "/api/auth/change-password"],
|
|
["GET", "/api/auth/list-sessions/export"],
|
|
])("leaves the neighbouring non-denied request %s %s on the native contract", async (method, path) => {
|
|
const runtime = fakeRuntime({
|
|
handleAuthRequest: vi
|
|
.fn()
|
|
.mockResolvedValue(new Response("native-method-response", { status: 200 })),
|
|
});
|
|
const app = createAuthWiringApp(runtime);
|
|
|
|
const response = await app.request(path, { method });
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(await response.text()).toBe("native-method-response");
|
|
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(),
|
|
moduleStorage: emptyModuleStorage(),
|
|
dashboard: createInMemoryDashboardStore(),
|
|
health: readyHealth(),
|
|
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(),
|
|
moduleStorage: emptyModuleStorage(),
|
|
dashboard: createInMemoryDashboardStore(),
|
|
health: readyHealth(),
|
|
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(),
|
|
moduleStorage: emptyModuleStorage(),
|
|
dashboard: createInMemoryDashboardStore(),
|
|
health: readyHealth(),
|
|
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(),
|
|
moduleStorage: emptyModuleStorage(),
|
|
dashboard: createInMemoryDashboardStore(),
|
|
health: readyHealth(),
|
|
moduleServices: fakeModuleServices(),
|
|
authorization: fakeAuthorization({}),
|
|
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({
|
|
moduleRoutes: Object.freeze([]),
|
|
moduleUi: emptyModuleUiPublication(),
|
|
moduleLifecycle: emptyModuleLifecycle(),
|
|
moduleConfiguration: emptyModuleConfiguration(),
|
|
moduleStorage: emptyModuleStorage(),
|
|
dashboard: createInMemoryDashboardStore(),
|
|
health: readyHealth(),
|
|
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(),
|
|
moduleStorage: emptyModuleStorage(),
|
|
dashboard: createInMemoryDashboardStore(),
|
|
health: readyHealth(),
|
|
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(),
|
|
moduleStorage: emptyModuleStorage(),
|
|
dashboard: createInMemoryDashboardStore(),
|
|
health: readyHealth(),
|
|
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.json()).toEqual({ ok: true });
|
|
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(),
|
|
moduleStorage: emptyModuleStorage(),
|
|
dashboard: createInMemoryDashboardStore(),
|
|
health: readyHealth(),
|
|
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(),
|
|
moduleStorage: emptyModuleStorage(),
|
|
dashboard: createInMemoryDashboardStore(),
|
|
health: readyHealth(),
|
|
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(),
|
|
moduleStorage: emptyModuleStorage(),
|
|
dashboard: createInMemoryDashboardStore(),
|
|
health: readyHealth(),
|
|
moduleServices: fakeModuleServices(),
|
|
authorization: fakeAuthorization({}),
|
|
runtime,
|
|
trustedOrigins: [trustedOrigin],
|
|
});
|
|
const stream = new ReadableStream<Uint8Array>({
|
|
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(),
|
|
moduleStorage: emptyModuleStorage(),
|
|
dashboard: createInMemoryDashboardStore(),
|
|
health: readyHealth(),
|
|
moduleServices: fakeModuleServices(),
|
|
authorization: fakeAuthorization({}),
|
|
runtime,
|
|
trustedOrigins: [trustedOrigin],
|
|
});
|
|
const chunk = new Uint8Array(1024).fill(97);
|
|
let enqueued = 0;
|
|
const stream = new ReadableStream<Uint8Array>({
|
|
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(),
|
|
moduleStorage: emptyModuleStorage(),
|
|
dashboard: createInMemoryDashboardStore(),
|
|
health: readyHealth(),
|
|
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(),
|
|
moduleStorage: emptyModuleStorage(),
|
|
dashboard: createInMemoryDashboardStore(),
|
|
health: readyHealth(),
|
|
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(),
|
|
moduleStorage: emptyModuleStorage(),
|
|
dashboard: createInMemoryDashboardStore(),
|
|
health: readyHealth(),
|
|
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(),
|
|
moduleStorage: emptyModuleStorage(),
|
|
dashboard: createInMemoryDashboardStore(),
|
|
health: readyHealth(),
|
|
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(),
|
|
moduleStorage: emptyModuleStorage(),
|
|
dashboard: createInMemoryDashboardStore(),
|
|
health: readyHealth(),
|
|
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(),
|
|
moduleStorage: emptyModuleStorage(),
|
|
dashboard: createInMemoryDashboardStore(),
|
|
health: readyHealth(),
|
|
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(),
|
|
moduleStorage: emptyModuleStorage(),
|
|
dashboard: createInMemoryDashboardStore(),
|
|
health: readyHealth(),
|
|
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(),
|
|
moduleStorage: emptyModuleStorage(),
|
|
dashboard: createInMemoryDashboardStore(),
|
|
health: readyHealth(),
|
|
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(),
|
|
moduleStorage: emptyModuleStorage(),
|
|
dashboard: createInMemoryDashboardStore(),
|
|
health: readyHealth(),
|
|
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();
|
|
});
|
|
});
|
|
});
|