CI / verify (pull_request) Successful in 49s
CI / infrastructure-smoke (pull_request) Successful in 13s
CI / migration-smoke (pull_request) Successful in 1m47s
CI / image-build (pull_request) Successful in 19s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / deploy-smoke (split) (pull_request) Successful in 1m4s
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]>
4096 lines
165 KiB
TypeScript
4096 lines
165 KiB
TypeScript
import { randomBytes } from "node:crypto";
|
|
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
import { request as httpRequest } from "node:http";
|
|
import net from "node:net";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { parseEkklesiaUserId, parsePermissionId, SYSTEM_ACTOR } from "@ekklesia/contracts";
|
|
import {
|
|
bootstrapPlatformOwner,
|
|
createAuthorizationDrizzleDatabase,
|
|
createAuthorizationStore,
|
|
} from "@ekklesia/core/authorization";
|
|
import { diagnoseEkklesia } from "@ekklesia/core/diagnostics";
|
|
import {
|
|
applyModuleMigrations,
|
|
createPostgresMigrationDatabase,
|
|
discoverAllMigrations,
|
|
} from "@ekklesia/core/migrations";
|
|
import {
|
|
bootstrapHost,
|
|
type EkklesiaHostConfig,
|
|
signInWithEmail,
|
|
signOutHost,
|
|
} from "@ekklesia/shell";
|
|
import { Client, type QueryResultRow } from "pg";
|
|
import { afterAll, describe, expect, it, vi } from "vitest";
|
|
import type { ClientAddressConfigInput } from "../src/client-address.js";
|
|
import { dashboardHostAssetUrls } from "@ekklesia/shell/dashboard-assets";
|
|
import { createApiRuntimeConfig } from "../src/config.js";
|
|
import { createApiDatabase } from "../src/database.js";
|
|
import { DASHBOARD_API_ORIGIN_PLACEHOLDER } from "../src/dashboard-host-assets.js";
|
|
import {
|
|
DASHBOARD_HOST_PAGE_PATH,
|
|
DASHBOARD_HOST_SCRIPT_PATH,
|
|
} from "../src/dashboard-host-routes.js";
|
|
import {
|
|
HEALTH_LIVE_PATH,
|
|
HEALTH_READY_PATH,
|
|
READINESS_DATABASE_TIMEOUT_MS,
|
|
} from "../src/health-routes.js";
|
|
import {
|
|
MODULE_UI_CACHE_CONTROL,
|
|
MODULE_UI_CONTENT_TYPE,
|
|
moduleUiArtifactUrl,
|
|
} from "../src/module-ui-routes.js";
|
|
import { type NodeServerTimeouts, type RunningApi, startApi } from "../src/server.js";
|
|
|
|
const integrationDatabaseUrlEnvironment = "EKKLESIA_MIGRATION_TEST_DATABASE_URL";
|
|
const coreMigrationsDirectory = path.join(
|
|
path.dirname(fileURLToPath(import.meta.url)),
|
|
"..",
|
|
"..",
|
|
"..",
|
|
"core",
|
|
"drizzle",
|
|
);
|
|
const trackedDatabases = new Set<string>();
|
|
let databaseCounter = 0;
|
|
|
|
class IntegrationHarnessError extends Error {
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = "IntegrationHarnessError";
|
|
this.stack = `${this.name}: ${this.message}`;
|
|
}
|
|
}
|
|
|
|
function harnessError(message: string): IntegrationHarnessError {
|
|
return new IntegrationHarnessError(message);
|
|
}
|
|
|
|
async function withTransportDeadline<Value>(
|
|
operation: Promise<Value>,
|
|
onTimeout: () => void,
|
|
): Promise<Value> {
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
try {
|
|
return await Promise.race([
|
|
operation,
|
|
new Promise<never>((_, reject) => {
|
|
timer = setTimeout(() => {
|
|
onTimeout();
|
|
reject(harnessError("An integration transport operation timed out."));
|
|
}, 5_000);
|
|
}),
|
|
]);
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
function baseDatabaseUrl(): string {
|
|
const value = process.env[integrationDatabaseUrlEnvironment];
|
|
if (typeof value !== "string" || value.trim() === "") {
|
|
throw harnessError("The API integration database is not configured.");
|
|
}
|
|
try {
|
|
const parsed = new URL(value);
|
|
if (parsed.protocol !== "postgres:" && parsed.protocol !== "postgresql:") {
|
|
throw new TypeError("Unsupported protocol.");
|
|
}
|
|
} catch {
|
|
throw harnessError("The API integration database configuration is invalid.");
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function quoteDatabaseName(databaseName: string): string {
|
|
if (!/^[a-z][a-z0-9_]{0,62}$/u.test(databaseName)) {
|
|
throw harnessError("The generated integration database name is invalid.");
|
|
}
|
|
return `"${databaseName}"`;
|
|
}
|
|
|
|
function makeDatabaseName(label: string): string {
|
|
const safeLabel = label
|
|
.toLowerCase()
|
|
.replaceAll(/[^a-z0-9]+/gu, "_")
|
|
.replaceAll(/^_+|_+$/gu, "")
|
|
.slice(0, 14);
|
|
databaseCounter += 1;
|
|
return `ekk_api_${process.pid}_${databaseCounter}_${safeLabel || "scenario"}_${randomBytes(4).toString("hex")}`;
|
|
}
|
|
|
|
function databaseUrl(databaseName: string): string {
|
|
const parsed = new URL(baseDatabaseUrl());
|
|
parsed.pathname = `/${databaseName}`;
|
|
parsed.searchParams.delete("application_name");
|
|
return parsed.toString();
|
|
}
|
|
|
|
async function openAdministrativeClient(connectionString: string): Promise<Client> {
|
|
const client = new Client({ connectionString });
|
|
client.on("error", () => undefined);
|
|
try {
|
|
await client.connect();
|
|
return client;
|
|
} catch {
|
|
try {
|
|
await client.end();
|
|
} catch {
|
|
// The public harness failure remains static when connection cleanup also fails.
|
|
}
|
|
throw harnessError("The API integration database connection failed.");
|
|
}
|
|
}
|
|
|
|
async function safeQuery<Row extends QueryResultRow>(
|
|
client: Client,
|
|
text: string,
|
|
values: unknown[] = [],
|
|
): Promise<readonly Row[]> {
|
|
try {
|
|
const result = await client.query<Row>(text, values);
|
|
return result.rows;
|
|
} catch {
|
|
throw harnessError("An integration database administration operation failed.");
|
|
}
|
|
}
|
|
|
|
async function safeEnd(client: Client): Promise<void> {
|
|
try {
|
|
await client.end();
|
|
} catch {
|
|
throw harnessError("An integration database connection could not be closed.");
|
|
}
|
|
}
|
|
|
|
async function createDatabase(databaseName: string): Promise<void> {
|
|
const admin = await openAdministrativeClient(baseDatabaseUrl());
|
|
try {
|
|
await safeQuery(admin, `CREATE DATABASE ${quoteDatabaseName(databaseName)} TEMPLATE template0`);
|
|
} finally {
|
|
await safeEnd(admin);
|
|
}
|
|
}
|
|
|
|
async function dropDatabase(databaseName: string): Promise<void> {
|
|
const admin = await openAdministrativeClient(baseDatabaseUrl());
|
|
try {
|
|
await safeQuery(
|
|
admin,
|
|
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()",
|
|
[databaseName],
|
|
);
|
|
await safeQuery(
|
|
admin,
|
|
`DROP DATABASE IF EXISTS ${quoteDatabaseName(databaseName)} WITH (FORCE)`,
|
|
);
|
|
trackedDatabases.delete(databaseName);
|
|
} finally {
|
|
await safeEnd(admin);
|
|
}
|
|
}
|
|
|
|
async function activeConnectionCount(databaseName: string): Promise<number> {
|
|
const admin = await openAdministrativeClient(baseDatabaseUrl());
|
|
try {
|
|
const rows = await safeQuery<{ count: string }>(
|
|
admin,
|
|
"SELECT count(*)::text AS count FROM pg_stat_activity WHERE datname = $1",
|
|
[databaseName],
|
|
);
|
|
return Number(rows[0]?.count ?? "0");
|
|
} finally {
|
|
await safeEnd(admin);
|
|
}
|
|
}
|
|
|
|
async function createDisposableDatabase(label: string): Promise<{ name: string; url: string }> {
|
|
const name = makeDatabaseName(label);
|
|
await createDatabase(name);
|
|
trackedDatabases.add(name);
|
|
return { name, url: databaseUrl(name) };
|
|
}
|
|
|
|
async function applyCoreMigrations(url: string): Promise<void> {
|
|
const emptyModulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-api-no-modules-"));
|
|
try {
|
|
const aggregate = await discoverAllMigrations({
|
|
coreMigrationsDirectory,
|
|
moduleDiscovery: {
|
|
moduleSdkVersion: "0.1.0",
|
|
modulesDirectory: emptyModulesDirectory,
|
|
platformVersion: "0.1.0",
|
|
},
|
|
});
|
|
// Phase 4A's and Phase 4C pass 1's committed migrations must not have drifted while building
|
|
// this transport slice.
|
|
expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([
|
|
"core:0000_identity_core_tables",
|
|
"core:0001_authorization_core_tables",
|
|
"core:0002_authorization_integrity",
|
|
"core:0003_dashboard_navigation_tables",
|
|
"core:0004_module_lifecycle_state",
|
|
"core:0005_module_configuration",
|
|
"core:0006_device_identity",
|
|
"core:0007_verified_bootstrap_owners",
|
|
]);
|
|
expect(aggregate.migrations[0]?.checksum).toBe(
|
|
"f8f2086ef06f7a6943e8ffc4350bb010b37aed04c0a72030d2b153c986b1325f",
|
|
);
|
|
expect(aggregate.migrations[1]?.checksum).toBe(
|
|
"830e1fbc26d05af08df8d8710c3d10e1f3016d32c41b8363cb661c49afcbe81e",
|
|
);
|
|
// Pass 3's new Core migration, pinned the same way: 0000/0001 above must stay byte-identical.
|
|
expect(aggregate.migrations[2]?.checksum).toBe(
|
|
"63429473118065dfe3877a1baec61b8f61663b88127d9cb17707c859f27f6036",
|
|
);
|
|
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
|
} finally {
|
|
await rm(emptyModulesDirectory, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
const trackedModulesDirectories: string[] = [];
|
|
const eventSymbols: symbol[] = [];
|
|
|
|
async function createFixtureModulesDirectory(): Promise<string> {
|
|
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-api-installed-modules-"));
|
|
trackedModulesDirectories.push(directory);
|
|
return directory;
|
|
}
|
|
|
|
/** A dynamically imported module fixture cannot close over this test file's local variables, so
|
|
* lifecycle markers cross that boundary through a `globalThis` symbol instead (same technique as
|
|
* `core/tests/module-runtime.test.ts`). */
|
|
function createEventRecorder(): { events: string[]; expression: string } {
|
|
const key = `ekklesia.api.integration.test.${eventSymbols.length}.${Date.now()}.${Math.random()}`;
|
|
const symbol = Symbol.for(key);
|
|
const events: string[] = [];
|
|
(globalThis as unknown as Record<PropertyKey, unknown>)[symbol] = events;
|
|
eventSymbols.push(symbol);
|
|
return { events, expression: `globalThis[Symbol.for(${JSON.stringify(key)})]` };
|
|
}
|
|
|
|
/** Writes a minimal, structurally valid module fixture straight to disk — real discovery,
|
|
* composition, and (where a scenario actually starts the API) real module runtime execution all
|
|
* run against it, nothing hand-built. Register/start/stop push markers onto `recorder.events`
|
|
* instead of throwing, so both "Doctor/composition never executed this" (an empty recorder) and
|
|
* "startApi actually imported, registered, and started this" (a populated recorder) can be proven
|
|
* against the exact same fixture. When declared with the `calendar.events.read` permission, the
|
|
* module also registers a restricted `GET /events/:eventId` route and a restricted, state-changing
|
|
* `POST /events` route against that same permission, so the full HTTP mounting/enforcement chain
|
|
* can be exercised live. */
|
|
/** A handler gate shared with a dynamically imported fixture module through `globalThis`, the same
|
|
* technique `createEventRecorder` uses. The handler resolves `entered` when it is genuinely inside
|
|
* user code and then awaits `released`, so a test can position shutdown precisely instead of
|
|
* inferring handler entry from a connection count. `release` is idempotent and safe in `finally`. */
|
|
function createHandlerGate(): {
|
|
readonly entered: Promise<void>;
|
|
readonly expression: string;
|
|
release: () => void;
|
|
} {
|
|
const key = `ekklesia.api.integration.gate.${eventSymbols.length}.${Date.now()}.${Math.random()}`;
|
|
const symbol = Symbol.for(key);
|
|
let signalEntered: () => void = () => undefined;
|
|
let signalReleased: () => void = () => undefined;
|
|
const entered = new Promise<void>((resolve) => {
|
|
signalEntered = resolve;
|
|
});
|
|
const released = new Promise<void>((resolve) => {
|
|
signalReleased = resolve;
|
|
});
|
|
(globalThis as unknown as Record<PropertyKey, unknown>)[symbol] = {
|
|
enter: () => signalEntered(),
|
|
released,
|
|
};
|
|
eventSymbols.push(symbol);
|
|
return {
|
|
entered,
|
|
expression: `globalThis[Symbol.for(${JSON.stringify(key)})]`,
|
|
release: () => signalReleased(),
|
|
};
|
|
}
|
|
|
|
/** A fixture module with one public route whose handler blocks on the supplied gate, plus a stop
|
|
* hook that records when it ran — everything the graceful-drain ordering assertion needs. */
|
|
async function createGatedRouteModule(
|
|
modulesDirectory: string,
|
|
id: string,
|
|
recorder: { expression: string },
|
|
gate: { expression: string },
|
|
): Promise<void> {
|
|
const permissionId = `${id}.gated.read`;
|
|
const moduleDirectory = path.join(modulesDirectory, id);
|
|
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
|
|
await Promise.all([
|
|
writeFile(
|
|
path.join(moduleDirectory, "package.json"),
|
|
JSON.stringify({
|
|
ekklesia: { layer: "module" },
|
|
name: `@test/module-${id}`,
|
|
type: "module",
|
|
version: "0.1.0",
|
|
}),
|
|
),
|
|
writeFile(
|
|
path.join(moduleDirectory, "ekklesia.module.json"),
|
|
JSON.stringify({
|
|
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
|
entrypoints: { server: "./dist/server/index.js" },
|
|
id,
|
|
manifestVersion: 7,
|
|
name: `${id} module`,
|
|
permissions: [{ baseline: "public", description: "Gated drain probe.", id: permissionId }],
|
|
version: "0.1.0",
|
|
}),
|
|
),
|
|
writeFile(
|
|
path.join(moduleDirectory, "dist/server/index.js"),
|
|
`export default {
|
|
id: ${JSON.stringify(id)},
|
|
version: "0.1.0",
|
|
register({ http, realtime }) {
|
|
realtime.channel({ channel: "state", permission: ${JSON.stringify(permissionId)} });
|
|
http.route({
|
|
method: "GET",
|
|
path: "/gated",
|
|
permission: ${JSON.stringify(permissionId)},
|
|
maxBodyBytes: 0,
|
|
async handler(_request, context) {
|
|
${recorder.expression}.push(${JSON.stringify(`${id}:handler-entered`)});
|
|
${gate.expression}.enter();
|
|
await ${gate.expression}.released;
|
|
context.realtime.publish("state", { status: "finished" });
|
|
${recorder.expression}.push(${JSON.stringify(`${id}:handler-published`)});
|
|
${recorder.expression}.push(${JSON.stringify(`${id}:handler-finished`)});
|
|
return Response.json({ gated: true });
|
|
},
|
|
});
|
|
},
|
|
start({ realtime }) { realtime.publish("state", { status: "waiting" }); },
|
|
stop() { ${recorder.expression}.push(${JSON.stringify(`${id}:stop`)}); },
|
|
};`,
|
|
),
|
|
]);
|
|
}
|
|
|
|
async function createFixtureModule(
|
|
modulesDirectory: string,
|
|
id: string,
|
|
recorder: { expression: string },
|
|
manifestOverrides: Record<string, unknown> = {},
|
|
): Promise<void> {
|
|
const moduleDirectory = path.join(modulesDirectory, id);
|
|
const manifest = {
|
|
manifestVersion: 7,
|
|
id,
|
|
name: `${id} module`,
|
|
version: "0.1.0",
|
|
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
|
|
permissions: [],
|
|
entrypoints: { server: "./dist/server/index.js" },
|
|
...manifestOverrides,
|
|
};
|
|
const declaresCalendarRead = (manifest.permissions as ReadonlyArray<{ id: string }>).some(
|
|
(declaration) => declaration.id === "calendar.events.read",
|
|
);
|
|
const realtimePermission = (manifest.permissions as ReadonlyArray<{ id: string }>).find(
|
|
(declaration) => declaration.id === `${id}.state.view`,
|
|
)?.id;
|
|
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
|
|
await Promise.all([
|
|
writeFile(
|
|
path.join(moduleDirectory, "package.json"),
|
|
JSON.stringify({
|
|
ekklesia: { layer: "module" },
|
|
name: `@test/module-${id}`,
|
|
type: "module",
|
|
version: "0.1.0",
|
|
}),
|
|
),
|
|
writeFile(path.join(moduleDirectory, "ekklesia.module.json"), JSON.stringify(manifest)),
|
|
writeFile(
|
|
path.join(moduleDirectory, "dist/server/index.js"),
|
|
`export default {
|
|
id: ${JSON.stringify(id)},
|
|
version: "0.1.0",
|
|
register({ http, realtime }) {
|
|
${recorder.expression}.push(${JSON.stringify(`${id}:register`)});
|
|
${
|
|
realtimePermission
|
|
? `realtime.channel({ channel: "state", permission: ${JSON.stringify(realtimePermission)} });`
|
|
: ""
|
|
}
|
|
${
|
|
declaresCalendarRead
|
|
? `http.route({
|
|
method: "GET",
|
|
path: "/events/:eventId",
|
|
permission: "calendar.events.read",
|
|
maxBodyBytes: 0,
|
|
async handler(request, context) {
|
|
return Response.json({ eventId: context.params.eventId, principal: context.principal });
|
|
},
|
|
});
|
|
http.route({
|
|
method: "POST",
|
|
path: "/events",
|
|
permission: "calendar.events.read",
|
|
maxBodyBytes: 1024,
|
|
async handler(request, context) {
|
|
return Response.json({ created: true, principal: context.principal }, { status: 201 });
|
|
},
|
|
});
|
|
// An image-sized streamed upload (ADR 0042). The handler counts bytes as they
|
|
// arrive and never materializes the body, which is exactly what a real module
|
|
// storing an upload would do.
|
|
http.route({
|
|
method: "POST",
|
|
path: "/uploads",
|
|
permission: "calendar.events.read",
|
|
maxBodyBytes: 33554432,
|
|
bodyMode: "streamed",
|
|
async handler(request) {
|
|
let bytes = 0;
|
|
if (request.body) {
|
|
const reader = request.body.getReader();
|
|
for (;;) {
|
|
const chunk = await reader.read();
|
|
if (chunk.done) { break; }
|
|
bytes += chunk.value.byteLength;
|
|
}
|
|
}
|
|
return Response.json({ bytes }, { status: 201 });
|
|
},
|
|
});
|
|
http.route({
|
|
method: "POST",
|
|
path: "/small-uploads",
|
|
permission: "calendar.events.read",
|
|
maxBodyBytes: 64,
|
|
bodyMode: "streamed",
|
|
async handler(request) {
|
|
await request.arrayBuffer().catch(() => undefined);
|
|
return Response.json({ swallowed: true }, { status: 201 });
|
|
},
|
|
});`
|
|
: ""
|
|
}
|
|
},
|
|
start({ realtime }) {
|
|
${
|
|
realtimePermission
|
|
? `realtime.publish("state", { module: ${JSON.stringify(id)}, status: "running" });`
|
|
: ""
|
|
}
|
|
${recorder.expression}.push(${JSON.stringify(`${id}:start`)});
|
|
},
|
|
stop() {
|
|
${recorder.expression}.push(${JSON.stringify(`${id}:stop`)});
|
|
},
|
|
};`,
|
|
),
|
|
]);
|
|
}
|
|
|
|
/** A module that ships a real, loadable UI artifact plus the neighbouring files a naive static
|
|
* mount would have exposed: a source map, a sibling bundle chunk, and a package-root secret. */
|
|
async function createUiModule(
|
|
modulesDirectory: string,
|
|
id: string,
|
|
version: string,
|
|
artifact: string,
|
|
): Promise<void> {
|
|
const moduleDirectory = path.join(modulesDirectory, id);
|
|
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
|
|
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
|
|
await Promise.all([
|
|
writeFile(
|
|
path.join(moduleDirectory, "package.json"),
|
|
JSON.stringify({
|
|
ekklesia: { layer: "module" },
|
|
name: `@test/module-${id}`,
|
|
type: "module",
|
|
version,
|
|
}),
|
|
),
|
|
writeFile(
|
|
path.join(moduleDirectory, "ekklesia.module.json"),
|
|
JSON.stringify({
|
|
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
|
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
|
id,
|
|
manifestVersion: 7,
|
|
name: `${id} module`,
|
|
version,
|
|
}),
|
|
),
|
|
writeFile(
|
|
path.join(moduleDirectory, "dist/server/index.js"),
|
|
`export default { id: ${JSON.stringify(id)}, version: ${JSON.stringify(version)} };`,
|
|
),
|
|
writeFile(path.join(moduleDirectory, "dist/ui/index.js"), artifact),
|
|
writeFile(
|
|
path.join(moduleDirectory, "dist/ui/index.js.map"),
|
|
'{"version":3,"sourcesContent":["map-secret"]}',
|
|
),
|
|
writeFile(path.join(moduleDirectory, "dist/ui/chunk.js"), "export const chunkSecret = 1;\n"),
|
|
writeFile(path.join(moduleDirectory, ".env"), "EKKLESIA_IDENTITY_SECRET=env-secret\n"),
|
|
]);
|
|
}
|
|
|
|
afterAll(async () => {
|
|
for (const databaseName of [...trackedDatabases]) {
|
|
await dropDatabase(databaseName).catch(() => undefined);
|
|
}
|
|
for (const directory of trackedModulesDirectories.splice(0)) {
|
|
await rm(directory, { recursive: true, force: true }).catch(() => undefined);
|
|
}
|
|
const globalValues = globalThis as unknown as Record<PropertyKey, unknown>;
|
|
for (const symbol of eventSymbols.splice(0)) {
|
|
delete globalValues[symbol];
|
|
}
|
|
});
|
|
|
|
const identityBaseUrl = "https://identity.example.test";
|
|
const dashboardOrigin = "https://dashboard.example.test";
|
|
const untrustedOrigin = "https://evil.example.test";
|
|
|
|
function apiOrigin(running: RunningApi): string {
|
|
const address = running.server.address();
|
|
if (!address || typeof address !== "object") {
|
|
throw harnessError("The API integration server has no bound address.");
|
|
}
|
|
return `http://127.0.0.1:${address.port}`;
|
|
}
|
|
|
|
/**
|
|
* Marks an address verified out of band, the same trusted step the initial-owner bootstrap performs
|
|
* (ADR 0038).
|
|
*
|
|
* Deliberately not a weakening of the policy: it is the test-harness equivalent of an operator with
|
|
* database credentials, used only so scenarios that are about something else are not obliged to run
|
|
* an SMTP server. That the policy itself is enforced — that an unverified account genuinely cannot
|
|
* sign in — is asserted separately, both in this file and in `email-verification.integration.ts`.
|
|
*/
|
|
async function markEmailVerified(origin: string, email: string): Promise<void> {
|
|
const url = databaseUrlByOrigin.get(origin);
|
|
if (!url) {
|
|
throw harnessError("No database is registered for that API origin.");
|
|
}
|
|
const client = new Client({ connectionString: url });
|
|
await client.connect();
|
|
try {
|
|
await client.query(
|
|
'update "ekklesia_core_user" set "email_verified" = true where lower("email") = $1',
|
|
[email.trim().toLowerCase()],
|
|
);
|
|
} finally {
|
|
await client.end().catch(() => undefined);
|
|
}
|
|
}
|
|
|
|
interface StartConfiguredApiOptions {
|
|
readonly clientAddress?: ClientAddressConfigInput;
|
|
/** Test-only narrowing of the platform's 30-second graceful HTTP drain deadline. */
|
|
readonly drainTimeoutMsOverride?: number;
|
|
readonly modulesDirectoryOverride?: string;
|
|
readonly nodeServerTimeoutOverride?: Partial<NodeServerTimeouts>;
|
|
/** Defaults to `true`: almost every existing scenario in this suite signs accounts up over HTTP
|
|
* and needs it enabled. Pass `false` explicitly for the dedicated signup-gating scenarios. */
|
|
readonly publicSignupEnabled?: boolean;
|
|
/** Defaults to a generous override so this suite's own request volume never trips Better Auth's
|
|
* strict built-in sign-in/sign-up rule. The dedicated rate-limit/client-address scenarios pass
|
|
* `undefined` explicitly to exercise the real, unmodified production rule instead. */
|
|
readonly rateLimitOverride?: { readonly max: number; readonly window: number } | undefined;
|
|
}
|
|
|
|
/**
|
|
* The database each running API in this suite is attached to, keyed by its bound origin.
|
|
*
|
|
* Needed because Phase 7C Pass 2 made a verified address a precondition for holding a session
|
|
* (ADR 0038), so `signUp` below has to mark the account verified before these scenarios — which are
|
|
* about cookies, CSRF, principals, sessions, and authorization, not about verification — can sign
|
|
* in. Verification itself has its own dedicated end-to-end suite.
|
|
*/
|
|
const databaseUrlByOrigin = new Map<string, string>();
|
|
|
|
async function startConfiguredApi(
|
|
url: string,
|
|
options: StartConfiguredApiOptions = {},
|
|
): Promise<RunningApi> {
|
|
const publicSignupEnabled = options.publicSignupEnabled ?? true;
|
|
// This harness explicitly selects development configuration, for two independent reasons that
|
|
// both apply to every scenario in this suite:
|
|
//
|
|
// 1. It connects to the disposable *plaintext* loopback PostgreSQL. Production configuration
|
|
// requires certificate-verified TLS with no exception, so a plaintext test database can
|
|
// only be used by a deployment that has explicitly declared itself non-production.
|
|
// 2. Scenarios that create accounts over HTTP need public signup, which production refuses to
|
|
// enable at all until durable abuse controls exist.
|
|
//
|
|
// This changes only Ekklesia's own configuration-validation strictness — `environment` never
|
|
// reaches Better Auth — and the harness keeps the identical https, non-loopback base URL and
|
|
// trusted origins regardless, so the production-equivalent origin/CSRF behavior ADR 0012
|
|
// established is exercised exactly the same either way.
|
|
const environment = "development" as const;
|
|
const config = createApiRuntimeConfig(
|
|
{
|
|
...(options.clientAddress === undefined ? {} : { clientAddress: options.clientAddress }),
|
|
databaseUrl: url,
|
|
host: "127.0.0.1",
|
|
identity: {
|
|
baseUrl: identityBaseUrl,
|
|
publicSignupEnabled,
|
|
secret: "integration-test-secret-0123456789abcdefgh",
|
|
trustedOrigins: [identityBaseUrl, dashboardOrigin],
|
|
},
|
|
port: 0,
|
|
},
|
|
{ environment },
|
|
);
|
|
const running = await startApi(config, {
|
|
...("rateLimitOverride" in options
|
|
? { ...(options.rateLimitOverride ? { rateLimitOverride: options.rateLimitOverride } : {}) }
|
|
: { rateLimitOverride: { max: 1000, window: 1 } }),
|
|
...(options.modulesDirectoryOverride === undefined
|
|
? {}
|
|
: { modulesDirectoryOverride: options.modulesDirectoryOverride }),
|
|
...(options.nodeServerTimeoutOverride === undefined
|
|
? {}
|
|
: { nodeServerTimeoutOverride: options.nodeServerTimeoutOverride }),
|
|
...(options.drainTimeoutMsOverride === undefined
|
|
? {}
|
|
: { drainTimeoutMsOverride: options.drainTimeoutMsOverride }),
|
|
});
|
|
databaseUrlByOrigin.set(apiOrigin(running), url);
|
|
return running;
|
|
}
|
|
|
|
async function startMigratedApi(
|
|
url: string,
|
|
modulesDirectoryOverride?: string,
|
|
): Promise<RunningApi> {
|
|
// Narrow, explicit test-only overrides: production composition (server.ts's `main`) never sets
|
|
// either. Better Auth's built-in sign-in/sign-up rate limit (3 requests / 10s) would otherwise
|
|
// reject this suite's many sequential requests; `modulesDirectoryOverride` lets a scenario point
|
|
// permission composition at a real, disposable module fixture instead of this repository's own
|
|
// (empty) `modules/` directory.
|
|
return startConfiguredApi(url, {
|
|
...(modulesDirectoryOverride === undefined ? {} : { modulesDirectoryOverride }),
|
|
});
|
|
}
|
|
|
|
function setCookieValue(response: Response): string | undefined {
|
|
return response.headers.get("set-cookie") ?? undefined;
|
|
}
|
|
|
|
function firstCookiePair(setCookieHeader: string): string {
|
|
return setCookieHeader.split(",")[0]?.split(";")[0]?.trim() ?? "";
|
|
}
|
|
|
|
interface SafeCookieObservation {
|
|
readonly domain: string | undefined;
|
|
readonly httpOnly: boolean;
|
|
readonly path: string | undefined;
|
|
readonly sameSite: string | undefined;
|
|
readonly secure: boolean;
|
|
}
|
|
|
|
interface BrowserAuthResponseObservation {
|
|
readonly accessControlAllowCredentials: string | null;
|
|
readonly accessControlAllowOrigin: string | null;
|
|
readonly body: string;
|
|
readonly contentLength: string | null;
|
|
readonly cookie: SafeCookieObservation;
|
|
readonly issuedSetCookie: boolean;
|
|
readonly status: number;
|
|
}
|
|
|
|
interface OriginLockedBrowserCookieJar {
|
|
readonly fetch: typeof fetch;
|
|
hasSessionCookie(): boolean;
|
|
signInResponse(): BrowserAuthResponseObservation;
|
|
signOutResponse(): BrowserAuthResponseObservation;
|
|
}
|
|
|
|
function observeCookie(setCookie: string | undefined): SafeCookieObservation {
|
|
const attributes = (setCookie ?? "")
|
|
.split(";")
|
|
.slice(1)
|
|
.map((part) => part.trim())
|
|
.filter(Boolean);
|
|
const attributeValue = (name: string): string | undefined => {
|
|
const prefix = `${name.toLowerCase()}=`;
|
|
const attribute = attributes.find((part) => part.toLowerCase().startsWith(prefix));
|
|
return attribute?.slice(prefix.length);
|
|
};
|
|
const hasAttribute = (name: string): boolean =>
|
|
attributes.some((part) => part.toLowerCase() === name.toLowerCase());
|
|
return Object.freeze({
|
|
domain: attributeValue("domain"),
|
|
httpOnly: hasAttribute("httponly"),
|
|
path: attributeValue("path"),
|
|
sameSite: attributeValue("samesite"),
|
|
secure: hasAttribute("secure"),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Node deliberately has no ambient browser cookie store. This origin-locked test transport models
|
|
* the one host-only session cookie explicitly: it accepts Set-Cookie only from the configured API,
|
|
* sends only the cookie pair back to that same origin, clears it on an expiry response, and never
|
|
* exposes the stored value to assertions or error output. The observable cookie data contains only
|
|
* flags needed to prove the browser contract.
|
|
*/
|
|
function createOriginLockedBrowserCookieJar(apiBaseUrl: string): OriginLockedBrowserCookieJar {
|
|
const apiOrigin = new URL(apiBaseUrl).origin;
|
|
let cookiePair: string | undefined;
|
|
let signInObservation: BrowserAuthResponseObservation | undefined;
|
|
let signOutObservation: BrowserAuthResponseObservation | undefined;
|
|
|
|
const browserFetch: typeof fetch = async (input, init) => {
|
|
const request = new Request(input, init);
|
|
const requestUrl = new URL(request.url);
|
|
if (requestUrl.origin !== apiOrigin) {
|
|
throw harnessError("The browser integration transport refused a cross-origin request.");
|
|
}
|
|
|
|
// A real cross-origin browser supplies Origin itself and its cookie jar only when
|
|
// `credentials: include` is selected. Node's fetch has neither ambient behavior, so this
|
|
// tightly scoped harness supplies exactly those two transport facts.
|
|
request.headers.set("origin", dashboardOrigin);
|
|
request.headers.delete("cookie");
|
|
if (cookiePair !== undefined && request.credentials === "include") {
|
|
request.headers.set("cookie", cookiePair);
|
|
}
|
|
|
|
const response = await fetch(request);
|
|
const setCookie = setCookieValue(response);
|
|
if (setCookie !== undefined && request.credentials === "include") {
|
|
const nextCookiePair = firstCookiePair(setCookie);
|
|
const separator = nextCookiePair.indexOf("=");
|
|
const nextName = separator < 0 ? "" : nextCookiePair.slice(0, separator);
|
|
const nextValue = separator < 0 ? "" : nextCookiePair.slice(separator + 1);
|
|
const expiresCookie = /(?:^|;)\s*max-age=0(?:;|$)/iu.test(setCookie);
|
|
if (nextName !== "" && nextValue !== "" && !expiresCookie) {
|
|
cookiePair = nextCookiePair;
|
|
} else if (cookiePair?.startsWith(`${nextName}=`)) {
|
|
cookiePair = undefined;
|
|
}
|
|
}
|
|
|
|
const pathname = requestUrl.pathname;
|
|
if (
|
|
request.method === "POST" &&
|
|
(pathname === "/api/auth/sign-in/email" || pathname === "/api/auth/sign-out")
|
|
) {
|
|
const observation = Object.freeze({
|
|
accessControlAllowCredentials: response.headers.get("access-control-allow-credentials"),
|
|
accessControlAllowOrigin: response.headers.get("access-control-allow-origin"),
|
|
body: await response.clone().text(),
|
|
contentLength: response.headers.get("content-length"),
|
|
cookie: observeCookie(setCookie),
|
|
issuedSetCookie: setCookie !== undefined,
|
|
status: response.status,
|
|
});
|
|
if (pathname === "/api/auth/sign-in/email") {
|
|
signInObservation = observation;
|
|
} else {
|
|
signOutObservation = observation;
|
|
}
|
|
}
|
|
|
|
return response;
|
|
};
|
|
|
|
return Object.freeze({
|
|
fetch: browserFetch,
|
|
hasSessionCookie: () => cookiePair !== undefined,
|
|
signInResponse: () => {
|
|
if (signInObservation === undefined) {
|
|
throw harnessError("The browser integration did not observe a sign-in response.");
|
|
}
|
|
return signInObservation;
|
|
},
|
|
signOutResponse: () => {
|
|
if (signOutObservation === undefined) {
|
|
throw harnessError("The browser integration did not observe a sign-out response.");
|
|
}
|
|
return signOutObservation;
|
|
},
|
|
});
|
|
}
|
|
|
|
interface SignUpResult {
|
|
readonly response: Response;
|
|
readonly body: { token: string | null; user: { id: string; email: string } };
|
|
}
|
|
|
|
async function signUp(
|
|
origin: string,
|
|
email: string,
|
|
password: string,
|
|
headers: Record<string, string> = {},
|
|
): Promise<SignUpResult> {
|
|
const response = await fetch(`${origin}/api/auth/sign-up/email`, {
|
|
body: JSON.stringify({ email, name: "Integration Test User", password }),
|
|
// A trusted Origin by default, matching signIn below: Node's own fetch() (and every browser)
|
|
// automatically sends `Sec-Fetch-Mode: cors`, which Better Auth's first-login CSRF protection
|
|
// treats as "this is a Fetch-API call" and requires a trusted Origin/Referer for regardless of
|
|
// whether a cookie is present — see the dedicated no-Origin scenario below, which uses a raw
|
|
// `http.request` instead of `fetch()` to exercise a genuinely Fetch-Metadata-free caller.
|
|
headers: { "content-type": "application/json", origin: dashboardOrigin, ...headers },
|
|
method: "POST",
|
|
});
|
|
if (response.ok) {
|
|
// A verified address is a precondition for holding a session (ADR 0038). Sign-up itself always
|
|
// writes `emailVerified: false`, so these scenarios — none of which are about verification —
|
|
// complete that step out of band rather than standing up an SMTP server apiece.
|
|
await markEmailVerified(origin, email);
|
|
}
|
|
return { body: (await response.clone().json()) as SignUpResult["body"], response };
|
|
}
|
|
|
|
interface SignInResult {
|
|
readonly cookiePair: string | undefined;
|
|
readonly response: Response;
|
|
}
|
|
|
|
async function signIn(
|
|
origin: string,
|
|
email: string,
|
|
password: string,
|
|
headers: Record<string, string> = {},
|
|
): Promise<SignInResult> {
|
|
const response = await fetch(`${origin}/api/auth/sign-in/email`, {
|
|
body: JSON.stringify({ email, password }),
|
|
headers: {
|
|
"content-type": "application/json",
|
|
origin: dashboardOrigin,
|
|
...headers,
|
|
},
|
|
method: "POST",
|
|
});
|
|
const setCookie = setCookieValue(response);
|
|
return { cookiePair: setCookie ? firstCookiePair(setCookie) : undefined, response };
|
|
}
|
|
|
|
/**
|
|
* Signs up an account over real HTTP, grants it platform-owner authority through Core's own real
|
|
* bootstrap operation (exactly what `pnpm authorization:bootstrap-owner` performs), and signs it
|
|
* in — so a scenario needing an administrator exercises the genuine evaluator path rather than a
|
|
* stubbed decision.
|
|
*/
|
|
async function signUpPlatformOwner(
|
|
origin: string,
|
|
url: string,
|
|
): Promise<{ readonly cookie: string; readonly userId: string }> {
|
|
const email = `owner-${randomBytes(6).toString("hex")}@example.test`;
|
|
const password = "correct horse battery staple";
|
|
const created = await signUp(origin, email, password);
|
|
const { close, db } = createAuthorizationDrizzleDatabase(url);
|
|
try {
|
|
await bootstrapPlatformOwner(db, { userId: parseEkklesiaUserId(created.body.user.id) });
|
|
} finally {
|
|
await close();
|
|
}
|
|
const session = await signIn(origin, email, password);
|
|
if (!session.cookiePair) {
|
|
throw harnessError("The platform-owner session cookie was not issued.");
|
|
}
|
|
return { cookie: session.cookiePair, userId: created.body.user.id };
|
|
}
|
|
|
|
async function fetchPrincipal(
|
|
origin: string,
|
|
headers: Record<string, string> = {},
|
|
): Promise<{ body: unknown; response: Response }> {
|
|
const response = await fetch(`${origin}/api/v1/principal`, { headers });
|
|
return { body: await response.clone().json(), response };
|
|
}
|
|
|
|
/**
|
|
* A raw `node:http` POST, deliberately not `fetch()`. Node's own `fetch()` (undici) — like every
|
|
* browser — automatically sends `Sec-Fetch-Mode: cors` on every request, which Better Auth's
|
|
* first-login CSRF protection (`formCsrfMiddleware`) treats as "this came from a Fetch-API
|
|
* client" and requires a trusted Origin/Referer for regardless of cookie presence. A genuine
|
|
* non-browser, non-Fetch server-to-server integration (curl, most backend HTTP client libraries,
|
|
* or this raw socket) sends none of the Fetch Metadata headers, so it is unaffected by that
|
|
* specific protection and needs no Origin header at all.
|
|
*/
|
|
function rawPostRequest(
|
|
origin: string,
|
|
requestPath: string,
|
|
body: unknown,
|
|
): Promise<{ body: string; status: number }> {
|
|
return new Promise((resolve, reject) => {
|
|
const url = new URL(requestPath, origin);
|
|
const payload = JSON.stringify(body);
|
|
const req = httpRequest(
|
|
url,
|
|
{
|
|
headers: {
|
|
"content-length": Buffer.byteLength(payload),
|
|
"content-type": "application/json",
|
|
},
|
|
method: "POST",
|
|
},
|
|
(res) => {
|
|
const chunks: Buffer[] = [];
|
|
res.on("data", (chunk) => chunks.push(chunk as Buffer));
|
|
res.on("end", () => {
|
|
resolve({ body: Buffer.concat(chunks).toString("utf8"), status: res.statusCode ?? 0 });
|
|
});
|
|
},
|
|
);
|
|
req.on("error", reject);
|
|
req.end(payload);
|
|
});
|
|
}
|
|
|
|
interface RawSignInAttemptOptions {
|
|
/** Originates the outbound connection from this local address — real, distinct loopback
|
|
* addresses (127.0.0.2, 127.0.0.3, ...) are all independently routable on the loopback interface,
|
|
* so this proves genuinely different real socket peers, not merely different header claims. */
|
|
readonly localAddress?: string;
|
|
/** A raw, unvalidated `X-Forwarded-For` header value to send — deliberately not parsed or
|
|
* validated here, since these tests are proving how the *server* handles it. */
|
|
readonly forwardedFor?: string;
|
|
}
|
|
|
|
interface RawResponse {
|
|
readonly body: string;
|
|
readonly headers: Record<string, string | string[] | undefined>;
|
|
readonly status: number;
|
|
}
|
|
|
|
/**
|
|
* A raw `node:http` sign-in attempt against a deliberately wrong password. Better Auth's rate
|
|
* limiter keys and counts every `/sign-in*` request before ever validating credentials, so this
|
|
* never needs a real account and always resolves to either `401` (accepted by the limiter, wrong
|
|
* credentials) or `429` (rejected by the limiter) — the only two outcomes these tests distinguish.
|
|
* Raw `node:http`, not `fetch()`, for the same Fetch-Metadata reason `rawPostRequest` documents.
|
|
*/
|
|
function rawSignInAttempt(
|
|
origin: string,
|
|
options: RawSignInAttemptOptions = {},
|
|
): Promise<RawResponse> {
|
|
return new Promise((resolve, reject) => {
|
|
const url = new URL("/api/auth/sign-in/email", origin);
|
|
const payload = JSON.stringify({
|
|
email: "[email protected]",
|
|
password: "definitely-the-wrong-password",
|
|
});
|
|
const req = httpRequest(
|
|
url,
|
|
{
|
|
headers: {
|
|
"content-length": Buffer.byteLength(payload),
|
|
"content-type": "application/json",
|
|
...(options.forwardedFor === undefined
|
|
? {}
|
|
: { "x-forwarded-for": options.forwardedFor }),
|
|
},
|
|
...(options.localAddress === undefined ? {} : { localAddress: options.localAddress }),
|
|
method: "POST",
|
|
},
|
|
(res) => {
|
|
const chunks: Buffer[] = [];
|
|
res.on("data", (chunk) => chunks.push(chunk as Buffer));
|
|
res.on("end", () => {
|
|
resolve({
|
|
body: Buffer.concat(chunks).toString("utf8"),
|
|
headers: res.headers,
|
|
status: res.statusCode ?? 0,
|
|
});
|
|
});
|
|
},
|
|
);
|
|
req.on("error", reject);
|
|
req.end(payload);
|
|
});
|
|
}
|
|
|
|
describe.sequential("live API — signup, sign-in, principal, sign-out", () => {
|
|
let database: { name: string; url: string };
|
|
let running: RunningApi;
|
|
let origin: string;
|
|
const emailOne = "[email protected]";
|
|
const emailTwo = "[email protected]";
|
|
const password = "correct horse battery staple";
|
|
|
|
it("applies migrations and starts the API against a freshly migrated database", async () => {
|
|
database = await createDisposableDatabase("signup_flow");
|
|
await applyCoreMigrations(database.url);
|
|
running = await startMigratedApi(database.url);
|
|
origin = apiOrigin(running);
|
|
|
|
expect(running.server.listening).toBe(true);
|
|
});
|
|
|
|
it("returns the anonymous principal shape before any sign-in", async () => {
|
|
const { body, response } = await fetchPrincipal(origin);
|
|
expect(response.status).toBe(200);
|
|
expect(body).toEqual({ kind: "anonymous" });
|
|
});
|
|
|
|
it("publicly creates the first ordinary identity via sign-up, with no automatic session (autoSignIn: false)", async () => {
|
|
const { body, response } = await signUp(origin, emailOne, password);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(body.token).toBeNull();
|
|
expect(body.user.email).toBe(emailOne);
|
|
expect(setCookieValue(response)).toBeUndefined();
|
|
});
|
|
|
|
it("does not sign in automatically after sign-up", async () => {
|
|
const { body } = await fetchPrincipal(origin);
|
|
expect(body).toEqual({ kind: "anonymous" });
|
|
});
|
|
|
|
it("rejects wrong credentials without leaking raw internal information", async () => {
|
|
const { response } = await signIn(origin, emailOne, "wrong password entirely");
|
|
const text = await response.text();
|
|
|
|
expect(response.status).toBe(401);
|
|
expect(text).not.toContain(database.url);
|
|
expect(text).not.toContain("postgres");
|
|
expect(text.toLowerCase()).not.toContain("stack");
|
|
});
|
|
|
|
let sessionCookie: string;
|
|
|
|
it("signs in explicitly and returns a valid, host-only, HttpOnly, SameSite=Lax, secure session cookie", async () => {
|
|
const { cookiePair, response } = await signIn(origin, emailOne, password);
|
|
const setCookie = setCookieValue(response);
|
|
|
|
const browserVisibleBody = await response.clone().text();
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(JSON.parse(browserVisibleBody)).toEqual({ ok: true });
|
|
expect(browserVisibleBody).not.toContain("token");
|
|
expect(browserVisibleBody).not.toContain("user");
|
|
expect(browserVisibleBody).not.toContain("session");
|
|
expect(browserVisibleBody).not.toContain(emailOne);
|
|
expect(setCookie).toBeDefined();
|
|
expect(cookiePair).toBeDefined();
|
|
sessionCookie = cookiePair as string;
|
|
|
|
const cookieText = setCookie as string;
|
|
expect(cookieText).toContain("Path=/");
|
|
expect(cookieText).toContain("HttpOnly");
|
|
expect(cookieText).toContain("SameSite=Lax");
|
|
expect(cookieText).toContain("Secure");
|
|
expect(cookieText).not.toMatch(/domain=/iu);
|
|
});
|
|
|
|
it("resolves the authenticated principal containing only kind and the opaque user id", async () => {
|
|
const { body, response } = await fetchPrincipal(origin, { cookie: sessionCookie });
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(body).toMatchObject({ kind: "authenticated" });
|
|
const record = body as Record<string, unknown>;
|
|
expect(Object.keys(record).sort()).toEqual(["kind", "userId"]);
|
|
expect(typeof record.userId).toBe("string");
|
|
});
|
|
|
|
it("resolves a malformed cookie as anonymous rather than failing", async () => {
|
|
const { body, response } = await fetchPrincipal(origin, {
|
|
cookie: "better-auth.session_token=not-a-real-token",
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(body).toEqual({ kind: "anonymous" });
|
|
});
|
|
|
|
it("resolves a nonexistent cookie name as anonymous", async () => {
|
|
const { body, response } = await fetchPrincipal(origin, { cookie: "unrelated=value" });
|
|
expect(response.status).toBe(200);
|
|
expect(body).toEqual({ kind: "anonymous" });
|
|
});
|
|
|
|
it("does not create a second identity on duplicate sign-up, and does not leak unstable internal details", async () => {
|
|
const { body, response } = await signUp(origin, emailOne, "a totally different password");
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(body.token).toBeNull();
|
|
expect(typeof body.user.id).toBe("string");
|
|
});
|
|
|
|
it("keeps exactly one user row for the duplicated email after the duplicate sign-up attempt", async () => {
|
|
const admin = await openAdministrativeClient(database.url);
|
|
try {
|
|
const rows = await safeQuery<{ count: string }>(
|
|
admin,
|
|
'select count(*)::text as count from "ekklesia_core_user" where email = $1',
|
|
[emailOne],
|
|
);
|
|
expect(rows[0]?.count).toBe("1");
|
|
} finally {
|
|
await safeEnd(admin);
|
|
}
|
|
});
|
|
|
|
it("signs out and invalidates the session", async () => {
|
|
const response = await fetch(`${origin}/api/auth/sign-out`, {
|
|
body: "{}",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
cookie: sessionCookie,
|
|
origin: dashboardOrigin,
|
|
},
|
|
method: "POST",
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(setCookieValue(response)).toBeDefined();
|
|
});
|
|
|
|
it("resolves the old cookie as anonymous after sign-out", async () => {
|
|
const { body, response } = await fetchPrincipal(origin, { cookie: sessionCookie });
|
|
expect(response.status).toBe(200);
|
|
expect(body).toEqual({ kind: "anonymous" });
|
|
});
|
|
|
|
let secondUserId: string;
|
|
|
|
it("registers and signs in a second, distinct public account", async () => {
|
|
const signUpResult = await signUp(origin, emailTwo, password);
|
|
expect(signUpResult.response.status).toBe(200);
|
|
|
|
const signInResult = await signIn(origin, emailTwo, password);
|
|
expect(signInResult.response.status).toBe(200);
|
|
expect(signInResult.cookiePair).toBeDefined();
|
|
|
|
const { body } = await fetchPrincipal(origin, {
|
|
cookie: signInResult.cookiePair as string,
|
|
});
|
|
const record = body as { kind: string; userId: string };
|
|
expect(record.kind).toBe("authenticated");
|
|
secondUserId = record.userId;
|
|
expect(secondUserId).toBeTruthy();
|
|
});
|
|
|
|
it("gives the first and second account identical principal shapes with no elevation", async () => {
|
|
const first = await signIn(origin, emailOne, password);
|
|
const { body: firstBody } = await fetchPrincipal(origin, {
|
|
cookie: first.cookiePair as string,
|
|
});
|
|
const second = await signIn(origin, emailTwo, password);
|
|
const { body: secondBody } = await fetchPrincipal(origin, {
|
|
cookie: second.cookiePair as string,
|
|
});
|
|
|
|
expect(Object.keys(firstBody as object).sort()).toEqual(
|
|
Object.keys(secondBody as object).sort(),
|
|
);
|
|
expect((firstBody as { kind: string }).kind).toBe((secondBody as { kind: string }).kind);
|
|
});
|
|
|
|
it("gives neither account owner, role, permission, membership, or volunteer data at the database level", async () => {
|
|
const admin = await openAdministrativeClient(database.url);
|
|
try {
|
|
const columns = await safeQuery<{ column_name: string }>(
|
|
admin,
|
|
"select column_name from information_schema.columns where table_schema = 'public' and table_name = 'ekklesia_core_user'",
|
|
);
|
|
const columnNames = columns.map((row) => row.column_name);
|
|
for (const forbidden of [
|
|
"role",
|
|
"is_admin",
|
|
"is_owner",
|
|
"is_staff",
|
|
"is_volunteer",
|
|
"is_member",
|
|
"permissions",
|
|
"grants",
|
|
]) {
|
|
expect(columnNames).not.toContain(forbidden);
|
|
}
|
|
} finally {
|
|
await safeEnd(admin);
|
|
}
|
|
});
|
|
|
|
it("never stores a plaintext password", async () => {
|
|
const admin = await openAdministrativeClient(database.url);
|
|
try {
|
|
const rows = await safeQuery<{ password: string | null }>(
|
|
admin,
|
|
`select a.password from "ekklesia_core_account" a
|
|
inner join "ekklesia_core_user" u on u.id = a.user_id
|
|
where u.email = $1`,
|
|
[emailOne],
|
|
);
|
|
const stored = rows[0]?.password;
|
|
expect(stored).toBeTruthy();
|
|
expect(stored).not.toBe(password);
|
|
expect(stored?.length ?? 0).toBeGreaterThan(password.length);
|
|
} finally {
|
|
await safeEnd(admin);
|
|
}
|
|
});
|
|
|
|
it("supports a genuine server-to-server sign-up request that carries no Origin header and no Fetch Metadata headers at all", async () => {
|
|
const { status } = await rawPostRequest(origin, "/api/auth/sign-up/email", {
|
|
email: "[email protected]",
|
|
name: "Integration Test User",
|
|
password,
|
|
});
|
|
expect(status).toBe(200);
|
|
});
|
|
|
|
it("allows a trusted browser origin for the principal route with credentials", async () => {
|
|
const response = await fetch(`${origin}/api/v1/principal`, {
|
|
headers: { cookie: sessionCookie, origin: dashboardOrigin },
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.headers.get("access-control-allow-origin")).toBe(dashboardOrigin);
|
|
expect(response.headers.get("access-control-allow-credentials")).toBe("true");
|
|
});
|
|
|
|
it("never returns Access-Control-Allow-Origin for an untrusted browser origin", async () => {
|
|
const principalResponse = await fetch(`${origin}/api/v1/principal`, {
|
|
headers: { origin: untrustedOrigin },
|
|
});
|
|
expect(principalResponse.headers.get("access-control-allow-origin")).toBeNull();
|
|
});
|
|
|
|
// Production-equivalent Better Auth origin/CSRF protection (ADR 0012). Explicitly setting
|
|
// `advanced: { disableCSRFCheck: false, disableOriginCheck: false }` (`core/src/identity/runtime.ts`)
|
|
// removes Better Auth's own `isTest()`-driven default that otherwise silently skips origin/CSRF
|
|
// validation under a test runner, so the same behavior verified below is also what production
|
|
// enforces.
|
|
|
|
it("accepts a no-cookie first authentication attempt from a trusted browser origin", async () => {
|
|
const response = await fetch(`${origin}/api/auth/sign-in/email`, {
|
|
body: JSON.stringify({ email: emailOne, password }),
|
|
headers: { "content-type": "application/json", origin: dashboardOrigin },
|
|
method: "POST",
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.headers.get("access-control-allow-origin")).toBe(dashboardOrigin);
|
|
});
|
|
|
|
it("rejects a no-cookie first authentication attempt from an untrusted browser origin", async () => {
|
|
const response = await fetch(`${origin}/api/auth/sign-in/email`, {
|
|
body: JSON.stringify({ email: emailOne, password }),
|
|
headers: { "content-type": "application/json", origin: untrustedOrigin },
|
|
method: "POST",
|
|
});
|
|
const text = await response.text();
|
|
|
|
expect(response.status).toBe(403);
|
|
expect(response.headers.get("access-control-allow-origin")).toBeNull();
|
|
expect(text).not.toContain(database.url);
|
|
expect(text.toLowerCase()).not.toContain("stack");
|
|
});
|
|
|
|
it("rejects a cookie-bearing state-changing request from an untrusted browser origin, and does not invalidate the session", async () => {
|
|
const { cookiePair } = await signIn(origin, emailOne, password);
|
|
expect(cookiePair).toBeDefined();
|
|
|
|
const response = await fetch(`${origin}/api/auth/sign-out`, {
|
|
body: "{}",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
cookie: cookiePair as string,
|
|
origin: untrustedOrigin,
|
|
},
|
|
method: "POST",
|
|
});
|
|
const text = await response.text();
|
|
|
|
expect(response.status).toBe(403);
|
|
expect(response.headers.get("access-control-allow-origin")).toBeNull();
|
|
expect(text).not.toContain(database.url);
|
|
expect(text.toLowerCase()).not.toContain("stack");
|
|
|
|
// The forged/rejected sign-out must never have reached sign-out logic: the session presented
|
|
// alongside it must still be valid.
|
|
const { body } = await fetchPrincipal(origin, { cookie: cookiePair as string });
|
|
expect(body).toMatchObject({ kind: "authenticated" });
|
|
});
|
|
|
|
it("rejects a 7-character password as too short", async () => {
|
|
const { response } = await signUp(origin, "[email protected]", "1234567");
|
|
expect(response.status).toBe(400);
|
|
});
|
|
|
|
it("accepts an 8-character password", async () => {
|
|
const { response, body } = await signUp(origin, "[email protected]", "12345678");
|
|
expect(response.status).toBe(200);
|
|
expect(body.user.email).toBe("[email protected]");
|
|
});
|
|
|
|
it("accepts a 128-character password", async () => {
|
|
const { response } = await signUp(origin, "[email protected]", "a".repeat(128));
|
|
expect(response.status).toBe(200);
|
|
});
|
|
|
|
it("rejects a 129-character password as too long", async () => {
|
|
const { response } = await signUp(origin, "[email protected]", "a".repeat(129));
|
|
expect(response.status).toBe(400);
|
|
});
|
|
|
|
it("leaves Better Auth's handler unavailable outside /api/auth/*", async () => {
|
|
const response = await fetch(`${origin}/sign-in/email`, { method: "POST" });
|
|
expect(response.status).toBe(404);
|
|
});
|
|
|
|
it("shuts down cleanly, closing the HTTP listener and the PostgreSQL pool", async () => {
|
|
await running.shutdown();
|
|
|
|
await expect(fetch(origin, { signal: AbortSignal.timeout(1_000) })).rejects.toBeTruthy();
|
|
const remainingConnections = await activeConnectionCount(database.name);
|
|
expect(remainingConnections).toBe(0);
|
|
|
|
await dropDatabase(database.name);
|
|
});
|
|
});
|
|
|
|
describe.sequential("live API — exported browser auth shell against PostgreSQL", () => {
|
|
it("signs a real platform owner in, bootstraps protected Dashboard navigation, and signs out", async () => {
|
|
const modulesDirectory = await createFixtureModulesDirectory();
|
|
await createUiModule(
|
|
modulesDirectory,
|
|
"browser-shell",
|
|
"1.0.0",
|
|
'export default { id: "browser-shell", version: "1.0.0", mount() {} };\n',
|
|
);
|
|
const database = await createDisposableDatabase("browser_auth");
|
|
await applyCoreMigrations(database.url);
|
|
const running = await startMigratedApi(database.url, modulesDirectory);
|
|
|
|
try {
|
|
const origin = apiOrigin(running);
|
|
const email = `browser-owner-${randomBytes(6).toString("hex")}@example.test`;
|
|
const password = "correct horse battery staple";
|
|
const created = await signUp(origin, email, password);
|
|
expect(created.response.status).toBe(200);
|
|
|
|
const authorizationDatabase = createAuthorizationDrizzleDatabase(database.url);
|
|
try {
|
|
await bootstrapPlatformOwner(authorizationDatabase.db, {
|
|
userId: parseEkklesiaUserId(created.body.user.id),
|
|
});
|
|
} finally {
|
|
await authorizationDatabase.close();
|
|
}
|
|
|
|
const cookieJar = createOriginLockedBrowserCookieJar(origin);
|
|
const hostConfig: EkklesiaHostConfig = Object.freeze({
|
|
apiBaseUrl: origin,
|
|
fetch: cookieJar.fetch,
|
|
});
|
|
|
|
// This is the public framework-free helper the browser shell itself calls — no test-side
|
|
// reimplementation of its URL, payload, credentials mode, or response handling.
|
|
await signInWithEmail(hostConfig, { email, password });
|
|
|
|
const signIn = cookieJar.signInResponse();
|
|
expect(signIn.status).toBe(200);
|
|
expect(JSON.parse(signIn.body)).toEqual({ ok: true });
|
|
expect(
|
|
signIn.contentLength === null ||
|
|
signIn.contentLength === String(Buffer.byteLength(signIn.body)),
|
|
).toBe(true);
|
|
for (const forbidden of ["token", "user", "session", email]) {
|
|
expect(signIn.body).not.toContain(forbidden);
|
|
}
|
|
expect(signIn.issuedSetCookie).toBe(true);
|
|
expect(cookieJar.hasSessionCookie()).toBe(true);
|
|
expect(signIn.cookie).toEqual({
|
|
domain: undefined,
|
|
httpOnly: true,
|
|
path: "/",
|
|
sameSite: "Lax",
|
|
secure: true,
|
|
});
|
|
expect(signIn.accessControlAllowOrigin).toBe(dashboardOrigin);
|
|
expect(signIn.accessControlAllowCredentials).toBe("true");
|
|
|
|
// The pinned Better Auth build would return the raw session token from the first three
|
|
// resources, and from change-password when `revokeOtherSessions` is true. Ekklesia has no
|
|
// native session/password-management UI, so the outer API boundary must answer before Better
|
|
// Auth can read these authenticated requests. The valid change-password payload makes this a
|
|
// meaningful leak regression: if the denylist disappeared it could mint and expose a new
|
|
// token, not merely fail validation before reaching the vulnerable response.
|
|
const deniedNativeEndpoints = [
|
|
{ method: "GET", path: "/api/auth/get-session?source=browser-shell" },
|
|
{ method: "GET", path: "/api/auth/%67et-session" },
|
|
{ method: "GET", path: "/api/auth/get-session%2F" },
|
|
{ body: {}, method: "POST", path: "/api/auth/get-session" },
|
|
{ method: "GET", path: "/api/auth/list-sessions" },
|
|
{ body: { arbitrary: "value" }, method: "POST", path: "/api/auth/update-session" },
|
|
{
|
|
body: {
|
|
currentPassword: password,
|
|
newPassword: "replacement horse battery staple",
|
|
revokeOtherSessions: true,
|
|
},
|
|
method: "POST",
|
|
path: "/api/auth/change-password",
|
|
},
|
|
] as const;
|
|
for (const endpoint of deniedNativeEndpoints) {
|
|
const response = await cookieJar.fetch(`${origin}${endpoint.path}`, {
|
|
credentials: "include",
|
|
method: endpoint.method,
|
|
...("body" in endpoint
|
|
? {
|
|
body: JSON.stringify(endpoint.body),
|
|
headers: { "content-type": "application/json" },
|
|
}
|
|
: {}),
|
|
});
|
|
const visibleBody = await response.text();
|
|
|
|
expect(response.status).toBe(404);
|
|
expect(JSON.parse(visibleBody)).toEqual({ code: "NOT_FOUND" });
|
|
for (const forbidden of ["token", "session", email, password]) {
|
|
expect(visibleBody).not.toContain(forbidden);
|
|
}
|
|
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(dashboardOrigin);
|
|
expect(response.headers.get("access-control-allow-credentials")).toBe("true");
|
|
}
|
|
|
|
// `bootstrapHost` resolves the principal first, then calls the protected Dashboard endpoint.
|
|
// A real module in the result proves the latter request was authenticated and authorized.
|
|
const authenticated = await bootstrapHost(hostConfig);
|
|
expect(authenticated.principal).toEqual({
|
|
kind: "authenticated",
|
|
userId: created.body.user.id,
|
|
});
|
|
expect(authenticated.navigation.groups).toEqual([]);
|
|
expect(authenticated.navigation.ungrouped.map((entry) => entry.descriptor.id)).toEqual([
|
|
"browser-shell",
|
|
]);
|
|
|
|
await signOutHost(hostConfig);
|
|
|
|
const signOut = cookieJar.signOutResponse();
|
|
expect(signOut.status).toBe(200);
|
|
expect(JSON.parse(signOut.body)).toEqual({ success: true });
|
|
expect(signOut.issuedSetCookie).toBe(true);
|
|
expect(signOut.accessControlAllowOrigin).toBe(dashboardOrigin);
|
|
expect(signOut.accessControlAllowCredentials).toBe("true");
|
|
expect(cookieJar.hasSessionCookie()).toBe(false);
|
|
|
|
// Reload through the same exported principal-first shell bootstrap. The expired cookie is no
|
|
// longer sent, protected navigation is not probed, and the host returns its frozen empty view.
|
|
const reloaded = await bootstrapHost(hostConfig);
|
|
expect(reloaded.principal).toEqual({ kind: "anonymous" });
|
|
expect(reloaded.navigation).toEqual({ groups: [], ungrouped: [] });
|
|
} finally {
|
|
await running.shutdown().catch(() => undefined);
|
|
await dropDatabase(database.name);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe.sequential("live API — startup and migration guarantees", () => {
|
|
it("refuses to start against an unmigrated database, and creates no tables or migration history", async () => {
|
|
const database = await createDisposableDatabase("unmigrated");
|
|
try {
|
|
const config = createApiRuntimeConfig(
|
|
{
|
|
databaseUrl: database.url,
|
|
host: "127.0.0.1",
|
|
identity: {
|
|
baseUrl: identityBaseUrl,
|
|
publicSignupEnabled: true,
|
|
secret: "integration-test-secret-0123456789abcdefgh",
|
|
trustedOrigins: [identityBaseUrl],
|
|
},
|
|
port: 0,
|
|
},
|
|
// Development validation, for the same reason `startConfiguredApi` documents: this scenario
|
|
// uses the disposable plaintext loopback database, which production configuration refuses.
|
|
{ environment: "development" },
|
|
);
|
|
|
|
// Startup reads the stored module enablement configuration and then the stored module
|
|
// configuration, both before importing any module entrypoint (ADR 0028, ADR 0029). Against an
|
|
// unmigrated database neither read can succeed, and the platform fails closed rather than
|
|
// starting every discovered module: it cannot know what an administrator decided, and starting
|
|
// a module they disabled — or one whose required configuration is absent — is worse than not
|
|
// starting. The two reads are sequential rather than raced precisely so this error code is
|
|
// deterministic; the failure is Core's own sanitized store error, with no connection string,
|
|
// host, SQL, driver message, or stack.
|
|
let captured: unknown;
|
|
try {
|
|
await startApi(config, { rateLimitOverride: { max: 1000, window: 1 } });
|
|
} catch (error) {
|
|
captured = error;
|
|
}
|
|
expect(captured).toMatchObject({
|
|
code: "MODULE_LIFECYCLE_READ_FAILED",
|
|
name: "ModuleLifecycleStoreError",
|
|
});
|
|
const rendered = `${String(captured)} ${JSON.stringify(captured)}`;
|
|
expect(rendered).not.toContain(database.name);
|
|
expect(rendered).not.toContain("127.0.0.1");
|
|
expect(rendered).not.toContain("select");
|
|
|
|
const admin = await openAdministrativeClient(database.url);
|
|
try {
|
|
// The invariant this scenario has always existed to protect, unchanged and now proven on a
|
|
// path that never even reached Better Auth: a failed start repairs nothing, creates
|
|
// nothing, and applies no migration.
|
|
const rows = await safeQuery<{ count: string }>(
|
|
admin,
|
|
"select count(*)::text as count from information_schema.tables where table_schema = 'public'",
|
|
);
|
|
expect(rows[0]?.count).toBe("0");
|
|
} finally {
|
|
await safeEnd(admin);
|
|
}
|
|
} finally {
|
|
await dropDatabase(database.name);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe.sequential("live API — principal infrastructure failure is never downgraded to anonymous", () => {
|
|
it("returns a sanitized failure, not a false anonymous 200, when the database becomes unreachable mid-session", async () => {
|
|
const database = await createDisposableDatabase("infra_failure");
|
|
await applyCoreMigrations(database.url);
|
|
const running = await startMigratedApi(database.url);
|
|
const origin = apiOrigin(running);
|
|
|
|
await signUp(origin, "[email protected]", "correct horse battery staple");
|
|
const { cookiePair } = await signIn(
|
|
origin,
|
|
"[email protected]",
|
|
"correct horse battery staple",
|
|
);
|
|
expect(cookiePair).toBeDefined();
|
|
|
|
// Force the database itself unreachable out from under the running API pool.
|
|
await dropDatabase(database.name);
|
|
|
|
const { body, response } = await fetchPrincipal(origin, { cookie: cookiePair as string });
|
|
expect(response.status).toBe(503);
|
|
expect(body).toEqual({ code: "PRINCIPAL_RESOLUTION_FAILED" });
|
|
|
|
await running.shutdown().catch(() => undefined);
|
|
});
|
|
});
|
|
|
|
describe.sequential("live API — session-store readiness against real PostgreSQL", () => {
|
|
/**
|
|
* Every scenario below drives a real, migrated PostgreSQL database, breaks exactly one property
|
|
* of the *session store specifically*, and asserts the public contract. The administrative
|
|
* connection each helper opens (`openAdministrativeClient`) is **test-only provisioning
|
|
* infrastructure** — it renames a relation, drops a column, or revokes a privilege in a
|
|
* disposable database, and is closed in `finally`. It is never part of the API runtime, which
|
|
* continues to own exactly one pool through `startApi`.
|
|
*/
|
|
async function withBrokenSessionStore(
|
|
label: string,
|
|
breakStore: (admin: Client) => Promise<void>,
|
|
assertions: (context: {
|
|
readonly cookie: string;
|
|
readonly origin: string;
|
|
readonly logs: string[];
|
|
}) => Promise<void>,
|
|
): Promise<void> {
|
|
const database = await createDisposableDatabase(label);
|
|
await applyCoreMigrations(database.url);
|
|
const running = await startConfiguredApi(database.url);
|
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
try {
|
|
const origin = apiOrigin(running);
|
|
const email = `${label}@example.test`;
|
|
const password = "correct horse battery staple";
|
|
await signUp(origin, email, password);
|
|
const { cookiePair } = await signIn(origin, email, password);
|
|
expect(cookiePair).toBeDefined();
|
|
|
|
// The session resolves normally before anything is broken.
|
|
const before = await fetchPrincipal(origin, { cookie: cookiePair as string });
|
|
expect(before.response.status).toBe(200);
|
|
expect(before.body).toMatchObject({ kind: "authenticated" });
|
|
|
|
const admin = await openAdministrativeClient(database.url);
|
|
try {
|
|
await breakStore(admin);
|
|
} finally {
|
|
await safeEnd(admin);
|
|
}
|
|
|
|
await assertions({
|
|
cookie: cookiePair as string,
|
|
logs: errorSpy.mock.calls.flat().map((value) => String(value)),
|
|
origin,
|
|
});
|
|
} finally {
|
|
errorSpy.mockRestore();
|
|
await running.shutdown().catch(() => undefined);
|
|
await dropDatabase(database.name);
|
|
}
|
|
}
|
|
|
|
/** The exact public contract a broken session store must produce — never anonymous, never 403,
|
|
* and never carrying a relation name, column name, privilege detail, SQL, host, or stack. */
|
|
async function expectSanitizedUnavailable(origin: string, cookie: string): Promise<Response> {
|
|
const response = await fetch(`${origin}/api/v1/principal`, { headers: { cookie } });
|
|
expect(response.status).toBe(503);
|
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
|
const text = await response.clone().text();
|
|
expect(JSON.parse(text)).toEqual({ code: "PRINCIPAL_RESOLUTION_FAILED" });
|
|
for (const forbidden of [
|
|
"ekklesia_core_session",
|
|
"permission denied",
|
|
"does not exist",
|
|
"relation",
|
|
"column",
|
|
"select",
|
|
"postgres",
|
|
"5432",
|
|
]) {
|
|
expect(text.toLowerCase()).not.toContain(forbidden.toLowerCase());
|
|
}
|
|
return response;
|
|
}
|
|
|
|
it("returns a sanitized 503 — not anonymous — when the session table is missing, even though a generic SELECT 1 still succeeds", async () => {
|
|
await withBrokenSessionStore(
|
|
"session_missing_table",
|
|
async (admin) => {
|
|
// Prove the distinction this pass exists for: the connection is perfectly healthy for a
|
|
// trivial statement, so the previous `select 1` probe would have reported "reachable" and
|
|
// the caller would have been reported as genuinely anonymous.
|
|
const trivial = await safeQuery<{ one: number }>(admin, "SELECT 1 AS one");
|
|
expect(trivial[0]?.one).toBe(1);
|
|
await safeQuery(
|
|
admin,
|
|
'ALTER TABLE "ekklesia_core_session" RENAME TO "ekklesia_core_session_moved"',
|
|
);
|
|
},
|
|
async ({ cookie, origin }) => {
|
|
await expectSanitizedUnavailable(origin, cookie);
|
|
},
|
|
);
|
|
});
|
|
|
|
it("returns a sanitized 503 when an expected session column is missing", async () => {
|
|
await withBrokenSessionStore(
|
|
"session_missing_column",
|
|
async (admin) => {
|
|
await safeQuery(admin, 'ALTER TABLE "ekklesia_core_session" DROP COLUMN "token" CASCADE');
|
|
},
|
|
async ({ cookie, origin }) => {
|
|
await expectSanitizedUnavailable(origin, cookie);
|
|
},
|
|
);
|
|
});
|
|
|
|
it("returns a sanitized 503 when SELECT privilege on the session table is genuinely revoked", async () => {
|
|
// A real privilege denial needs the API to connect as a role that is *not* the table owner —
|
|
// an owner's implicit rights cannot be revoked. This provisions a dedicated restricted role
|
|
// (test-only infrastructure), grants it everything except SELECT on the session table, and
|
|
// runs the API entirely as that role.
|
|
const database = await createDisposableDatabase("session_denied");
|
|
await applyCoreMigrations(database.url);
|
|
const restrictedRole = `ekk_restricted_${randomBytes(4).toString("hex")}`;
|
|
const restrictedPassword = randomBytes(12).toString("hex");
|
|
|
|
const admin = await openAdministrativeClient(database.url);
|
|
try {
|
|
await safeQuery(
|
|
admin,
|
|
`CREATE ROLE "${restrictedRole}" LOGIN PASSWORD '${restrictedPassword}'`,
|
|
);
|
|
await safeQuery(admin, `GRANT USAGE ON SCHEMA public TO "${restrictedRole}"`);
|
|
await safeQuery(
|
|
admin,
|
|
`GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO "${restrictedRole}"`,
|
|
);
|
|
// The one thing this role may not do: read the session relation.
|
|
await safeQuery(admin, `REVOKE ALL ON "ekklesia_core_session" FROM "${restrictedRole}"`);
|
|
} finally {
|
|
await safeEnd(admin);
|
|
}
|
|
|
|
const restrictedUrl = (() => {
|
|
const parsed = new URL(database.url);
|
|
parsed.username = restrictedRole;
|
|
parsed.password = restrictedPassword;
|
|
return parsed.toString();
|
|
})();
|
|
|
|
const running = await startConfiguredApi(restrictedUrl);
|
|
try {
|
|
const origin = apiOrigin(running);
|
|
// The restricted role can still run a trivial statement and can still read non-session
|
|
// tables, so only the session store specifically is broken.
|
|
const response = await fetch(`${origin}/api/v1/principal`, {
|
|
headers: { cookie: "__Secure-better-auth.session_token=any-token-shape" },
|
|
});
|
|
const text = await response.clone().text();
|
|
|
|
expect(response.status).toBe(503);
|
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
|
expect(JSON.parse(text)).toEqual({ code: "PRINCIPAL_RESOLUTION_FAILED" });
|
|
expect(text).not.toContain("ekklesia_core_session");
|
|
expect(text.toLowerCase()).not.toContain("permission denied");
|
|
expect(text).not.toContain(restrictedRole);
|
|
} finally {
|
|
await running.shutdown().catch(() => undefined);
|
|
// Drop the disposable database first (it holds the role's dependencies), then the role.
|
|
await dropDatabase(database.name).catch(() => undefined);
|
|
const cleanup = await openAdministrativeClient(baseDatabaseUrl());
|
|
try {
|
|
await safeQuery(cleanup, `DROP ROLE IF EXISTS "${restrictedRole}"`);
|
|
} finally {
|
|
await safeEnd(cleanup);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("keeps a genuinely unknown, expired, or revoked session anonymous while the store is healthy", async () => {
|
|
const database = await createDisposableDatabase("session_healthy");
|
|
await applyCoreMigrations(database.url);
|
|
const running = await startConfiguredApi(database.url);
|
|
try {
|
|
const origin = apiOrigin(running);
|
|
const email = "[email protected]";
|
|
const password = "correct horse battery staple";
|
|
await signUp(origin, email, password);
|
|
const { cookiePair } = await signIn(origin, email, password);
|
|
expect(cookiePair).toBeDefined();
|
|
const cookieName = (cookiePair as string).split("=")[0] as string;
|
|
|
|
// A completely unknown token against a healthy store is genuinely anonymous.
|
|
const unknown = await fetchPrincipal(origin, { cookie: `${cookieName}=not-a-real-token` });
|
|
expect(unknown.response.status).toBe(200);
|
|
expect(unknown.body).toEqual({ kind: "anonymous" });
|
|
|
|
// A revoked (signed-out) session is likewise genuinely anonymous, not a 503.
|
|
const signOut = await fetch(`${origin}/api/auth/sign-out`, {
|
|
body: "{}",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
cookie: cookiePair as string,
|
|
origin: dashboardOrigin,
|
|
},
|
|
method: "POST",
|
|
});
|
|
expect(signOut.status).toBe(200);
|
|
const revoked = await fetchPrincipal(origin, { cookie: cookiePair as string });
|
|
expect(revoked.response.status).toBe(200);
|
|
expect(revoked.body).toEqual({ kind: "anonymous" });
|
|
|
|
// An expired session (expiry forced into the past) is anonymous, not a 503.
|
|
const admin = await openAdministrativeClient(database.url);
|
|
try {
|
|
await safeQuery(
|
|
admin,
|
|
`UPDATE "ekklesia_core_session" SET expires_at = now() - interval '1 day'`,
|
|
);
|
|
} finally {
|
|
await safeEnd(admin);
|
|
}
|
|
const secondSignIn = await signIn(origin, email, password);
|
|
const expiredCookie = secondSignIn.cookiePair as string;
|
|
const adminAgain = await openAdministrativeClient(database.url);
|
|
try {
|
|
await safeQuery(
|
|
adminAgain,
|
|
`UPDATE "ekklesia_core_session" SET expires_at = now() - interval '1 day'`,
|
|
);
|
|
} finally {
|
|
await safeEnd(adminAgain);
|
|
}
|
|
const expired = await fetchPrincipal(origin, { cookie: expiredCookie });
|
|
expect(expired.response.status).toBe(200);
|
|
expect(expired.body).toEqual({ kind: "anonymous" });
|
|
} finally {
|
|
await running.shutdown().catch(() => undefined);
|
|
await dropDatabase(database.name);
|
|
}
|
|
});
|
|
|
|
it("performs no identity database work for an unrelated cookie, proven against a database whose session table is missing", async () => {
|
|
const database = await createDisposableDatabase("session_unrelated_cookie");
|
|
await applyCoreMigrations(database.url);
|
|
const running = await startConfiguredApi(database.url);
|
|
try {
|
|
const origin = apiOrigin(running);
|
|
// Break the session store outright. Any request that actually touched it would now 503.
|
|
const admin = await openAdministrativeClient(database.url);
|
|
try {
|
|
await safeQuery(
|
|
admin,
|
|
'ALTER TABLE "ekklesia_core_session" RENAME TO "ekklesia_core_session_moved"',
|
|
);
|
|
} finally {
|
|
await safeEnd(admin);
|
|
}
|
|
|
|
// Unrelated cookies must still resolve as anonymous with a 200: they never triggered
|
|
// session-store work in the first place. This is the original reproduction, inverted.
|
|
for (const cookie of ["x=y", "a=1; b=2; c=3", "better_auth_session_token=v"]) {
|
|
const { body, response } = await fetchPrincipal(origin, { cookie });
|
|
expect(response.status).toBe(200);
|
|
expect(body).toEqual({ kind: "anonymous" });
|
|
}
|
|
|
|
// No cookie at all behaves identically.
|
|
const none = await fetchPrincipal(origin);
|
|
expect(none.response.status).toBe(200);
|
|
expect(none.body).toEqual({ kind: "anonymous" });
|
|
} finally {
|
|
await running.shutdown().catch(() => undefined);
|
|
await dropDatabase(database.name);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe.sequential("live API — Better Auth logging never leaks operational detail", () => {
|
|
it("emits no raw exception, SQL, connection detail, origin header, or stack trace when a real identity database failure occurs", async () => {
|
|
const database = await createDisposableDatabase("logging_leak");
|
|
await applyCoreMigrations(database.url);
|
|
const running = await startMigratedApi(database.url);
|
|
const origin = apiOrigin(running);
|
|
|
|
await signUp(origin, "[email protected]", "correct horse battery staple");
|
|
|
|
// Force the database itself unreachable out from under the running API pool, then drive a
|
|
// request through Better Auth's own database-backed handler (not Ekklesia's own principal
|
|
// route) so Better Auth's internal logging — configured in `core/src/identity/runtime.ts` —
|
|
// is the thing actually being exercised here, not Core's own error handling. Without
|
|
// `onAPIError: { throw: true }`, the pinned release's own `better-call` transport dependency
|
|
// would catch this internally and print it raw via an unconditional
|
|
// `console.error("# SERVER_ERROR: ", error)` that no `logger` configuration can reach; with
|
|
// it, the failure instead propagates out of `auth.handler()` and becomes a sanitized
|
|
// `IdentityRuntimeError` at `handleAuthRequest`'s own boundary, and nothing is logged at all.
|
|
await dropDatabase(database.name);
|
|
|
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
|
const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
try {
|
|
const response = await fetch(`${origin}/api/auth/sign-in/email`, {
|
|
body: JSON.stringify({
|
|
email: "[email protected]",
|
|
password: "correct horse battery staple",
|
|
}),
|
|
headers: { "content-type": "application/json", origin: dashboardOrigin },
|
|
method: "POST",
|
|
});
|
|
const body = await response.clone().json();
|
|
|
|
expect(response.status).not.toBe(200);
|
|
expect(body).toEqual({ code: "AUTH_REQUEST_FAILED" });
|
|
|
|
const emitted = [...errorSpy.mock.calls, ...warnSpy.mock.calls, ...logSpy.mock.calls]
|
|
.flat()
|
|
.map((value) =>
|
|
value instanceof Error ? `${value.message}\n${value.stack ?? ""}` : String(value),
|
|
)
|
|
.join("\n");
|
|
|
|
// The primary guarantee: onAPIError.throw routes this exact failure out to Core's own
|
|
// sanitized boundary before Better Auth (or its own transport dependency) ever gets a
|
|
// chance to log anything about it, so nothing is emitted at all.
|
|
expect(emitted).toBe("");
|
|
// Defense in depth, kept even though the assertion above is currently sufficient: if a
|
|
// future change causes something to be logged after all, it must still never contain any
|
|
// of this.
|
|
expect(emitted).not.toContain(database.url);
|
|
expect(emitted).not.toContain(database.name);
|
|
expect(emitted.toLowerCase()).not.toContain("password");
|
|
expect(emitted.toLowerCase()).not.toContain("stack");
|
|
expect(emitted).not.toMatch(/select|insert|update|delete|drizzle/iu);
|
|
expect(emitted).not.toContain(dashboardOrigin);
|
|
expect(emitted).not.toContain(identityBaseUrl);
|
|
} finally {
|
|
errorSpy.mockRestore();
|
|
warnSpy.mockRestore();
|
|
logSpy.mockRestore();
|
|
await running.shutdown().catch(() => undefined);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe.sequential("live API — platform owner and delegated grant management", () => {
|
|
let database: { name: string; url: string };
|
|
let running: RunningApi;
|
|
let origin: string;
|
|
let ownerUserId: string;
|
|
let ownerCookie: string;
|
|
let managerUserId: string;
|
|
let managerCookie: string;
|
|
let createdGrantId: string;
|
|
|
|
const ownerEmail = "[email protected]";
|
|
const managerEmail = "[email protected]";
|
|
const password = "correct horse battery staple";
|
|
// This scenario intentionally proves out the explicit-grant lifecycle (create, list, use,
|
|
// revoke) against Core's own grant-management permission rather than a module's — a module
|
|
// permission would exercise the exact same route and evaluator code, adding coverage breadth
|
|
// without coverage depth. The "live API — installed module permission end-to-end" scenario
|
|
// below is where a real installed module's declared permission is proven end-to-end instead.
|
|
const testPermission = "core.authorization.grants.manage";
|
|
// Well-formed (passes `PermissionId` shape validation) but never registered — used to prove the
|
|
// live route rejects it even for a caller who otherwise has full grant-management authority.
|
|
const unregisteredPermission = "calendar.events.read";
|
|
|
|
it("starts a freshly migrated API and signs up the eventual owner and eventual delegated manager", async () => {
|
|
database = await createDisposableDatabase("authz_http");
|
|
await applyCoreMigrations(database.url);
|
|
running = await startMigratedApi(database.url);
|
|
origin = apiOrigin(running);
|
|
|
|
const ownerSignUp = await signUp(origin, ownerEmail, password);
|
|
ownerUserId = ownerSignUp.body.user.id;
|
|
const managerSignUp = await signUp(origin, managerEmail, password);
|
|
managerUserId = managerSignUp.body.user.id;
|
|
|
|
const ownerSignIn = await signIn(origin, ownerEmail, password);
|
|
expect(ownerSignIn.cookiePair).toBeDefined();
|
|
ownerCookie = ownerSignIn.cookiePair as string;
|
|
});
|
|
|
|
it("denies grants access to an anonymous caller", async () => {
|
|
const response = await fetch(`${origin}/api/v1/grants?userId=${ownerUserId}`);
|
|
expect(response.status).toBe(403);
|
|
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
|
});
|
|
|
|
it("denies grants access to an authenticated caller with no owner authority and no grant", async () => {
|
|
const managerSignInBefore = await signIn(origin, managerEmail, password);
|
|
const response = await fetch(`${origin}/api/v1/grants?userId=${ownerUserId}`, {
|
|
headers: { cookie: managerSignInBefore.cookiePair as string },
|
|
});
|
|
expect(response.status).toBe(403);
|
|
});
|
|
|
|
it("bootstraps the owner via Core's real bootstrap operation, then confirms owner authority over the HTTP grants API", async () => {
|
|
// Exactly what `pnpm authorization:bootstrap-owner` would do, driven directly against the
|
|
// same disposable database the running API already uses — no second process needed to prove
|
|
// the HTTP layer honors real platform-owner state.
|
|
const { close, db } = createAuthorizationDrizzleDatabase(database.url);
|
|
try {
|
|
await bootstrapPlatformOwner(db, { userId: parseEkklesiaUserId(ownerUserId) });
|
|
} finally {
|
|
await close();
|
|
}
|
|
|
|
const response = await fetch(`${origin}/api/v1/grants?userId=${ownerUserId}`, {
|
|
headers: { cookie: ownerCookie },
|
|
});
|
|
expect(response.status).toBe(200);
|
|
expect(await response.json()).toEqual({ grants: [], nextCursor: null });
|
|
});
|
|
|
|
it("lets the owner delegate grants.manage to another account through the explicit-grant HTTP API", async () => {
|
|
const response = await fetch(`${origin}/api/v1/grants`, {
|
|
body: JSON.stringify({
|
|
permission: "core.authorization.grants.manage",
|
|
subjectUserId: managerUserId,
|
|
}),
|
|
headers: {
|
|
"content-type": "application/json",
|
|
cookie: ownerCookie,
|
|
origin: dashboardOrigin,
|
|
},
|
|
method: "POST",
|
|
});
|
|
const body = (await response.json()) as { grant: { sourceKind: string; userId: string } };
|
|
|
|
expect(response.status).toBe(201);
|
|
expect(body.grant.sourceKind).toBe("explicit");
|
|
expect(body.grant.userId).toBe(managerUserId);
|
|
});
|
|
|
|
it("lets the delegated manager — who is not a platform owner — manage another user's grants", async () => {
|
|
const managerSignIn = await signIn(origin, managerEmail, password);
|
|
expect(managerSignIn.cookiePair).toBeDefined();
|
|
managerCookie = managerSignIn.cookiePair as string;
|
|
|
|
const response = await fetch(`${origin}/api/v1/grants?userId=${ownerUserId}`, {
|
|
headers: { cookie: managerCookie },
|
|
});
|
|
expect(response.status).toBe(200);
|
|
});
|
|
|
|
it("rejects a well-formed but unregistered permission even from a caller with full grant-management authority, and writes no grant or audit row", async () => {
|
|
const admin = await openAdministrativeClient(database.url);
|
|
try {
|
|
const grantCountBefore = await safeQuery<{ count: string }>(
|
|
admin,
|
|
`select count(*)::text as count from "ekklesia_core_grant" where permission_id = $1`,
|
|
[unregisteredPermission],
|
|
);
|
|
const auditCountBefore = await safeQuery<{ count: string }>(
|
|
admin,
|
|
`select count(*)::text as count from "ekklesia_core_authorization_audit" where permission_id = $1`,
|
|
[unregisteredPermission],
|
|
);
|
|
expect(grantCountBefore[0]?.count).toBe("0");
|
|
expect(auditCountBefore[0]?.count).toBe("0");
|
|
|
|
const response = await fetch(`${origin}/api/v1/grants`, {
|
|
body: JSON.stringify({
|
|
permission: unregisteredPermission,
|
|
subjectUserId: managerUserId,
|
|
}),
|
|
headers: {
|
|
"content-type": "application/json",
|
|
cookie: managerCookie,
|
|
origin: dashboardOrigin,
|
|
},
|
|
method: "POST",
|
|
});
|
|
expect(response.status).toBe(403);
|
|
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
|
|
|
const grantCountAfter = await safeQuery<{ count: string }>(
|
|
admin,
|
|
`select count(*)::text as count from "ekklesia_core_grant" where permission_id = $1`,
|
|
[unregisteredPermission],
|
|
);
|
|
const auditCountAfter = await safeQuery<{ count: string }>(
|
|
admin,
|
|
`select count(*)::text as count from "ekklesia_core_authorization_audit" where permission_id = $1`,
|
|
[unregisteredPermission],
|
|
);
|
|
expect(grantCountAfter[0]?.count).toBe("0");
|
|
expect(auditCountAfter[0]?.count).toBe("0");
|
|
} finally {
|
|
await safeEnd(admin);
|
|
}
|
|
});
|
|
|
|
it("creates an explicit grant via HTTP, deriving sourceKind and actor server-side, and records an atomic audit entry", async () => {
|
|
const response = await fetch(`${origin}/api/v1/grants`, {
|
|
// A subject with no equivalent grant yet — the manager already holds an equivalent
|
|
// `grants.manage` grant from the delegation scenario above, which the new duplicate
|
|
// invariant would (correctly) refuse. Unscoped: the generic HTTP surface creates only
|
|
// unscoped explicit grants, since a scope it accepted could never authorize anything.
|
|
body: JSON.stringify({
|
|
permission: testPermission,
|
|
subjectUserId: ownerUserId,
|
|
}),
|
|
headers: {
|
|
"content-type": "application/json",
|
|
cookie: managerCookie,
|
|
origin: dashboardOrigin,
|
|
},
|
|
method: "POST",
|
|
});
|
|
const body = (await response.json()) as {
|
|
grant: { id: string; sourceKind: string; userId: string };
|
|
};
|
|
|
|
expect(response.status).toBe(201);
|
|
expect(body.grant.sourceKind).toBe("explicit");
|
|
expect(body.grant.userId).toBe(ownerUserId);
|
|
createdGrantId = body.grant.id;
|
|
|
|
const admin = await openAdministrativeClient(database.url);
|
|
try {
|
|
const rows = await safeQuery<{ action: string; actor_id: string | null }>(
|
|
admin,
|
|
`select action, actor_id from "ekklesia_core_authorization_audit" where grant_id = $1`,
|
|
[createdGrantId],
|
|
);
|
|
expect(rows).toEqual([{ action: "grant.create", actor_id: managerUserId }]);
|
|
} finally {
|
|
await safeEnd(admin);
|
|
}
|
|
});
|
|
|
|
it("lists the explicit grant via HTTP, bounded and with an explicit next-page cursor field", async () => {
|
|
const response = await fetch(`${origin}/api/v1/grants?userId=${ownerUserId}`, {
|
|
headers: { cookie: ownerCookie },
|
|
});
|
|
const body = (await response.json()) as {
|
|
grants: Array<{ id: string; scope: string | null }>;
|
|
nextCursor: string | null;
|
|
};
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
|
expect(body.grants.some((grant) => grant.id === createdGrantId)).toBe(true);
|
|
// Created through the generic HTTP surface, so it is unscoped.
|
|
expect(body.grants.find((grant) => grant.id === createdGrantId)?.scope).toBeNull();
|
|
expect(body.nextCursor).toBeNull();
|
|
});
|
|
|
|
it("rejects a create-grant request carrying disallowed fields such as actor or sourceKind, even alongside otherwise-valid fields", async () => {
|
|
const response = await fetch(`${origin}/api/v1/grants`, {
|
|
body: JSON.stringify({
|
|
actor: { type: "system" },
|
|
permission: testPermission,
|
|
sourceKind: "relationship",
|
|
subjectUserId: managerUserId,
|
|
}),
|
|
headers: {
|
|
"content-type": "application/json",
|
|
cookie: ownerCookie,
|
|
origin: dashboardOrigin,
|
|
},
|
|
method: "POST",
|
|
});
|
|
expect(response.status).toBe(400);
|
|
});
|
|
|
|
it("rejects a mutation request with no trusted Origin header, and never returns a permissive CORS header", async () => {
|
|
const response = await fetch(`${origin}/api/v1/grants`, {
|
|
body: JSON.stringify({ permission: testPermission, subjectUserId: managerUserId }),
|
|
headers: { "content-type": "application/json", cookie: ownerCookie },
|
|
method: "POST",
|
|
});
|
|
expect(response.status).toBe(403);
|
|
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
|
|
expect(response.headers.get("access-control-allow-origin")).toBeNull();
|
|
});
|
|
|
|
it("rejects a mutation request from an untrusted Origin the same way", async () => {
|
|
const response = await fetch(`${origin}/api/v1/grants`, {
|
|
body: JSON.stringify({ permission: testPermission, subjectUserId: managerUserId }),
|
|
headers: {
|
|
"content-type": "application/json",
|
|
cookie: ownerCookie,
|
|
origin: untrustedOrigin,
|
|
},
|
|
method: "POST",
|
|
});
|
|
expect(response.status).toBe(403);
|
|
expect(response.headers.get("access-control-allow-origin")).toBeNull();
|
|
});
|
|
|
|
it("revokes the explicit grant via HTTP and records the revocation audit entry", async () => {
|
|
const response = await fetch(`${origin}/api/v1/grants/${createdGrantId}/revoke`, {
|
|
headers: { cookie: ownerCookie, origin: dashboardOrigin },
|
|
method: "POST",
|
|
});
|
|
expect(response.status).toBe(200);
|
|
expect(await response.json()).toEqual({ success: true });
|
|
|
|
const admin = await openAdministrativeClient(database.url);
|
|
try {
|
|
const rows = await safeQuery<{ action: string }>(
|
|
admin,
|
|
`select action from "ekklesia_core_authorization_audit" where grant_id = $1 order by created_at`,
|
|
[createdGrantId],
|
|
);
|
|
expect(rows.map((row) => row.action)).toEqual(["grant.create", "grant.revoke"]);
|
|
} finally {
|
|
await safeEnd(admin);
|
|
}
|
|
});
|
|
|
|
it("never exposes a relationship-derived grant through the generic list, create, or revoke HTTP API", async () => {
|
|
// Seeded directly through the store, simulating a module's own business operation — never
|
|
// through HTTP, which has no way to express sourceKind at all.
|
|
const { close, db } = createAuthorizationDrizzleDatabase(database.url);
|
|
let relationshipGrantId: string;
|
|
try {
|
|
const store = createAuthorizationStore(db);
|
|
const relationshipGrant = await store.createGrant({
|
|
actor: SYSTEM_ACTOR,
|
|
permission: parsePermissionId("roster.volunteer.read"),
|
|
sourceKind: "relationship",
|
|
sourceRef: "roster:volunteer:99",
|
|
userId: parseEkklesiaUserId(managerUserId),
|
|
});
|
|
relationshipGrantId = relationshipGrant.id;
|
|
} finally {
|
|
await close();
|
|
}
|
|
|
|
const listResponse = await fetch(`${origin}/api/v1/grants?userId=${managerUserId}`, {
|
|
headers: { cookie: ownerCookie },
|
|
});
|
|
const listBody = (await listResponse.json()) as { grants: Array<{ id: string }> };
|
|
expect(listBody.grants.some((grant) => grant.id === relationshipGrantId)).toBe(false);
|
|
|
|
const revokeResponse = await fetch(`${origin}/api/v1/grants/${relationshipGrantId}/revoke`, {
|
|
headers: { cookie: ownerCookie, origin: dashboardOrigin },
|
|
method: "POST",
|
|
});
|
|
expect(revokeResponse.status).toBe(404);
|
|
expect(await revokeResponse.json()).toEqual({ code: "AUTHORIZATION_GRANT_NOT_FOUND" });
|
|
});
|
|
|
|
it("shuts down cleanly, closing the one shared pool", async () => {
|
|
await running.shutdown();
|
|
const remainingConnections = await activeConnectionCount(database.name);
|
|
expect(remainingConnections).toBe(0);
|
|
await dropDatabase(database.name);
|
|
});
|
|
});
|
|
|
|
describe.sequential("live API — authorization infrastructure failure is never downgraded to a false denial", () => {
|
|
it("returns a sanitized 503, not a 403, when the database becomes unreachable mid-session", async () => {
|
|
const database = await createDisposableDatabase("authz_infra_failure");
|
|
await applyCoreMigrations(database.url);
|
|
const running = await startMigratedApi(database.url);
|
|
const origin = apiOrigin(running);
|
|
|
|
await signUp(origin, "[email protected]", "correct horse battery staple");
|
|
const { cookiePair } = await signIn(
|
|
origin,
|
|
"[email protected]",
|
|
"correct horse battery staple",
|
|
);
|
|
expect(cookiePair).toBeDefined();
|
|
|
|
// Force the database itself unreachable out from under the running API pool — the same
|
|
// technique the identity infra-failure test above uses, applied to a grants route this time.
|
|
await dropDatabase(database.name);
|
|
|
|
const response = await fetch(`${origin}/api/v1/grants?userId=does-not-matter`, {
|
|
headers: { cookie: cookiePair as string },
|
|
});
|
|
expect(response.status).toBe(503);
|
|
const body = (await response.json()) as { code: string };
|
|
expect(["PRINCIPAL_RESOLUTION_FAILED", "AUTHORIZATION_EVALUATION_FAILED"]).toContain(body.code);
|
|
|
|
await running.shutdown().catch(() => undefined);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Pass 3's explicit-grant invariants, exercised end-to-end through a real authorized
|
|
* grants-management session against real PostgreSQL — the layer where a validation decision that
|
|
* "should" happen before the database either does or does not.
|
|
*/
|
|
describe.sequential("live API — explicit-grant invariants over real HTTP", () => {
|
|
let database: { name: string; url: string };
|
|
let running: RunningApi;
|
|
let origin: string;
|
|
let ownerUserId: string;
|
|
let subjectUserId: string;
|
|
let ownerCookie: string;
|
|
const password = "correct horse battery staple";
|
|
const managePermission = "core.authorization.grants.manage";
|
|
|
|
async function post(path: string, body?: unknown): Promise<Response> {
|
|
return await fetch(`${origin}${path}`, {
|
|
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
headers: {
|
|
...(body === undefined ? {} : { "content-type": "application/json" }),
|
|
cookie: ownerCookie,
|
|
origin: dashboardOrigin,
|
|
},
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
async function list(query: string): Promise<{ body: unknown; response: Response }> {
|
|
const response = await fetch(`${origin}/api/v1/grants?${query}`, {
|
|
headers: { cookie: ownerCookie },
|
|
});
|
|
return { body: await response.json(), response };
|
|
}
|
|
|
|
it("starts a freshly migrated API with a bootstrapped owner and a separate grant subject", async () => {
|
|
database = await createDisposableDatabase("grant_invariants");
|
|
await applyCoreMigrations(database.url);
|
|
running = await startMigratedApi(database.url);
|
|
origin = apiOrigin(running);
|
|
|
|
ownerUserId = (await signUp(origin, "[email protected]", password)).body.user.id;
|
|
subjectUserId = (await signUp(origin, "[email protected]", password)).body.user
|
|
.id;
|
|
|
|
const { close, db } = createAuthorizationDrizzleDatabase(database.url);
|
|
try {
|
|
await bootstrapPlatformOwner(db, { userId: parseEkklesiaUserId(ownerUserId) });
|
|
} finally {
|
|
await close();
|
|
}
|
|
const ownerSignIn = await signIn(origin, "[email protected]", password);
|
|
expect(ownerSignIn.cookiePair).toBeDefined();
|
|
ownerCookie = ownerSignIn.cookiePair as string;
|
|
});
|
|
|
|
it("rejects a malformed non-UUID grant id with a sanitized 400, never a database failure", async () => {
|
|
for (const malformed of [
|
|
"not-a-uuid",
|
|
"0B36B1B6-9A29-4C4E-9E2D-4C0E4C5E6F70",
|
|
"0b36b1b6-9a29-4c4e-9e2d-4c0e4c5e6f7",
|
|
]) {
|
|
const response = await post(`/api/v1/grants/${malformed}/revoke`);
|
|
const raw = await response.text();
|
|
|
|
// Before Pass 3 this reached PostgreSQL's own UUID comparison and returned 503.
|
|
expect(response.status, malformed).toBe(400);
|
|
expect(JSON.parse(raw)).toEqual({ code: "INVALID_REQUEST" });
|
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
|
expect(raw).not.toContain(malformed);
|
|
}
|
|
});
|
|
|
|
it("returns the sanitized 404 for a canonical but unknown grant id", async () => {
|
|
const response = await post("/api/v1/grants/00000000-0000-4000-8000-000000000000/revoke");
|
|
expect(response.status).toBe(404);
|
|
expect(await response.json()).toEqual({ code: "AUTHORIZATION_GRANT_NOT_FOUND" });
|
|
});
|
|
|
|
it("rejects a scoped create body with a sanitized 400 and writes nothing", async () => {
|
|
const response = await post("/api/v1/grants", {
|
|
permission: managePermission,
|
|
scope: "calendar-event:1",
|
|
subjectUserId,
|
|
});
|
|
expect(response.status).toBe(400);
|
|
expect(await response.json()).toEqual({ code: "INVALID_REQUEST" });
|
|
|
|
const admin = await openAdministrativeClient(database.url);
|
|
try {
|
|
expect(
|
|
await safeQuery(admin, `select id from "ekklesia_core_grant" where user_id = $1`, [
|
|
subjectUserId,
|
|
]),
|
|
).toHaveLength(0);
|
|
} finally {
|
|
await safeEnd(admin);
|
|
}
|
|
});
|
|
|
|
it("rejects normalized-impossible, non-canonical, and past expirations with a sanitized 400", async () => {
|
|
for (const expiresAt of [
|
|
"2026-02-31T00:00:00.000Z",
|
|
"March 5, 2027",
|
|
"2027-01-01",
|
|
"2027-01-01T00:00:00Z",
|
|
"2027-01-01T00:00:00.000+02:00",
|
|
"2020-01-01T00:00:00.000Z",
|
|
]) {
|
|
const response = await post("/api/v1/grants", {
|
|
expiresAt,
|
|
permission: managePermission,
|
|
subjectUserId,
|
|
});
|
|
expect(response.status, expiresAt).toBe(400);
|
|
expect(await response.json()).toEqual({ code: "INVALID_REQUEST" });
|
|
}
|
|
});
|
|
|
|
it("creates one grant, then answers an equivalent creation with a sanitized 409 and no second row", async () => {
|
|
const created = await post("/api/v1/grants", {
|
|
permission: managePermission,
|
|
subjectUserId,
|
|
});
|
|
expect(created.status).toBe(201);
|
|
|
|
const duplicate = await post("/api/v1/grants", {
|
|
permission: managePermission,
|
|
subjectUserId,
|
|
});
|
|
const raw = await duplicate.text();
|
|
expect(duplicate.status).toBe(409);
|
|
expect(JSON.parse(raw)).toEqual({ code: "AUTHORIZATION_GRANT_ALREADY_EXISTS" });
|
|
expect(duplicate.headers.get("cache-control")).toBe("no-store");
|
|
// No existing grant id, actor, scope, source reference, SQL, or driver text leaks.
|
|
expect(raw).not.toContain(ownerUserId);
|
|
expect(raw).not.toContain(subjectUserId);
|
|
expect(raw).not.toContain("ekklesia_core_grant");
|
|
|
|
// A differing expiration is not a different grant either.
|
|
const withExpiry = await post("/api/v1/grants", {
|
|
expiresAt: "2099-01-01T00:00:00.000Z",
|
|
permission: managePermission,
|
|
subjectUserId,
|
|
});
|
|
expect(withExpiry.status).toBe(409);
|
|
|
|
const admin = await openAdministrativeClient(database.url);
|
|
try {
|
|
expect(
|
|
await safeQuery(
|
|
admin,
|
|
`select id from "ekklesia_core_grant" where user_id = $1 and revoked_at is null`,
|
|
[subjectUserId],
|
|
),
|
|
).toHaveLength(1);
|
|
} finally {
|
|
await safeEnd(admin);
|
|
}
|
|
});
|
|
|
|
it("paginates deterministically across at least three pages and ends with a null cursor", async () => {
|
|
// Seeded through the test-only administrative client so the page count is deterministic and
|
|
// independent of how many grants the scenarios above happened to create.
|
|
const admin = await openAdministrativeClient(database.url);
|
|
try {
|
|
await safeQuery(
|
|
admin,
|
|
`insert into "ekklesia_core_grant"
|
|
(user_id, permission_id, scope, source_kind, source_ref, created_by_actor_type, created_by_actor_id, created_at)
|
|
select $1, 'calendar.events.page' || generation, null, 'explicit', null, 'user', $2,
|
|
timestamptz '2026-01-01 00:00:00Z'
|
|
from generate_series(1, 6) as generation`,
|
|
[subjectUserId, ownerUserId],
|
|
);
|
|
await safeQuery(
|
|
admin,
|
|
`insert into "ekklesia_core_grant"
|
|
(user_id, permission_id, scope, source_kind, source_ref, created_by_actor_type, created_by_actor_id)
|
|
values ($1, 'roster.volunteer.read', null, 'relationship', 'roster:1', 'user', $2)`,
|
|
[subjectUserId, ownerUserId],
|
|
);
|
|
} finally {
|
|
await safeEnd(admin);
|
|
}
|
|
|
|
const seen: string[] = [];
|
|
let cursor: string | null = null;
|
|
let pages = 0;
|
|
for (;;) {
|
|
const query = `userId=${subjectUserId}&limit=3${
|
|
cursor === null ? "" : `&cursor=${encodeURIComponent(cursor)}`
|
|
}`;
|
|
const { body, response } = await list(query);
|
|
const page = body as {
|
|
grants: Array<{ id: string; sourceKind: string }>;
|
|
nextCursor: string | null;
|
|
};
|
|
expect(response.status).toBe(200);
|
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
|
expect(page.grants.length).toBeLessThanOrEqual(3);
|
|
// Relationship grants never appear on any page.
|
|
expect(page.grants.every((grant) => grant.sourceKind === "explicit")).toBe(true);
|
|
seen.push(...page.grants.map((grant) => grant.id));
|
|
pages += 1;
|
|
if (page.nextCursor === null) {
|
|
break;
|
|
}
|
|
cursor = page.nextCursor;
|
|
expect(pages).toBeLessThan(10);
|
|
}
|
|
|
|
expect(pages).toBeGreaterThanOrEqual(3);
|
|
expect(seen).toHaveLength(7);
|
|
expect(new Set(seen).size).toBe(seen.length);
|
|
});
|
|
|
|
it("rejects an out-of-range limit and a foreign or malformed cursor with a sanitized 400", async () => {
|
|
for (const query of [
|
|
`userId=${subjectUserId}&limit=0`,
|
|
`userId=${subjectUserId}&limit=101`,
|
|
`userId=${subjectUserId}&limit=01`,
|
|
`userId=${subjectUserId}&limit=1&limit=2`,
|
|
`userId=${subjectUserId}&unknown=1`,
|
|
`userId=${subjectUserId}&cursor=!!!`,
|
|
// A cursor minted for a different subject must not be replayable against this one.
|
|
`userId=${subjectUserId}&cursor=${encodeURIComponent(
|
|
Buffer.from(
|
|
JSON.stringify({
|
|
g: "00000000-0000-4000-8000-000000000000",
|
|
t: "2026-01-01T00:00:00.000Z",
|
|
u: ownerUserId,
|
|
v: 1,
|
|
}),
|
|
"utf8",
|
|
).toString("base64url"),
|
|
)}`,
|
|
]) {
|
|
const { body, response } = await list(query);
|
|
expect(response.status, query).toBe(400);
|
|
expect(body).toEqual({ code: "INVALID_REQUEST" });
|
|
}
|
|
});
|
|
|
|
it("revokes every unrevoked equivalent through one HTTP revocation, leaving nothing authorizing", async () => {
|
|
const permission = "calendar.events.legacydupe";
|
|
const admin = await openAdministrativeClient(database.url);
|
|
let ids: string[] = [];
|
|
try {
|
|
// Legacy duplicates the pre-Pass-3 store could produce, planted directly because the
|
|
// production store now refuses to create them.
|
|
const rows = await safeQuery<{ id: string }>(
|
|
admin,
|
|
`insert into "ekklesia_core_grant"
|
|
(user_id, permission_id, scope, source_kind, source_ref, created_by_actor_type, created_by_actor_id)
|
|
values ($1, $2, null, 'explicit', null, 'user', $3),
|
|
($1, $2, null, 'explicit', null, 'user', $3),
|
|
($1, $2, null, 'explicit', null, 'user', $3)
|
|
returning id`,
|
|
[subjectUserId, permission, ownerUserId],
|
|
);
|
|
ids = rows.map((row) => row.id);
|
|
} finally {
|
|
await safeEnd(admin);
|
|
}
|
|
|
|
const response = await post(`/api/v1/grants/${ids[0]}/revoke`);
|
|
expect(response.status).toBe(200);
|
|
expect(await response.json()).toEqual({ success: true });
|
|
|
|
const inspector = await openAdministrativeClient(database.url);
|
|
try {
|
|
const remaining = await safeQuery(
|
|
inspector,
|
|
`select id from "ekklesia_core_grant"
|
|
where user_id = $1 and permission_id = $2 and revoked_at is null`,
|
|
[subjectUserId, permission],
|
|
);
|
|
expect(remaining).toHaveLength(0);
|
|
const audit = await safeQuery<{ grant_id: string }>(
|
|
inspector,
|
|
`select grant_id from "ekklesia_core_authorization_audit"
|
|
where action = 'grant.revoke' and grant_id = any($1::uuid[])`,
|
|
[ids],
|
|
);
|
|
expect([...new Set(audit.map((row) => row.grant_id))].sort()).toEqual([...ids].sort());
|
|
} finally {
|
|
await safeEnd(inspector);
|
|
}
|
|
});
|
|
|
|
it("shuts down cleanly", async () => {
|
|
await running.shutdown();
|
|
await dropDatabase(database.name);
|
|
});
|
|
});
|
|
|
|
describe.sequential("live API — installed module permission end-to-end", () => {
|
|
let database: { name: string; url: string };
|
|
let modulesDirectory: string;
|
|
let running: RunningApi;
|
|
let origin: string;
|
|
let ownerUserId: string;
|
|
let ownerCookie: string;
|
|
let createdGrantId: string;
|
|
|
|
const ownerEmail = "[email protected]";
|
|
const password = "correct horse battery staple";
|
|
const modulePermission = "calendar.events.read";
|
|
const absentPermission = "scheduler.jobs.read";
|
|
const recorder = createEventRecorder();
|
|
|
|
it("recognizes a real installed module and passes permission composition in Doctor, without executing its server entrypoint", async () => {
|
|
modulesDirectory = await createFixtureModulesDirectory();
|
|
await createFixtureModule(modulesDirectory, "calendar", recorder, {
|
|
permissions: [
|
|
{
|
|
baseline: "restricted",
|
|
description: "Read calendar events.",
|
|
id: modulePermission,
|
|
},
|
|
],
|
|
});
|
|
|
|
const report = await diagnoseEkklesia({
|
|
moduleDiscovery: {
|
|
moduleSdkVersion: "0.1.0",
|
|
modulesDirectory,
|
|
platformVersion: "0.1.0",
|
|
},
|
|
runtimeRange: ">=0.0.0",
|
|
runtimeVersion: process.versions.node,
|
|
});
|
|
|
|
// Only the module-discovery and permission-composition checks are asserted here — this
|
|
// fixture's temporary directory has no `core/drizzle` sibling for migrations discovery to
|
|
// find, which is unrelated to what this scenario proves and would otherwise make the check
|
|
// fail for a reason that has nothing to do with permission composition.
|
|
const moduleCheck = report.checks.find((entry) => entry.id === "modules.discovery");
|
|
const permissionsCheck = report.checks.find((entry) => entry.id === "modules.permissions");
|
|
expect(moduleCheck).toMatchObject({ status: "pass" });
|
|
expect(moduleCheck?.details).toEqual(["calendar 0.1.0"]);
|
|
expect(permissionsCheck).toMatchObject({
|
|
// Core's own six (`core.authorization.grants.manage`, `core.modules.ui.read`,
|
|
// `core.dashboard.navigation.manage`, `core.modules.lifecycle.manage`,
|
|
// `core.modules.configuration.manage`, `core.devices.manage`) plus the fixture module's one.
|
|
details: ["7 permissions registered"],
|
|
status: "pass",
|
|
});
|
|
// Doctor and permission composition read only the manifest — the fixture's register/start
|
|
// markers must remain entirely unset.
|
|
expect(recorder.events).toEqual([]);
|
|
});
|
|
|
|
it("starts a freshly migrated API that discovers the fixture module, imports and starts its runtime exactly once, and mounts its route", async () => {
|
|
database = await createDisposableDatabase("authz_module");
|
|
await applyCoreMigrations(database.url);
|
|
running = await startMigratedApi(database.url, modulesDirectory);
|
|
origin = apiOrigin(running);
|
|
|
|
// Unlike Doctor above, a successful production startup does deliberately import, register,
|
|
// and start the module's runtime — exactly once each.
|
|
expect(recorder.events).toEqual(["calendar:register", "calendar:start"]);
|
|
|
|
const ownerSignUp = await signUp(origin, ownerEmail, password);
|
|
ownerUserId = ownerSignUp.body.user.id;
|
|
const ownerSignIn = await signIn(origin, ownerEmail, password);
|
|
expect(ownerSignIn.cookiePair).toBeDefined();
|
|
ownerCookie = ownerSignIn.cookiePair as string;
|
|
|
|
// Existing identity HTTP behavior — sign-up, sign-in, principal resolution — is unaffected by
|
|
// module permission composition running before it.
|
|
const { body: principal } = await fetchPrincipal(origin, { cookie: ownerCookie });
|
|
expect(principal).toEqual({ kind: "authenticated", userId: ownerUserId });
|
|
});
|
|
|
|
it("lets an authorized platform owner create an explicit grant for the installed module's permission, with an atomic audit entry using its declared id", async () => {
|
|
const { close, db } = createAuthorizationDrizzleDatabase(database.url);
|
|
try {
|
|
await bootstrapPlatformOwner(db, { userId: parseEkklesiaUserId(ownerUserId) });
|
|
} finally {
|
|
await close();
|
|
}
|
|
|
|
const response = await fetch(`${origin}/api/v1/grants`, {
|
|
body: JSON.stringify({ permission: modulePermission, subjectUserId: ownerUserId }),
|
|
headers: {
|
|
"content-type": "application/json",
|
|
cookie: ownerCookie,
|
|
origin: dashboardOrigin,
|
|
},
|
|
method: "POST",
|
|
});
|
|
const body = (await response.json()) as {
|
|
grant: { id: string; permission: string; sourceKind: string };
|
|
};
|
|
|
|
expect(response.status).toBe(201);
|
|
expect(body.grant.permission).toBe(modulePermission);
|
|
expect(body.grant.sourceKind).toBe("explicit");
|
|
createdGrantId = body.grant.id;
|
|
|
|
const admin = await openAdministrativeClient(database.url);
|
|
try {
|
|
const rows = await safeQuery<{ action: string; permission_id: string }>(
|
|
admin,
|
|
`select action, permission_id from "ekklesia_core_authorization_audit" where grant_id = $1`,
|
|
[createdGrantId],
|
|
);
|
|
expect(rows).toEqual([{ action: "grant.create", permission_id: modulePermission }]);
|
|
} finally {
|
|
await safeEnd(admin);
|
|
}
|
|
});
|
|
|
|
it("denies the module's own mounted route to an anonymous caller, then serves it to the newly granted owner with the decoded path parameter and authenticated principal", async () => {
|
|
const anonymousResponse = await fetch(`${origin}/api/v1/modules/calendar/events/evt-42`);
|
|
expect(anonymousResponse.status).toBe(403);
|
|
expect(await anonymousResponse.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
|
|
|
const grantedResponse = await fetch(`${origin}/api/v1/modules/calendar/events/evt-42`, {
|
|
headers: { cookie: ownerCookie },
|
|
});
|
|
expect(grantedResponse.status).toBe(200);
|
|
const body = (await grantedResponse.json()) as {
|
|
eventId: string;
|
|
principal: { kind: string; userId: string };
|
|
};
|
|
expect(body.eventId).toBe("evt-42");
|
|
expect(body.principal).toEqual({ kind: "authenticated", userId: ownerUserId });
|
|
});
|
|
|
|
it("requires a trusted Origin for the module's own state-changing route, exactly like the platform's own mutation routes", async () => {
|
|
const noOriginResponse = await fetch(`${origin}/api/v1/modules/calendar/events`, {
|
|
body: JSON.stringify({}),
|
|
headers: { "content-type": "application/json", cookie: ownerCookie },
|
|
method: "POST",
|
|
});
|
|
expect(noOriginResponse.status).toBe(403);
|
|
expect(await noOriginResponse.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
|
|
|
|
const untrustedResponse = await fetch(`${origin}/api/v1/modules/calendar/events`, {
|
|
body: JSON.stringify({}),
|
|
headers: {
|
|
"content-type": "application/json",
|
|
cookie: ownerCookie,
|
|
origin: untrustedOrigin,
|
|
},
|
|
method: "POST",
|
|
});
|
|
expect(untrustedResponse.status).toBe(403);
|
|
|
|
const trustedResponse = await fetch(`${origin}/api/v1/modules/calendar/events`, {
|
|
body: JSON.stringify({}),
|
|
headers: {
|
|
"content-type": "application/json",
|
|
cookie: ownerCookie,
|
|
origin: dashboardOrigin,
|
|
},
|
|
method: "POST",
|
|
});
|
|
expect(trustedResponse.status).toBe(201);
|
|
expect(await trustedResponse.json()).toMatchObject({ created: true });
|
|
});
|
|
|
|
it("streams one image-sized upload end to end over real HTTP", async () => {
|
|
// 25 MiB — an ordinary photograph, and the size the streamed ceiling exists to accommodate. It
|
|
// travels through a real socket, a real Node server, and the platform's byte counter, and the
|
|
// platform holds none of it (ADR 0042).
|
|
const image = new Uint8Array(25 * 1024 * 1024).fill(0x41);
|
|
|
|
const response = await fetch(`${origin}/api/v1/modules/calendar/uploads`, {
|
|
body: image,
|
|
headers: {
|
|
"content-type": "application/octet-stream",
|
|
cookie: ownerCookie,
|
|
origin: dashboardOrigin,
|
|
},
|
|
method: "POST",
|
|
});
|
|
|
|
expect(response.status).toBe(201);
|
|
expect(await response.json()).toEqual({ bytes: image.byteLength });
|
|
}, 60_000);
|
|
|
|
it("refuses a declared oversize on a streamed route with a sanitized 413", async () => {
|
|
const response = await fetch(`${origin}/api/v1/modules/calendar/small-uploads`, {
|
|
body: "a".repeat(65),
|
|
headers: {
|
|
"content-type": "text/plain",
|
|
cookie: ownerCookie,
|
|
origin: dashboardOrigin,
|
|
},
|
|
method: "POST",
|
|
});
|
|
|
|
expect(response.status).toBe(413);
|
|
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
|
|
});
|
|
|
|
it("terminates a chunked streamed body that exceeds the limit, even when the handler swallowed it", async () => {
|
|
// No declared `Content-Length`, so only the running byte count can bound this — and the
|
|
// handler deliberately ignores the stream error and answers `201`. The platform's `413` still
|
|
// wins: a limit a handler can swallow is not a limit.
|
|
const oversize = new ReadableStream<Uint8Array>({
|
|
pull(controller) {
|
|
controller.enqueue(new Uint8Array(512).fill(0x41));
|
|
},
|
|
});
|
|
|
|
const response = await fetch(`${origin}/api/v1/modules/calendar/small-uploads`, {
|
|
body: oversize,
|
|
duplex: "half",
|
|
headers: { cookie: ownerCookie, origin: dashboardOrigin },
|
|
method: "POST",
|
|
} as RequestInit);
|
|
|
|
expect(response.status).toBe(413);
|
|
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
|
|
}, 30_000);
|
|
|
|
it("keeps the server healthy after an oversized upload", async () => {
|
|
// The socket was released rather than left draining, so the very next request is served
|
|
// normally.
|
|
const response = await fetch(`${origin}/api/v1/modules/calendar/events/evt-43`, {
|
|
headers: { cookie: ownerCookie },
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(await response.json()).toMatchObject({ eventId: "evt-43" });
|
|
});
|
|
|
|
it("rejects a well-formed permission absent from the composed registry, writing no grant or audit row", async () => {
|
|
const admin = await openAdministrativeClient(database.url);
|
|
try {
|
|
const before = await safeQuery<{ count: string }>(
|
|
admin,
|
|
`select count(*)::text as count from "ekklesia_core_grant" where permission_id = $1`,
|
|
[absentPermission],
|
|
);
|
|
expect(before[0]?.count).toBe("0");
|
|
|
|
const response = await fetch(`${origin}/api/v1/grants`, {
|
|
body: JSON.stringify({ permission: absentPermission, subjectUserId: ownerUserId }),
|
|
headers: {
|
|
"content-type": "application/json",
|
|
cookie: ownerCookie,
|
|
origin: dashboardOrigin,
|
|
},
|
|
method: "POST",
|
|
});
|
|
expect(response.status).toBe(403);
|
|
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
|
|
|
const after = await safeQuery<{ count: string }>(
|
|
admin,
|
|
`select count(*)::text as count from "ekklesia_core_grant" where permission_id = $1`,
|
|
[absentPermission],
|
|
);
|
|
const audit = await safeQuery<{ count: string }>(
|
|
admin,
|
|
`select count(*)::text as count from "ekklesia_core_authorization_audit" where permission_id = $1`,
|
|
[absentPermission],
|
|
);
|
|
expect(after[0]?.count).toBe("0");
|
|
expect(audit[0]?.count).toBe("0");
|
|
} finally {
|
|
await safeEnd(admin);
|
|
}
|
|
});
|
|
|
|
it("shuts down cleanly, then restarting with the module absent rejects new grants for its former permission, returns NOT_FOUND for its former route, while the historical grant remains listable", async () => {
|
|
await running.shutdown();
|
|
const remainingConnections = await activeConnectionCount(database.name);
|
|
expect(remainingConnections).toBe(0);
|
|
// Exactly one stop, matching the exactly-one register/start already asserted above.
|
|
expect(recorder.events.filter((event) => event === "calendar:stop")).toHaveLength(1);
|
|
|
|
const emptyModulesDirectory = await createFixtureModulesDirectory();
|
|
running = await startMigratedApi(database.url, emptyModulesDirectory);
|
|
origin = apiOrigin(running);
|
|
|
|
// The module's own route is unmounted along with the module itself.
|
|
const removedRouteResponse = await fetch(`${origin}/api/v1/modules/calendar/events/evt-42`, {
|
|
headers: { cookie: ownerCookie },
|
|
});
|
|
expect(removedRouteResponse.status).toBe(404);
|
|
expect(await removedRouteResponse.json()).toEqual({ code: "NOT_FOUND" });
|
|
|
|
// The owner's existing session survives the restart (sessions live in the database, not the
|
|
// process), so the same cookie proves ordinary identity behavior is unaffected here too.
|
|
const listResponse = await fetch(`${origin}/api/v1/grants?userId=${ownerUserId}`, {
|
|
headers: { cookie: ownerCookie },
|
|
});
|
|
const listBody = (await listResponse.json()) as {
|
|
grants: Array<{ id: string; permission: string }>;
|
|
};
|
|
expect(listResponse.status).toBe(200);
|
|
expect(listBody.grants.some((grant) => grant.id === createdGrantId)).toBe(true);
|
|
|
|
const response = await fetch(`${origin}/api/v1/grants`, {
|
|
body: JSON.stringify({ permission: modulePermission, subjectUserId: ownerUserId }),
|
|
headers: {
|
|
"content-type": "application/json",
|
|
cookie: ownerCookie,
|
|
origin: dashboardOrigin,
|
|
},
|
|
method: "POST",
|
|
});
|
|
expect(response.status).toBe(403);
|
|
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
|
|
|
const admin = await openAdministrativeClient(database.url);
|
|
try {
|
|
const rows = await safeQuery<{ id: string }>(
|
|
admin,
|
|
`select id from "ekklesia_core_grant" where id = $1 and revoked_at is null`,
|
|
[createdGrantId],
|
|
);
|
|
expect(rows).toHaveLength(1);
|
|
} finally {
|
|
await safeEnd(admin);
|
|
}
|
|
|
|
await running.shutdown();
|
|
await dropDatabase(database.name);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* The publication route over a real socket, against a real started API: real Node HTTP, real
|
|
* headers, real bytes. The unit suite (`module-ui-routes.test.ts`) covers the security matrix in
|
|
* depth; this proves the same route behaves identically once it is behind the actual server rather
|
|
* than `app.request`.
|
|
*/
|
|
describe.sequential("live API — published module UI artifact", () => {
|
|
const uiArtifact = `export default {
|
|
id: "signage",
|
|
version: "2.0.1",
|
|
mount(container, context) {
|
|
container.textContent = context.moduleId;
|
|
return () => {
|
|
container.textContent = "";
|
|
};
|
|
},
|
|
};
|
|
`;
|
|
|
|
it("serves exactly the declared artifact, and nothing else in the module package", async () => {
|
|
const modulesDirectory = await createFixtureModulesDirectory();
|
|
await createUiModule(modulesDirectory, "signage", "2.0.1", uiArtifact);
|
|
const database = await createDisposableDatabase("module_ui");
|
|
await applyCoreMigrations(database.url);
|
|
const running = await startMigratedApi(database.url, modulesDirectory);
|
|
try {
|
|
const origin = apiOrigin(running);
|
|
const url = `${origin}${moduleUiArtifactUrl({ moduleId: "signage", version: "2.0.1" })}`;
|
|
|
|
const response = await fetch(url);
|
|
const body = await response.text();
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(body).toBe(uiArtifact);
|
|
expect(response.headers.get("content-type")).toBe(MODULE_UI_CONTENT_TYPE);
|
|
expect(response.headers.get("cache-control")).toBe(MODULE_UI_CACHE_CONTROL);
|
|
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
|
|
const etag = response.headers.get("etag");
|
|
expect(etag).toMatch(/^"[A-Za-z0-9_-]+"$/u);
|
|
|
|
// Revalidation over the wire, with the validator the server itself just issued.
|
|
const revalidated = await fetch(url, { headers: { "if-none-match": etag as string } });
|
|
expect(revalidated.status).toBe(304);
|
|
expect(await revalidated.text()).toBe("");
|
|
|
|
// The exact-version rule, and the neighbouring files, over the same real transport.
|
|
const unreachable = [
|
|
moduleUiArtifactUrl({ moduleId: "signage", version: "2.0.0" }),
|
|
moduleUiArtifactUrl({ moduleId: "announcements", version: "2.0.1" }),
|
|
`${moduleUiArtifactUrl({ moduleId: "signage", version: "2.0.1" })}/index.js.map`,
|
|
`${moduleUiArtifactUrl({ moduleId: "signage", version: "2.0.1" })}/chunk.js`,
|
|
`${moduleUiArtifactUrl({ moduleId: "signage", version: "2.0.1" })}/.env`,
|
|
`${moduleUiArtifactUrl({ moduleId: "signage", version: "2.0.1" })}.map`,
|
|
];
|
|
for (const candidate of unreachable) {
|
|
const refused = await fetch(`${origin}${candidate}`);
|
|
const refusedBody = await refused.text();
|
|
expect(refused.status, candidate).toBe(404);
|
|
expect(refusedBody, candidate).not.toContain("secret");
|
|
expect(refusedBody, candidate).not.toContain("mount");
|
|
}
|
|
} finally {
|
|
await running.shutdown().catch(() => undefined);
|
|
await dropDatabase(database.name);
|
|
}
|
|
});
|
|
|
|
it("answers a trusted browser origin with CORS, and an untrusted one without", async () => {
|
|
const modulesDirectory = await createFixtureModulesDirectory();
|
|
await createUiModule(modulesDirectory, "signage", "2.0.1", uiArtifact);
|
|
const database = await createDisposableDatabase("module_ui_cors");
|
|
await applyCoreMigrations(database.url);
|
|
const running = await startMigratedApi(database.url, modulesDirectory);
|
|
try {
|
|
const origin = apiOrigin(running);
|
|
const url = `${origin}${moduleUiArtifactUrl({ moduleId: "signage", version: "2.0.1" })}`;
|
|
|
|
// A cross-origin browser `import()` of this URL needs exactly this header, which is why the
|
|
// route lives inside the application's one CORS-governed `/api/v1/*` space.
|
|
const trusted = await fetch(url, { headers: { origin: dashboardOrigin } });
|
|
expect(trusted.status).toBe(200);
|
|
expect(trusted.headers.get("access-control-allow-origin")).toBe(dashboardOrigin);
|
|
expect(trusted.headers.get("vary")?.toLowerCase()).toContain("origin");
|
|
|
|
const untrusted = await fetch(url, { headers: { origin: untrustedOrigin } });
|
|
// The bytes are not confidential (ADR 0025), but the platform still never reflects an origin
|
|
// it does not trust, so a page on that origin cannot read the response.
|
|
expect(untrusted.headers.get("access-control-allow-origin")).toBeNull();
|
|
} finally {
|
|
await running.shutdown().catch(() => undefined);
|
|
await dropDatabase(database.name);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe.sequential("live API — the built Dashboard on the API's own origin", () => {
|
|
it("serves the built shell's index.html at /, with only its API origin written in, and its exact script, and nothing beside them", async () => {
|
|
const database = await createDisposableDatabase("dashboard_host");
|
|
await applyCoreMigrations(database.url);
|
|
const running = await startMigratedApi(database.url);
|
|
try {
|
|
const origin = apiOrigin(running);
|
|
const builtHtml = await readFile(new URL(dashboardHostAssetUrls.html), "utf8");
|
|
const builtScript = await readFile(new URL(dashboardHostAssetUrls.script), "utf8");
|
|
|
|
const page = await fetch(`${origin}${DASHBOARD_HOST_PAGE_PATH}`);
|
|
const html = await page.text();
|
|
expect(page.status).toBe(200);
|
|
expect(page.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
|
expect(page.headers.get("cache-control")).toBe("no-cache");
|
|
expect(page.headers.get("x-frame-options")).toBe("DENY");
|
|
expect(page.headers.get("content-security-policy")).toBe("frame-ancestors 'none'");
|
|
expect(page.headers.get("referrer-policy")).toBe("no-referrer");
|
|
// The built page carries one empty API-origin element; the API fills in its own origin
|
|
// (the identity base URL) and changes nothing else (ADR 0045).
|
|
expect(builtHtml.split(DASHBOARD_API_ORIGIN_PLACEHOLDER)).toHaveLength(2);
|
|
expect(html).toBe(
|
|
builtHtml.replace(
|
|
DASHBOARD_API_ORIGIN_PLACEHOLDER,
|
|
`<meta content="${identityBaseUrl}" name="ekklesia-api-origin" />`,
|
|
),
|
|
);
|
|
expect(html).toContain('id="ekklesia-host"');
|
|
expect(html).toContain(`<script src="${DASHBOARD_HOST_SCRIPT_PATH}" type="module"></script>`);
|
|
|
|
const revalidated = await fetch(`${origin}${DASHBOARD_HOST_PAGE_PATH}`, {
|
|
headers: { "if-none-match": page.headers.get("etag") ?? "" },
|
|
});
|
|
expect(revalidated.status).toBe(304);
|
|
|
|
const script = await fetch(`${origin}${DASHBOARD_HOST_SCRIPT_PATH}`);
|
|
expect(script.status).toBe(200);
|
|
expect(script.headers.get("content-type")).toBe("text/javascript; charset=utf-8");
|
|
expect(await script.text()).toBe(builtScript);
|
|
|
|
// The source map sits beside the script on disk and is never published.
|
|
const sourceMap = await fetch(`${origin}${DASHBOARD_HOST_SCRIPT_PATH}.map`);
|
|
expect(sourceMap.status).toBe(404);
|
|
expect(await sourceMap.json()).toEqual({ code: "NOT_FOUND" });
|
|
} finally {
|
|
await running.shutdown();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe.sequential("live API — health", () => {
|
|
it("answers liveness and readiness with 200 against a migrated disposable PostgreSQL", async () => {
|
|
const database = await createDisposableDatabase("health");
|
|
await applyCoreMigrations(database.url);
|
|
const running = await startMigratedApi(database.url);
|
|
try {
|
|
const origin = apiOrigin(running);
|
|
|
|
const live = await fetch(`${origin}${HEALTH_LIVE_PATH}`);
|
|
expect(live.status).toBe(200);
|
|
expect(live.headers.get("cache-control")).toBe("no-store");
|
|
expect(await live.json()).toEqual({ status: "live" });
|
|
|
|
const ready = await fetch(`${origin}${HEALTH_READY_PATH}`);
|
|
expect(ready.status).toBe(200);
|
|
expect(ready.headers.get("cache-control")).toBe("no-store");
|
|
expect(await ready.json()).toEqual({ status: "ready" });
|
|
} finally {
|
|
await running.shutdown();
|
|
}
|
|
});
|
|
|
|
it("pings through the shared pool, and PostgreSQL itself cancels a statement that outlives the ping's transaction-local timeout", async () => {
|
|
const database = await createDisposableDatabase("health_ping");
|
|
const handle = createApiDatabase(database.url);
|
|
try {
|
|
await expect(handle.ping(READINESS_DATABASE_TIMEOUT_MS)).resolves.toBeUndefined();
|
|
} finally {
|
|
await handle.close();
|
|
}
|
|
|
|
// The ping's exact statement shape, with a statement that cannot finish inside its timeout: the
|
|
// server cancels it (query_canceled) long before the sleep would end, on a client that has no
|
|
// client-side timeout of its own.
|
|
const client = new Client({ connectionString: database.url });
|
|
await client.connect();
|
|
try {
|
|
const startedAt = Date.now();
|
|
await expect(
|
|
client.query(
|
|
"BEGIN READ ONLY; SET LOCAL statement_timeout = 50; SELECT pg_sleep(5); COMMIT",
|
|
),
|
|
).rejects.toMatchObject({ code: "57014" });
|
|
expect(Date.now() - startedAt).toBeLessThan(2_000);
|
|
} finally {
|
|
await client.end();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe.sequential("live API — public signup is disabled by default", () => {
|
|
it("rejects sign-up with a stable sanitized 400 when publicSignupEnabled is not set, creating no user row", async () => {
|
|
const database = await createDisposableDatabase("signup_disabled");
|
|
await applyCoreMigrations(database.url);
|
|
const running = await startConfiguredApi(database.url, { publicSignupEnabled: false });
|
|
try {
|
|
const origin = apiOrigin(running);
|
|
const response = await fetch(`${origin}/api/auth/sign-up/email`, {
|
|
body: JSON.stringify({
|
|
email: "[email protected]",
|
|
name: "Blocked",
|
|
password: "correct horse battery staple",
|
|
}),
|
|
headers: { "content-type": "application/json", origin: dashboardOrigin },
|
|
method: "POST",
|
|
});
|
|
const text = await response.text();
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
|
expect(text.toLowerCase()).not.toContain("stack");
|
|
expect(text).not.toContain(database.url);
|
|
|
|
const admin = await openAdministrativeClient(database.url);
|
|
try {
|
|
const rows = await safeQuery<{ count: string }>(
|
|
admin,
|
|
'select count(*)::text as count from "ekklesia_core_user"',
|
|
);
|
|
expect(rows[0]?.count).toBe("0");
|
|
} finally {
|
|
await safeEnd(admin);
|
|
}
|
|
} finally {
|
|
await running.shutdown().catch(() => undefined);
|
|
await dropDatabase(database.name);
|
|
}
|
|
});
|
|
|
|
it("gives an identical rejection whether or not the requested email is already registered", async () => {
|
|
const database = await createDisposableDatabase("signup_disabled_enum");
|
|
await applyCoreMigrations(database.url);
|
|
const existingEmail = "[email protected]";
|
|
|
|
// Briefly enable signup on this same database to seed one real, pre-existing account, matching
|
|
// how a production database might have been seeded before signup was later disabled.
|
|
const seedRunning = await startConfiguredApi(database.url, { publicSignupEnabled: true });
|
|
try {
|
|
const seedSignUp = await signUp(
|
|
apiOrigin(seedRunning),
|
|
existingEmail,
|
|
"correct horse battery",
|
|
);
|
|
expect(seedSignUp.response.status).toBe(200);
|
|
} finally {
|
|
await seedRunning.shutdown().catch(() => undefined);
|
|
}
|
|
|
|
const running = await startConfiguredApi(database.url, { publicSignupEnabled: false });
|
|
try {
|
|
const origin = apiOrigin(running);
|
|
const signUpAttempt = (email: string) =>
|
|
fetch(`${origin}/api/auth/sign-up/email`, {
|
|
body: JSON.stringify({ email, name: "X", password: "correct horse battery staple" }),
|
|
headers: { "content-type": "application/json", origin: dashboardOrigin },
|
|
method: "POST",
|
|
});
|
|
|
|
const existingResponse = await signUpAttempt(existingEmail);
|
|
const newResponse = await signUpAttempt("[email protected]");
|
|
|
|
expect(existingResponse.status).toBe(400);
|
|
expect(newResponse.status).toBe(400);
|
|
expect(existingResponse.status).toBe(newResponse.status);
|
|
expect(await existingResponse.text()).toBe(await newResponse.text());
|
|
} finally {
|
|
await running.shutdown().catch(() => undefined);
|
|
await dropDatabase(database.name);
|
|
}
|
|
});
|
|
|
|
it("keeps sign-in and existing-session behavior fully functional for a pre-existing account while signup stays disabled", async () => {
|
|
const database = await createDisposableDatabase("signup_disabled_signin");
|
|
await applyCoreMigrations(database.url);
|
|
const email = "[email protected]";
|
|
const password = "correct horse battery staple";
|
|
|
|
const seedRunning = await startConfiguredApi(database.url, { publicSignupEnabled: true });
|
|
try {
|
|
const seedSignUp = await signUp(apiOrigin(seedRunning), email, password);
|
|
expect(seedSignUp.response.status).toBe(200);
|
|
} finally {
|
|
await seedRunning.shutdown().catch(() => undefined);
|
|
}
|
|
|
|
const running = await startConfiguredApi(database.url, { publicSignupEnabled: false });
|
|
try {
|
|
const origin = apiOrigin(running);
|
|
const signInResult = await signIn(origin, email, password);
|
|
expect(signInResult.response.status).toBe(200);
|
|
expect(signInResult.cookiePair).toBeDefined();
|
|
|
|
const { body } = await fetchPrincipal(origin, { cookie: signInResult.cookiePair as string });
|
|
expect(body).toMatchObject({ kind: "authenticated" });
|
|
|
|
const blockedSignUp = await fetch(`${origin}/api/auth/sign-up/email`, {
|
|
body: JSON.stringify({ email: "[email protected]", name: "X", password }),
|
|
headers: { "content-type": "application/json", origin: dashboardOrigin },
|
|
method: "POST",
|
|
});
|
|
expect(blockedSignUp.status).toBe(400);
|
|
} finally {
|
|
await running.shutdown().catch(() => undefined);
|
|
await dropDatabase(database.name);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe.sequential("live API — client-address resolution and auth rate-limit identity", () => {
|
|
let database: { name: string; url: string };
|
|
let directModeApi: RunningApi;
|
|
let directOrigin: string;
|
|
|
|
it("starts a direct-mode API (the safe default) against a freshly migrated database, with Better Auth's real unmodified rate-limit rule", async () => {
|
|
database = await createDisposableDatabase("client_address");
|
|
await applyCoreMigrations(database.url);
|
|
directModeApi = await startConfiguredApi(database.url, { rateLimitOverride: undefined });
|
|
directOrigin = apiOrigin(directModeApi);
|
|
expect(directModeApi.server.listening).toBe(true);
|
|
});
|
|
|
|
it("reproduces the original vulnerability's exact shape and proves it is closed: rotating spoofed X-Forwarded-For values never bypass the limiter in direct mode", async () => {
|
|
// Before this pass, Better Auth trusted a caller-controlled X-Forwarded-For directly: each
|
|
// distinct spoofed value looked like an unrelated client, so a caller could send an unlimited
|
|
// number of sign-in attempts merely by rotating the header on every request. Direct mode now
|
|
// resolves identity from the real socket peer only — the header is never consulted.
|
|
const peer = "127.0.0.21";
|
|
const first = await rawSignInAttempt(directOrigin, {
|
|
forwardedFor: "1.1.1.1",
|
|
localAddress: peer,
|
|
});
|
|
const second = await rawSignInAttempt(directOrigin, {
|
|
forwardedFor: "2.2.2.2",
|
|
localAddress: peer,
|
|
});
|
|
const third = await rawSignInAttempt(directOrigin, {
|
|
forwardedFor: "3.3.3.3",
|
|
localAddress: peer,
|
|
});
|
|
expect([first.status, second.status, third.status]).toEqual([401, 401, 401]);
|
|
|
|
// The fabricated value on this fourth request is new *again* — under the pre-fix behavior this
|
|
// would still look like a brand-new, never-before-seen client and would succeed. It must not.
|
|
const fourth = await rawSignInAttempt(directOrigin, {
|
|
forwardedFor: "4.4.4.4",
|
|
localAddress: peer,
|
|
});
|
|
expect(fourth.status).toBe(429);
|
|
// The limiter's own existing response semantics are preserved unmodified.
|
|
expect(fourth.headers["x-retry-after"]).toBeDefined();
|
|
expect(JSON.parse(fourth.body)).toMatchObject({ message: expect.any(String) });
|
|
|
|
// A request from the identical real peer with *no* forwarded header at all is equally blocked
|
|
// — proving the header was never what determined the bucket, in either direction.
|
|
const fifth = await rawSignInAttempt(directOrigin, { localAddress: peer });
|
|
expect(fifth.status).toBe(429);
|
|
});
|
|
|
|
it("gives distinct real peer addresses independent rate-limit buckets", async () => {
|
|
const peerA = "127.0.0.22";
|
|
const peerB = "127.0.0.23";
|
|
expect((await rawSignInAttempt(directOrigin, { localAddress: peerA })).status).toBe(401);
|
|
expect((await rawSignInAttempt(directOrigin, { localAddress: peerA })).status).toBe(401);
|
|
expect((await rawSignInAttempt(directOrigin, { localAddress: peerA })).status).toBe(401);
|
|
expect((await rawSignInAttempt(directOrigin, { localAddress: peerA })).status).toBe(429);
|
|
|
|
// An unrelated real peer's budget is completely untouched by peerA's exhaustion.
|
|
expect((await rawSignInAttempt(directOrigin, { localAddress: peerB })).status).toBe(401);
|
|
});
|
|
|
|
it("shuts down the direct-mode API", async () => {
|
|
await directModeApi.shutdown();
|
|
});
|
|
|
|
let proxyModeApi: RunningApi;
|
|
let proxyOrigin: string;
|
|
const trustedProxyPeer = "127.0.0.24";
|
|
|
|
it("starts a second API in explicit trusted-proxy mode against the same migrated database", async () => {
|
|
proxyModeApi = await startConfiguredApi(database.url, {
|
|
clientAddress: {
|
|
header: "x-forwarded-for",
|
|
mode: "trusted-proxy",
|
|
trustedProxies: [trustedProxyPeer],
|
|
},
|
|
rateLimitOverride: undefined,
|
|
});
|
|
proxyOrigin = apiOrigin(proxyModeApi);
|
|
});
|
|
|
|
it("honors a forwarded address only when it comes from the explicitly trusted proxy peer", async () => {
|
|
expect(
|
|
(
|
|
await rawSignInAttempt(proxyOrigin, {
|
|
forwardedFor: "203.0.113.50",
|
|
localAddress: trustedProxyPeer,
|
|
})
|
|
).status,
|
|
).toBe(401);
|
|
expect(
|
|
(
|
|
await rawSignInAttempt(proxyOrigin, {
|
|
forwardedFor: "203.0.113.50",
|
|
localAddress: trustedProxyPeer,
|
|
})
|
|
).status,
|
|
).toBe(401);
|
|
expect(
|
|
(
|
|
await rawSignInAttempt(proxyOrigin, {
|
|
forwardedFor: "203.0.113.50",
|
|
localAddress: trustedProxyPeer,
|
|
})
|
|
).status,
|
|
).toBe(401);
|
|
expect(
|
|
(
|
|
await rawSignInAttempt(proxyOrigin, {
|
|
forwardedFor: "203.0.113.50",
|
|
localAddress: trustedProxyPeer,
|
|
})
|
|
).status,
|
|
).toBe(429);
|
|
|
|
// A *different* forwarded client from the same trusted proxy gets its own independent bucket.
|
|
expect(
|
|
(
|
|
await rawSignInAttempt(proxyOrigin, {
|
|
forwardedFor: "203.0.113.51",
|
|
localAddress: trustedProxyPeer,
|
|
})
|
|
).status,
|
|
).toBe(401);
|
|
});
|
|
|
|
it("ignores a forwarded address claimed by an untrusted peer, using that peer's own real address instead", async () => {
|
|
// 203.0.113.50 is already exhausted above when legitimately forwarded by the trusted proxy —
|
|
// an untrusted peer merely claiming to *be* that address must not inherit its exhausted bucket,
|
|
// and must not be able to choose an arbitrary bucket of its own by forging the header either.
|
|
const untrustedPeer = "127.0.0.25";
|
|
const response = await rawSignInAttempt(proxyOrigin, {
|
|
forwardedFor: "203.0.113.50",
|
|
localAddress: untrustedPeer,
|
|
});
|
|
expect(response.status).toBe(401);
|
|
});
|
|
|
|
it("walks a chained forwarded header right-to-left, skipping the trusted-proxy hop to find the genuine client", async () => {
|
|
const chained = `203.0.113.60, ${trustedProxyPeer}`;
|
|
expect(
|
|
(
|
|
await rawSignInAttempt(proxyOrigin, {
|
|
forwardedFor: chained,
|
|
localAddress: trustedProxyPeer,
|
|
})
|
|
).status,
|
|
).toBe(401);
|
|
expect(
|
|
(
|
|
await rawSignInAttempt(proxyOrigin, {
|
|
forwardedFor: chained,
|
|
localAddress: trustedProxyPeer,
|
|
})
|
|
).status,
|
|
).toBe(401);
|
|
expect(
|
|
(
|
|
await rawSignInAttempt(proxyOrigin, {
|
|
forwardedFor: chained,
|
|
localAddress: trustedProxyPeer,
|
|
})
|
|
).status,
|
|
).toBe(401);
|
|
expect(
|
|
(
|
|
await rawSignInAttempt(proxyOrigin, {
|
|
forwardedFor: chained,
|
|
localAddress: trustedProxyPeer,
|
|
})
|
|
).status,
|
|
).toBe(429);
|
|
});
|
|
|
|
it("fails safely with a sanitized 503 on a malformed forwarded chain from a trusted proxy, never falling back to a shared bucket", async () => {
|
|
const response = await rawSignInAttempt(proxyOrigin, {
|
|
forwardedFor: "not-an-ip",
|
|
localAddress: trustedProxyPeer,
|
|
});
|
|
expect(response.status).toBe(503);
|
|
expect(JSON.parse(response.body)).toEqual({ code: "CLIENT_ADDRESS_UNRESOLVED" });
|
|
expect(response.headers["cache-control"]).toBe("no-store");
|
|
});
|
|
|
|
it("shuts down the trusted-proxy-mode API and drops the shared database", async () => {
|
|
await proxyModeApi.shutdown();
|
|
await dropDatabase(database.name);
|
|
});
|
|
});
|
|
|
|
describe.sequential("live API — Node header/request timeouts and client errors", () => {
|
|
/**
|
|
* Drives one raw, deliberately-malformed or deliberately-slow byte sequence through a real
|
|
* listening API and returns everything observable at the transport boundary, plus everything the
|
|
* *real* production logging seam (`console.error`, which `apps/api/src/server.ts` writes its
|
|
* sanitized operational line through) actually emitted while it happened.
|
|
*
|
|
* This is the only shape of proof that counts for the timeout-observability claim: no event is
|
|
* manually emitted, no logger is called directly, and nothing asserts merely that a listener was
|
|
* registered — a real socket is opened against a real server, real bytes are written, and the
|
|
* real Node transport is left to do whatever it genuinely does.
|
|
*/
|
|
async function driveRawConnection(
|
|
label: string,
|
|
write: (socket: net.Socket) => void,
|
|
timeouts: Partial<NodeServerTimeouts> = {
|
|
connectionsCheckingIntervalMs: 100,
|
|
headersTimeoutMs: 200,
|
|
keepAliveTimeoutMs: 200,
|
|
requestTimeoutMs: 300,
|
|
},
|
|
): Promise<{ destroyed: boolean; logs: string[]; received: string }> {
|
|
const database = await createDisposableDatabase(label);
|
|
await applyCoreMigrations(database.url);
|
|
const config = createApiRuntimeConfig(
|
|
{
|
|
databaseUrl: database.url,
|
|
host: "127.0.0.1",
|
|
identity: {
|
|
baseUrl: identityBaseUrl,
|
|
publicSignupEnabled: false,
|
|
secret: "integration-test-secret-0123456789abcdefgh",
|
|
trustedOrigins: [identityBaseUrl, dashboardOrigin],
|
|
},
|
|
port: 0,
|
|
},
|
|
// Development configuration, for the same reason `startConfiguredApi` documents: this suite
|
|
// connects to the disposable plaintext loopback PostgreSQL, which production configuration
|
|
// never permits. Nothing about the transport-level timeout behavior under test depends on it.
|
|
{ environment: "development" },
|
|
);
|
|
// Deliberately tiny, test-only timeouts — production composition (server.ts's `main`) never
|
|
// sets this override — so a real timeout is observed deterministically in well under a second
|
|
// rather than by waiting out the production values.
|
|
const running = await startApi(config, { nodeServerTimeoutOverride: timeouts });
|
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
try {
|
|
const address = running.server.address();
|
|
if (!address || typeof address !== "object") {
|
|
throw harnessError("The API integration server has no bound address.");
|
|
}
|
|
|
|
const socket = net.connect({ host: "127.0.0.1", port: address.port });
|
|
await new Promise<void>((resolve, reject) => {
|
|
socket.once("connect", () => resolve());
|
|
socket.once("error", reject);
|
|
});
|
|
const receivedChunks: Buffer[] = [];
|
|
// A `net.Socket` stays paused (and never surfaces the peer's eventual FIN as `close`) until
|
|
// something actively consumes its readable side — attaching this listener is what makes the
|
|
// socket start flowing, not merely a way to capture the response for the assertions below.
|
|
socket.on("data", (chunk) => receivedChunks.push(chunk as Buffer));
|
|
socket.on("error", () => undefined);
|
|
const closed = new Promise<void>((resolve) => socket.once("close", resolve));
|
|
|
|
write(socket);
|
|
await closed;
|
|
|
|
const logs = errorSpy.mock.calls.flat().map((value) => String(value));
|
|
return {
|
|
destroyed: socket.destroyed,
|
|
logs,
|
|
received: Buffer.concat(receivedChunks).toString("utf8"),
|
|
};
|
|
} finally {
|
|
errorSpy.mockRestore();
|
|
await running.shutdown().catch(() => undefined);
|
|
await dropDatabase(database.name);
|
|
}
|
|
}
|
|
|
|
/** Every status line in a response stream — more than one would mean the platform corrupted the
|
|
* connection by writing a second response after Node (or a handler) already wrote one. */
|
|
function statusLines(received: string): readonly string[] {
|
|
return received.match(/^HTTP\/1\.1 \d{3}[^\r\n]*/gmu) ?? [];
|
|
}
|
|
|
|
/** The exact operational line `apps/api/src/server.ts` promises for a transport-level client
|
|
* failure — no request path, query, body, cookie, authorization value, client-address header,
|
|
* forwarding chain, raw peer address, stack, or parser payload may appear anywhere in the logs. */
|
|
function expectSanitizedTransportLog(logs: readonly string[]): void {
|
|
expect(logs).toHaveLength(1);
|
|
const emitted = logs.join("\n");
|
|
for (const forbidden of [
|
|
"/api/v1/principal",
|
|
"/api/auth",
|
|
"127.0.0.1",
|
|
"Cookie",
|
|
"cookie",
|
|
"Authorization",
|
|
"authorization",
|
|
"x-forwarded-for",
|
|
"x-ekklesia-client-address",
|
|
"partial",
|
|
"Parse Error",
|
|
"HPE_",
|
|
"ERR_HTTP_REQUEST_TIMEOUT",
|
|
"at ",
|
|
]) {
|
|
expect(emitted).not.toContain(forbidden);
|
|
}
|
|
}
|
|
|
|
it("answers a never-completed header sequence with exactly one 408, closes the socket, invokes no handler, and emits exactly one sanitized operational log", async () => {
|
|
const { destroyed, logs, received } = await driveRawConnection(
|
|
"node_timeouts",
|
|
// An incomplete request: a request line and one header, deliberately never terminated by the
|
|
// blank line that would mark the end of the headers, and no further data ever sent.
|
|
(socket) => socket.write("GET /api/v1/principal HTTP/1.1\r\nHost: 127.0.0.1\r\n"),
|
|
);
|
|
|
|
expect(received).toBe("HTTP/1.1 408 Request Timeout\r\nConnection: close\r\n\r\n");
|
|
expect(statusLines(received)).toHaveLength(1);
|
|
// A handler would have produced a JSON principal body; Node answered before routing.
|
|
expect(received).not.toContain("anonymous");
|
|
expect(destroyed).toBe(true);
|
|
expectSanitizedTransportLog(logs);
|
|
});
|
|
|
|
it("answers a never-completed body with exactly one 408, closes the socket, invokes no auth logic, and emits exactly one sanitized operational log", async () => {
|
|
const { destroyed, logs, received } = await driveRawConnection(
|
|
"node_timeouts_body",
|
|
// Complete, well-formed headers declaring a body that never actually arrives.
|
|
(socket) =>
|
|
socket.write(
|
|
"POST /api/auth/sign-in/email HTTP/1.1\r\n" +
|
|
"Host: 127.0.0.1\r\n" +
|
|
"Content-Type: application/json\r\n" +
|
|
"Content-Length: 1000\r\n" +
|
|
"\r\n" +
|
|
'{"partial":',
|
|
),
|
|
{
|
|
connectionsCheckingIntervalMs: 100,
|
|
headersTimeoutMs: 300,
|
|
keepAliveTimeoutMs: 200,
|
|
requestTimeoutMs: 400,
|
|
},
|
|
);
|
|
|
|
expect(received).toBe("HTTP/1.1 408 Request Timeout\r\nConnection: close\r\n\r\n");
|
|
expect(statusLines(received)).toHaveLength(1);
|
|
expect(received).not.toContain("Set-Cookie");
|
|
expect(destroyed).toBe(true);
|
|
expectSanitizedTransportLog(logs);
|
|
});
|
|
|
|
it("preserves Node's own 400 for a malformed request line, without logging the malformed bytes", async () => {
|
|
const { destroyed, logs, received } = await driveRawConnection("node_bad_request", (socket) =>
|
|
socket.write("!!!!NOT-HTTP-AT-ALL!!!! /secret-path\r\n\r\n"),
|
|
);
|
|
|
|
expect(received).toBe("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n");
|
|
expect(statusLines(received)).toHaveLength(1);
|
|
expect(destroyed).toBe(true);
|
|
expectSanitizedTransportLog(logs);
|
|
// The attacker-controlled bytes must never reach the log.
|
|
expect(logs.join("\n")).not.toContain("NOT-HTTP-AT-ALL");
|
|
expect(logs.join("\n")).not.toContain("secret-path");
|
|
});
|
|
|
|
it("preserves Node's own 431 for an oversized header block, without logging the header contents", async () => {
|
|
const { destroyed, logs, received } = await driveRawConnection(
|
|
"node_header_overflow",
|
|
(socket) =>
|
|
socket.write(
|
|
`GET /api/v1/principal HTTP/1.1\r\nHost: 127.0.0.1\r\nX-Overflow: ${"z".repeat(20_000)}\r\n\r\n`,
|
|
),
|
|
);
|
|
|
|
expect(received).toBe(
|
|
"HTTP/1.1 431 Request Header Fields Too Large\r\nConnection: close\r\n\r\n",
|
|
);
|
|
expect(statusLines(received)).toHaveLength(1);
|
|
expect(destroyed).toBe(true);
|
|
expectSanitizedTransportLog(logs);
|
|
expect(logs.join("\n")).not.toContain("zzz");
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Pass 4: shutdown is finite even when HTTP work does not settle.
|
|
*
|
|
* These drive a real bound listener and a real socket rather than a mock, because the behavior
|
|
* under test is what Node's own `server.close()` does and does not do. Every socket, server, and
|
|
* disposable database is closed in `finally`, and each scenario carries its own outer deadline so
|
|
* a regression fails rather than hangs.
|
|
*/
|
|
describe.sequential("live API — module lifecycle governs what actually runs", () => {
|
|
/** Writes one explicit enablement decision through the real table, before the API starts —
|
|
* exactly what `pnpm modules:disable` or a prior `PUT` would have left behind. Nothing in this
|
|
* suite reaches into the store through a double. */
|
|
async function storeEnablement(url: string, moduleId: string, enabled: boolean): Promise<void> {
|
|
const client = await openAdministrativeClient(url);
|
|
try {
|
|
await safeQuery(
|
|
client,
|
|
`insert into ekklesia_core_module_state (module_id, enabled) values ($1, $2)
|
|
on conflict (module_id) do update set enabled = excluded.enabled`,
|
|
[moduleId, enabled],
|
|
);
|
|
} finally {
|
|
await safeEnd(client);
|
|
}
|
|
}
|
|
|
|
it("never imports, registers, starts, or mounts a disabled module, and still runs its neighbours", async () => {
|
|
const modulesDirectory = await createFixtureModulesDirectory();
|
|
const recorder = createEventRecorder();
|
|
await createFixtureModule(modulesDirectory, "calendar", recorder, {
|
|
permissions: [
|
|
{ baseline: "public", description: "Read calendar events.", id: "calendar.events.read" },
|
|
{ baseline: "public", description: "View calendar state.", id: "calendar.state.view" },
|
|
],
|
|
});
|
|
await createFixtureModule(modulesDirectory, "announcements", recorder, {
|
|
permissions: [
|
|
{
|
|
baseline: "public",
|
|
description: "Read announcements.",
|
|
id: "announcements.events.read",
|
|
},
|
|
{
|
|
baseline: "public",
|
|
description: "View announcement state.",
|
|
id: "announcements.state.view",
|
|
},
|
|
],
|
|
});
|
|
|
|
const database = await createDisposableDatabase("lifecycle_off");
|
|
await applyCoreMigrations(database.url);
|
|
await storeEnablement(database.url, "announcements", false);
|
|
|
|
const running = await startConfiguredApi(database.url, {
|
|
modulesDirectoryOverride: modulesDirectory,
|
|
});
|
|
const realtimeAbort = new AbortController();
|
|
let calendarReader: ReadableStreamDefaultReader<Uint8Array> | undefined;
|
|
try {
|
|
const origin = apiOrigin(running);
|
|
|
|
// The enabled module really did run: its markers are present, and its route answers.
|
|
expect(recorder.events).toContain("calendar:register");
|
|
expect(recorder.events).toContain("calendar:start");
|
|
const calendarRoute = await fetch(`${origin}/api/v1/modules/calendar/events/abc`);
|
|
expect(calendarRoute.status).toBe(200);
|
|
await withTransportDeadline(
|
|
(async () => {
|
|
const calendarRealtime = await fetch(`${origin}/api/v1/module-realtime/calendar/state`, {
|
|
signal: realtimeAbort.signal,
|
|
});
|
|
expect(calendarRealtime.status).toBe(200);
|
|
calendarReader = calendarRealtime.body?.getReader();
|
|
const calendarSnapshot = await calendarReader?.read();
|
|
expect(new TextDecoder().decode(calendarSnapshot?.value)).toContain("event: snapshot");
|
|
})(),
|
|
() => realtimeAbort.abort(),
|
|
);
|
|
|
|
// The disabled module ran nothing at all. Not "registered but skipped", not "started and
|
|
// gated per request" — its compiled entrypoint was never imported, so no hook could run.
|
|
expect(recorder.events).not.toContain("announcements:register");
|
|
expect(recorder.events).not.toContain("announcements:start");
|
|
expect(recorder.events.filter((event) => event.startsWith("announcements:"))).toEqual([]);
|
|
|
|
// No route was ever mounted, so the platform's ordinary 404 answers — there is no per-request
|
|
// "is this module enabled?" check anywhere, because there is nothing mounted to check.
|
|
const disabledRoute = await fetch(`${origin}/api/v1/modules/announcements/events/abc`);
|
|
expect(disabledRoute.status).toBe(404);
|
|
expect(await disabledRoute.json()).toEqual({ code: "NOT_FOUND" });
|
|
const disabledRealtime = await fetch(`${origin}/api/v1/module-realtime/announcements/state`);
|
|
expect(disabledRealtime.status).toBe(404);
|
|
expect(await disabledRealtime.json()).toEqual({ code: "NOT_FOUND" });
|
|
|
|
// Shutdown never invokes a stop hook for a module that never started.
|
|
const streamClosed = calendarReader?.read();
|
|
const shutdown = running.shutdown();
|
|
await expect(
|
|
withTransportDeadline(
|
|
streamClosed ?? Promise.reject(harnessError("The realtime stream was unavailable.")),
|
|
() => realtimeAbort.abort(),
|
|
),
|
|
).resolves.toEqual({ done: true, value: undefined });
|
|
await shutdown;
|
|
expect(recorder.events).toContain("calendar:stop");
|
|
expect(recorder.events).not.toContain("announcements:stop");
|
|
} finally {
|
|
realtimeAbort.abort();
|
|
await calendarReader?.cancel().catch(() => undefined);
|
|
await running.shutdown().catch(() => undefined);
|
|
await dropDatabase(database.name);
|
|
}
|
|
});
|
|
|
|
it("blocks a dependent of a disabled required dependency, and explains why over HTTP", async () => {
|
|
const modulesDirectory = await createFixtureModulesDirectory();
|
|
const recorder = createEventRecorder();
|
|
await createFixtureModule(modulesDirectory, "storage", recorder);
|
|
await createFixtureModule(modulesDirectory, "announcements", recorder, {
|
|
dependencies: { modules: { required: { storage: "^0.1.0" } } },
|
|
permissions: [
|
|
{
|
|
baseline: "public",
|
|
description: "View announcement state.",
|
|
id: "announcements.state.view",
|
|
},
|
|
],
|
|
});
|
|
|
|
const database = await createDisposableDatabase("lifecycle_block");
|
|
await applyCoreMigrations(database.url);
|
|
await storeEnablement(database.url, "storage", false);
|
|
|
|
const running = await startConfiguredApi(database.url, {
|
|
modulesDirectoryOverride: modulesDirectory,
|
|
});
|
|
try {
|
|
const origin = apiOrigin(running);
|
|
// Neither ran: the disabled dependency by decision, the dependent because starting it would
|
|
// violate its own required-dependency contract.
|
|
expect(recorder.events).toEqual([]);
|
|
|
|
const owner = await signUpPlatformOwner(origin, database.url);
|
|
const lifecycle = await fetch(`${origin}/api/v1/module-lifecycle`, {
|
|
headers: { cookie: owner.cookie, origin: dashboardOrigin },
|
|
});
|
|
expect(lifecycle.status).toBe(200);
|
|
const body = (await lifecycle.json()) as {
|
|
readonly modules: readonly {
|
|
readonly id: string;
|
|
readonly configured: { readonly enabled: boolean; readonly explicit: boolean };
|
|
readonly runtime: {
|
|
readonly state: string;
|
|
readonly reason?: string;
|
|
readonly dependency?: { readonly kind: string; readonly moduleId: string };
|
|
};
|
|
readonly restartRequired: boolean;
|
|
}[];
|
|
};
|
|
|
|
expect(body.modules).toEqual([
|
|
{
|
|
configured: { enabled: false, explicit: true },
|
|
id: "storage",
|
|
restartRequired: false,
|
|
runtime: { state: "disabled" },
|
|
version: "0.1.0",
|
|
},
|
|
{
|
|
configured: { enabled: true, explicit: false },
|
|
id: "announcements",
|
|
restartRequired: false,
|
|
runtime: {
|
|
dependency: { kind: "module", moduleId: "storage" },
|
|
reason: "required_dependency_disabled",
|
|
state: "blocked",
|
|
},
|
|
version: "0.1.0",
|
|
},
|
|
]);
|
|
// The operator's question — "why is this module unavailable?" — answered without a log line,
|
|
// and without a filesystem path anywhere in the payload.
|
|
const raw = JSON.stringify(body);
|
|
expect(raw).not.toContain(modulesDirectory);
|
|
expect(raw).not.toContain("dist/");
|
|
const blockedRealtime = await fetch(`${origin}/api/v1/module-realtime/announcements/state`);
|
|
expect(blockedRealtime.status).toBe(404);
|
|
expect(await blockedRealtime.json()).toEqual({ code: "NOT_FOUND" });
|
|
} finally {
|
|
await running.shutdown().catch(() => undefined);
|
|
await dropDatabase(database.name);
|
|
}
|
|
});
|
|
|
|
it("applies a change made over HTTP only at the next start, and says so in the meantime", async () => {
|
|
const modulesDirectory = await createFixtureModulesDirectory();
|
|
const recorder = createEventRecorder();
|
|
await createFixtureModule(modulesDirectory, "calendar", recorder, {
|
|
permissions: [
|
|
{ baseline: "public", description: "Read calendar events.", id: "calendar.events.read" },
|
|
],
|
|
});
|
|
|
|
const database = await createDisposableDatabase("lifecycle_restart");
|
|
await applyCoreMigrations(database.url);
|
|
|
|
const first = await startConfiguredApi(database.url, {
|
|
modulesDirectoryOverride: modulesDirectory,
|
|
});
|
|
try {
|
|
const origin = apiOrigin(first);
|
|
const owner = await signUpPlatformOwner(origin, database.url);
|
|
|
|
const mutation = await fetch(`${origin}/api/v1/module-lifecycle/calendar/enabled`, {
|
|
body: JSON.stringify({ enabled: false }),
|
|
headers: {
|
|
"content-type": "application/json",
|
|
cookie: owner.cookie,
|
|
origin: dashboardOrigin,
|
|
},
|
|
method: "PUT",
|
|
});
|
|
expect(mutation.status).toBe(200);
|
|
expect(await mutation.json()).toEqual({
|
|
configured: { enabled: false, explicit: true },
|
|
id: "calendar",
|
|
restartRequired: true,
|
|
// Honest: this process really is still serving that module.
|
|
runtime: { state: "running" },
|
|
version: "0.1.0",
|
|
});
|
|
|
|
// And it demonstrably still is — nothing was hot-unloaded to make the change feel immediate.
|
|
expect((await fetch(`${origin}/api/v1/modules/calendar/events/abc`)).status).toBe(200);
|
|
} finally {
|
|
await first.shutdown().catch(() => undefined);
|
|
}
|
|
|
|
recorder.events.length = 0;
|
|
const second = await startConfiguredApi(database.url, {
|
|
modulesDirectoryOverride: modulesDirectory,
|
|
});
|
|
try {
|
|
const origin = apiOrigin(second);
|
|
// The next start applies the stored decision: nothing imported, nothing mounted.
|
|
expect(recorder.events).toEqual([]);
|
|
expect((await fetch(`${origin}/api/v1/modules/calendar/events/abc`)).status).toBe(404);
|
|
} finally {
|
|
await second.shutdown().catch(() => undefined);
|
|
await dropDatabase(database.name);
|
|
}
|
|
});
|
|
|
|
it("keeps a disabled module's UI out of the inventory and out of Dashboard navigation", async () => {
|
|
const modulesDirectory = await createFixtureModulesDirectory();
|
|
await createUiModule(
|
|
modulesDirectory,
|
|
"shown",
|
|
"1.0.0",
|
|
'export default { id: "shown", version: "1.0.0", mount() {} };\n',
|
|
);
|
|
await createUiModule(
|
|
modulesDirectory,
|
|
"hidden",
|
|
"1.0.0",
|
|
'export default { id: "hidden", version: "1.0.0", mount() {} };\n',
|
|
);
|
|
|
|
const database = await createDisposableDatabase("lifecycle_ui");
|
|
await applyCoreMigrations(database.url);
|
|
await storeEnablement(database.url, "hidden", false);
|
|
|
|
const running = await startConfiguredApi(database.url, {
|
|
modulesDirectoryOverride: modulesDirectory,
|
|
});
|
|
try {
|
|
const origin = apiOrigin(running);
|
|
const owner = await signUpPlatformOwner(origin, database.url);
|
|
const headers = { cookie: owner.cookie, origin: dashboardOrigin };
|
|
|
|
const inventory = (await (await fetch(`${origin}/api/v1/module-ui`, { headers })).json()) as {
|
|
readonly modules: readonly { readonly id: string }[];
|
|
};
|
|
const navigation = (await (
|
|
await fetch(`${origin}/api/v1/dashboard/navigation`, { headers })
|
|
).json()) as {
|
|
readonly ungrouped: readonly { readonly module: { readonly id: string } }[];
|
|
};
|
|
|
|
expect(inventory.modules.map((entry) => entry.id)).toEqual(["shown"]);
|
|
expect(navigation.ungrouped.map((entry) => entry.module.id)).toEqual(["shown"]);
|
|
|
|
// The artifact itself is unreachable too, even to a caller that knows both halves of its
|
|
// identity — publication is derived from the eligible set, not from the file's existence.
|
|
const artifact = await fetch(`${origin}/api/v1/module-ui/hidden/1.0.0`);
|
|
expect(artifact.status).toBe(404);
|
|
expect((await fetch(`${origin}/api/v1/module-ui/shown/1.0.0`)).status).toBe(200);
|
|
} finally {
|
|
await running.shutdown().catch(() => undefined);
|
|
await dropDatabase(database.name);
|
|
}
|
|
});
|
|
|
|
it("still discovers a disabled module's migrations: deployed schema ownership is not runtime state", async () => {
|
|
const modulesDirectory = await createFixtureModulesDirectory();
|
|
const recorder = createEventRecorder();
|
|
await createFixtureModule(modulesDirectory, "calendar", recorder);
|
|
|
|
const database = await createDisposableDatabase("lifecycle_migrations");
|
|
await applyCoreMigrations(database.url);
|
|
await storeEnablement(database.url, "calendar", false);
|
|
|
|
// Migration discovery is offline and reads the deployed inventory, never the enablement table.
|
|
// A disabled module's tables must still be migrated, or enabling it later would need a schema
|
|
// change in the middle of a restart.
|
|
const aggregate = await discoverAllMigrations({
|
|
coreMigrationsDirectory,
|
|
moduleDiscovery: {
|
|
moduleSdkVersion: "0.1.0",
|
|
modulesDirectory,
|
|
platformVersion: "0.1.0",
|
|
},
|
|
});
|
|
expect(aggregate.discoveredModules.map((module) => module.manifest.id)).toEqual(["calendar"]);
|
|
});
|
|
});
|
|
|
|
describe.sequential("live API — bounded HTTP drain and shutdown", () => {
|
|
async function withDeadline<T>(operation: Promise<T>, timeoutMs: number): Promise<T> {
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
try {
|
|
return await Promise.race([
|
|
operation,
|
|
new Promise<never>((_resolve, reject) => {
|
|
timer = setTimeout(
|
|
() => reject(harnessError("shutdown did not settle in time")),
|
|
timeoutMs,
|
|
);
|
|
}),
|
|
]);
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
it("serves a real request and then drains cleanly, well inside the deadline", async () => {
|
|
const database = await createDisposableDatabase("drain_ok");
|
|
await applyCoreMigrations(database.url);
|
|
const running = await startConfiguredApi(database.url, { drainTimeoutMsOverride: 5_000 });
|
|
try {
|
|
const origin = apiOrigin(running);
|
|
const response = await fetch(`${origin}/api/v1/principal`);
|
|
expect(response.status).toBe(200);
|
|
expect(await response.json()).toEqual({ kind: "anonymous" });
|
|
|
|
// Nothing is stalled, so the graceful path completes and shutdown reports success — the
|
|
// drain deadline is never reached and no connection is force-closed.
|
|
await expect(withDeadline(running.shutdown(), 20_000)).resolves.toBeUndefined();
|
|
|
|
// The listener really is closed afterwards.
|
|
await expect(fetch(`${origin}/api/v1/principal`)).rejects.toBeTruthy();
|
|
} finally {
|
|
await running.shutdown().catch(() => undefined);
|
|
await dropDatabase(database.name);
|
|
}
|
|
});
|
|
|
|
it("lets a handler that has already entered finish, and closes its transport before module stop", async () => {
|
|
// A real handler gate, not an inference from the connection count: the fixture module's route
|
|
// handler announces that it has *entered*, then blocks until this test releases it. Shutdown
|
|
// therefore begins at a point where the handler is provably inside user code, and the ordering
|
|
// claim below is an assertion about recorded events rather than about timing.
|
|
const modulesDirectory = await createFixtureModulesDirectory();
|
|
const recorder = createEventRecorder();
|
|
const gate = createHandlerGate();
|
|
await createGatedRouteModule(modulesDirectory, "drainorder", recorder, gate);
|
|
|
|
const database = await createDisposableDatabase("drain_order");
|
|
await applyCoreMigrations(database.url);
|
|
const running = await startConfiguredApi(database.url, {
|
|
drainTimeoutMsOverride: 20_000,
|
|
modulesDirectoryOverride: modulesDirectory,
|
|
});
|
|
const address = running.server.address();
|
|
if (!address || typeof address !== "object") {
|
|
throw harnessError("The API integration server has no bound address.");
|
|
}
|
|
const socket = net.connect({ host: "127.0.0.1", port: address.port });
|
|
let shutdown: Promise<void> | undefined;
|
|
try {
|
|
const responseBytes: Buffer[] = [];
|
|
const responseClosed = new Promise<void>((resolve) => {
|
|
socket.once("close", () => resolve());
|
|
});
|
|
socket.on("data", (chunk: Buffer) => {
|
|
responseBytes.push(chunk);
|
|
});
|
|
await new Promise<void>((resolve, reject) => {
|
|
socket.once("error", reject);
|
|
socket.write(
|
|
`GET /api/v1/modules/drainorder/gated HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n`,
|
|
() => resolve(),
|
|
);
|
|
});
|
|
|
|
// Bounded wait on the handler's own signal — no sleep, no proxy for "entered".
|
|
await withDeadline(gate.entered, 20_000);
|
|
expect(recorder.events).toContain("drainorder:handler-entered");
|
|
|
|
// Shutdown starts while the handler is still inside user code.
|
|
shutdown = running.shutdown();
|
|
// Release it, then require the graceful path to complete.
|
|
gate.release();
|
|
await expect(withDeadline(shutdown, 30_000)).resolves.toBeUndefined();
|
|
|
|
// The response really was served, in full, rather than force-closed mid-flight.
|
|
await withDeadline(responseClosed, 10_000);
|
|
const raw = Buffer.concat(responseBytes).toString("utf8");
|
|
expect(raw).toContain("HTTP/1.1 200");
|
|
expect(raw).toContain('"gated":true');
|
|
|
|
// Explicit ordering: the already-running command can publish its committed state while HTTP
|
|
// drains, then the handler finishes and only afterward does its module stop.
|
|
const published = recorder.events.indexOf("drainorder:handler-published");
|
|
const finished = recorder.events.indexOf("drainorder:handler-finished");
|
|
const stopped = recorder.events.indexOf("drainorder:stop");
|
|
expect(published).toBeGreaterThanOrEqual(0);
|
|
expect(finished).toBeGreaterThanOrEqual(0);
|
|
expect(stopped).toBeGreaterThanOrEqual(0);
|
|
expect(published).toBeLessThan(finished);
|
|
expect(finished).toBeLessThan(stopped);
|
|
} finally {
|
|
gate.release();
|
|
if (shutdown) {
|
|
await shutdown.catch(() => undefined);
|
|
}
|
|
socket.destroy();
|
|
await running.shutdown().catch(() => undefined);
|
|
await dropDatabase(database.name);
|
|
}
|
|
});
|
|
|
|
it("force-closes a stalled connection at the drain deadline and still reaches pool closure", async () => {
|
|
const database = await createDisposableDatabase("drain_forced");
|
|
await applyCoreMigrations(database.url);
|
|
// A short, test-only drain deadline; production keeps HTTP_DRAIN_TIMEOUT_MS unchanged.
|
|
const running = await startConfiguredApi(database.url, { drainTimeoutMsOverride: 250 });
|
|
const address = running.server.address();
|
|
if (!address || typeof address !== "object") {
|
|
throw harnessError("The API integration server has no bound address.");
|
|
}
|
|
const socket = net.connect({ host: "127.0.0.1", port: address.port });
|
|
try {
|
|
// A real, deliberately incomplete request: headers are never finished, so Node keeps the
|
|
// connection open and `server.close()` alone would wait indefinitely.
|
|
await new Promise<void>((resolve, reject) => {
|
|
socket.once("error", reject);
|
|
socket.on("data", () => undefined);
|
|
socket.write("GET /api/v1/principal HTTP/1.1\r\nHost: 127.0.0.1\r\n", () => resolve());
|
|
});
|
|
|
|
const closed = new Promise<void>((resolve) => {
|
|
socket.once("close", () => resolve());
|
|
});
|
|
|
|
let captured: unknown;
|
|
try {
|
|
await withDeadline(running.shutdown(), 20_000);
|
|
} catch (error) {
|
|
captured = error;
|
|
}
|
|
|
|
// A forced drain is a genuine shutdown failure, never reported as success.
|
|
expect(captured).toMatchObject({ drainTimedOut: true, name: "ApiShutdownError" });
|
|
// ...and it still carries nothing about the request, socket, or database.
|
|
const publicText = `${String(captured)}\n${(captured as Error).stack ?? ""}`;
|
|
expect(publicText).not.toContain("127.0.0.1");
|
|
expect(publicText).not.toContain(database.name);
|
|
// The connection was actually closed by the platform, not left dangling.
|
|
await withDeadline(closed, 10_000);
|
|
} finally {
|
|
socket.destroy();
|
|
await running.shutdown().catch(() => undefined);
|
|
await dropDatabase(database.name);
|
|
}
|
|
});
|
|
|
|
it("shares one memoized shutdown across concurrent callers", async () => {
|
|
const database = await createDisposableDatabase("drain_once");
|
|
await applyCoreMigrations(database.url);
|
|
const running = await startConfiguredApi(database.url, { drainTimeoutMsOverride: 5_000 });
|
|
try {
|
|
const [first, second, third] = await withDeadline(
|
|
Promise.all([running.shutdown(), running.shutdown(), running.shutdown()]),
|
|
20_000,
|
|
);
|
|
expect([first, second, third]).toEqual([undefined, undefined, undefined]);
|
|
// A later caller observes the same completed result rather than restarting cleanup.
|
|
await expect(running.shutdown()).resolves.toBeUndefined();
|
|
} finally {
|
|
await dropDatabase(database.name);
|
|
}
|
|
});
|
|
});
|