Files
Ekklesia/apps/api/tests/app.test.ts
T
ekklesiaandClaude Opus 5 baa689b2a7
CI / verify (pull_request) Successful in 35s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 46s
feat: add the module configuration and secrets contract
Modules had no way to be told anything. Announcements — the first real
product Ekklesia will absorb — has zero environment variables and compiles
a LAN IP address into its source, so this is the contract standing between
it and the module system.

- A module declares what it needs in its manifest, using six bounded types
  (string, integer, boolean, enum, url, secret) with no nesting and no
  regular expressions. Static declaration is what lets the platform validate
  configuration *before* deciding whether a module may start: an enabled
  module missing a required value is reported `blocked ·
  configuration_missing · <key>` rather than taking startup down from inside
  its own `start` hook, and Doctor reports the same while still importing no
  module code. That forces `manifestVersion: 5`, a hard cutover.
- Ordinary values persist in one Core table (migration 0005), one row per
  (module_id, key), stored as text until validated against the declaring
  module's current manifest. Nothing is coerced; an invalid stored value
  blocks the module rather than falling back to the default. A key the
  manifest no longer declares is retained and ignored.
- Secrets are never persisted. There is no encryption at rest in this
  repository, so a `secret` key is deployment-supplied and mapped in by the
  host under a name the module never sees. A secret declaration has no
  `default` field, the write API refuses secret material, and the HTTP view
  has no `value` field for one — absent by construction, not filtered.
- Modules receive a frozen `context.config` on the registration, runtime,
  and HTTP handler contexts: the same object identity, per module, beside
  `services` rather than inside it (services is deliberately one shared
  instance). No environment access, no database reference, no way to reach
  another module's values.
- Configuration decides eligibility through ADR 0028's existing resolver:
  three new blocked reasons, each with a distinct operator fix, propagating
  to required dependents through the same mechanism as a disabled module.
- Changes take effect at the next restart; both resources report
  restartRequired. New restricted permission
  `core.modules.configuration.manage`, separate from lifecycle, at the
  sibling prefix `/api/v1/module-configuration`.
- Migration participation stays independent of configuration validity.
  Endpoints and credentials for shared infrastructure stay platform
  concerns; a module declares which bucket it needs, not an S3 endpoint.

See ADR 0029.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-22 21:44:43 -05:00

607 lines
24 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 {
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> = {}): 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<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 };
}
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<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(),
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<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(),
dashboard: createInMemoryDashboardStore(),
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(),
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();
});
});
});