CI / verify (pull_request) Successful in 51s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 1m49s
CI / image-build (pull_request) Successful in 17s
CI / deploy-smoke (same-origin) (pull_request) Successful in 48s
CI / deploy-smoke (split) (pull_request) Successful in 55s
An installation can now serve the Dashboard and the API on sibling hostnames (dash./api.) beside the default same-origin topology, from one image and with configuration alone. - The Dashboard learns its API origin at run time: the built page carries one empty <meta name="ekklesia-api-origin">, which the API fills with its identity base URL's origin at startup, before computing the page's ETag. The shell accepts only a canonical http(s) origin and otherwise fails closed without making a request. The build-time EKKLESIA_API_BASE_URL is gone from the bundle; the development server injects the same element. - The display host stays on the API's origin, so its device cookies, requests, and offline cache are unchanged. - The API trusts its own origin (trusted origins plus the identity base URL), as Better Auth already does, so the display page's mutations pass the origin check without operators listing it. - Doctor validates EKKLESIA_DASHBOARD_ORIGIN exactly as the API's startup does (it previously skipped it), reports the topology without printing hostnames, and warns when split hostnames are not siblings. - The module UI contract states the CORS header limit (safelisted headers plus Content-Type, safelisted response headers only). - Reference deployment: Caddyfile.split.example (Dashboard hostname serves / and its script; API hostname serves /api/*, /display/*, and the display script; each refuses the other's paths) and EKKLESIA_DASHBOARD_HOSTNAME in the proxy overlay and env examples. - The deploy smoke test runs either topology (CI runs both). The split run installs the module template as a test-only module, checks per-hostname routing and exact CORS, then drives a digest-pinned headless Chromium over the DevTools protocol, with no added dependency: sign-in on the Dashboard hostname, the session cookie host-only on the API hostname, the module UI imported cross-origin and its credentialed request succeeding, the display and its service worker on the API hostname, and sign-out. ADR 0045 is marked Accepted. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1017 lines
40 KiB
TypeScript
1017 lines
40 KiB
TypeScript
import {
|
|
ANONYMOUS_PRINCIPAL,
|
|
createAuthenticatedPrincipal,
|
|
type ModuleDatabase,
|
|
type ModuleRuntimeServices,
|
|
moduleIdSchema,
|
|
parseEkklesiaUserId,
|
|
parsePermissionId,
|
|
} from "@ekklesia/contracts";
|
|
import {
|
|
type AuthorizationEvaluator,
|
|
type AuthorizationStore,
|
|
CORE_PERMISSION_DECLARATIONS,
|
|
CORE_PERMISSION_REGISTRY,
|
|
createPermissionRegistry,
|
|
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,
|
|
StagedModuleRealtimeRegistry,
|
|
} 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 { testDashboardHostAssets } from "./support/dashboard-host-assets.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(),
|
|
dashboardHostAssets: testDashboardHostAssets(),
|
|
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(),
|
|
dashboardHostAssets: testDashboardHostAssets(),
|
|
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(),
|
|
dashboardHostAssets: testDashboardHostAssets(),
|
|
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(),
|
|
dashboardHostAssets: testDashboardHostAssets(),
|
|
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(),
|
|
dashboardHostAssets: testDashboardHostAssets(),
|
|
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(),
|
|
dashboardHostAssets: testDashboardHostAssets(),
|
|
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(),
|
|
dashboardHostAssets: testDashboardHostAssets(),
|
|
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(),
|
|
dashboardHostAssets: testDashboardHostAssets(),
|
|
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(),
|
|
dashboardHostAssets: testDashboardHostAssets(),
|
|
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(),
|
|
dashboardHostAssets: testDashboardHostAssets(),
|
|
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(),
|
|
dashboardHostAssets: testDashboardHostAssets(),
|
|
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(),
|
|
dashboardHostAssets: testDashboardHostAssets(),
|
|
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(),
|
|
dashboardHostAssets: testDashboardHostAssets(),
|
|
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(),
|
|
dashboardHostAssets: testDashboardHostAssets(),
|
|
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(),
|
|
dashboardHostAssets: testDashboardHostAssets(),
|
|
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(),
|
|
dashboardHostAssets: testDashboardHostAssets(),
|
|
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(),
|
|
dashboardHostAssets: testDashboardHostAssets(),
|
|
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(),
|
|
dashboardHostAssets: testDashboardHostAssets(),
|
|
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(),
|
|
dashboardHostAssets: testDashboardHostAssets(),
|
|
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(),
|
|
dashboardHostAssets: testDashboardHostAssets(),
|
|
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(),
|
|
dashboardHostAssets: testDashboardHostAssets(),
|
|
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(),
|
|
dashboardHostAssets: testDashboardHostAssets(),
|
|
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();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("split Dashboard and API origins (ADR 0045)", () => {
|
|
const moduleId = moduleIdSchema.parse("announcements");
|
|
const permission = parsePermissionId("announcements.slideshow.view");
|
|
const endpoint = "/api/v1/module-realtime/announcements/state";
|
|
|
|
/** The full application with one live channel, a signed-in caller, and an evaluator that allows
|
|
* it: the same composition a Dashboard on another origin reaches. */
|
|
function realtimeApp() {
|
|
const staged = new StagedModuleRealtimeRegistry();
|
|
const registration = staged.createRegistrationStage(moduleId, new Set([permission]));
|
|
registration.realtime.channel({ channel: "state", permission });
|
|
registration.commit();
|
|
staged.publisherFor(moduleId).publish("state", { mode: "on" } as never);
|
|
const moduleRealtime = staged.publish();
|
|
const app = createApiAppWithModules({
|
|
moduleRoutes: Object.freeze([]),
|
|
moduleUi: emptyModuleUiPublication(),
|
|
moduleLifecycle: emptyModuleLifecycle(),
|
|
moduleConfiguration: emptyModuleConfiguration(),
|
|
moduleStorage: emptyModuleStorage(),
|
|
moduleRealtime,
|
|
dashboard: createInMemoryDashboardStore(),
|
|
dashboardHostAssets: testDashboardHostAssets(),
|
|
health: readyHealth(),
|
|
moduleServices: fakeModuleServices(),
|
|
authorization: fakeAuthorization({
|
|
evaluator: { authorize: vi.fn().mockResolvedValue({ allowed: true }) },
|
|
registry: createPermissionRegistry([
|
|
...CORE_PERMISSION_DECLARATIONS,
|
|
{
|
|
baseline: "restricted",
|
|
description: "View the announcement slideshow.",
|
|
id: permission,
|
|
},
|
|
]),
|
|
}),
|
|
runtime: fakeRuntime({
|
|
resolvePrincipal: vi
|
|
.fn()
|
|
.mockResolvedValue(
|
|
createAuthenticatedPrincipal(
|
|
parseEkklesiaUserId("88888888-8888-4888-8888-888888888888"),
|
|
),
|
|
),
|
|
}),
|
|
trustedOrigins: [trustedOrigin],
|
|
});
|
|
return { app, moduleRealtime };
|
|
}
|
|
|
|
it("lets the Dashboard's origin open a credentialed realtime stream", async () => {
|
|
const { app, moduleRealtime } = realtimeApp();
|
|
|
|
const response = await app.request(endpoint, {
|
|
headers: { cookie: "session=1", origin: trustedOrigin },
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.headers.get("content-type")).toBe("text/event-stream; charset=utf-8");
|
|
expect(response.headers.get("access-control-allow-origin")).toBe(trustedOrigin);
|
|
expect(response.headers.get("access-control-allow-credentials")).toBe("true");
|
|
expect(response.headers.get("vary")).toContain("Origin");
|
|
await response.body?.cancel();
|
|
moduleRealtime.closeSubscriptions();
|
|
});
|
|
|
|
it("gives an untrusted origin's realtime request no CORS permission to read the stream", async () => {
|
|
const { app, moduleRealtime } = realtimeApp();
|
|
|
|
const response = await app.request(endpoint, {
|
|
headers: { cookie: "session=1", origin: untrustedOrigin },
|
|
});
|
|
|
|
expect(response.headers.get("access-control-allow-origin")).toBeNull();
|
|
await response.body?.cancel();
|
|
moduleRealtime.closeSubscriptions();
|
|
});
|
|
|
|
it("allows only Content-Type beyond the CORS-safelisted request headers, and exposes none", async () => {
|
|
const app = createAuthWiringApp(fakeRuntime());
|
|
|
|
const response = await app.request("/api/v1/principal", {
|
|
headers: {
|
|
"access-control-request-headers": "content-type, x-module-custom",
|
|
"access-control-request-method": "PATCH",
|
|
origin: trustedOrigin,
|
|
},
|
|
method: "OPTIONS",
|
|
});
|
|
|
|
expect(response.headers.get("access-control-allow-origin")).toBe(trustedOrigin);
|
|
expect(response.headers.get("access-control-allow-headers")?.toLowerCase()).toBe(
|
|
"content-type",
|
|
);
|
|
expect(response.headers.get("access-control-expose-headers")).toBeNull();
|
|
});
|
|
});
|