Add GET /api/v1/health/live and GET /api/v1/health/ready per ADR 0044.
- live returns a fixed 200 {"status":"live"} whenever the process serves.
- ready returns 200 {"status":"ready"} only when module startup has
completed (and shutdown has not begun) and a database ping succeeds
within 2s; otherwise a fixed 503 {"code":"NOT_READY"}.
- The ping runs on the shared pool as one simple-protocol message with
a transaction-local statement_timeout, so PostgreSQL cancels a stalled
statement; the pool discards the connection on failure.
- At most one ping is ever unsettled: probes share its outcome and, once
its deadline passes, answer 503 immediately until it settles.
- Both routes set Cache-Control: no-store, accept GET only (405 with
Allow: GET otherwise, HEAD included), never resolve a principal, and
are mounted in app.ts beside, never inside, /api/v1/modules/.
- createApiApp gains a required `health` option; startApi supplies it
and clears module readiness when shutdown begins.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
4040 lines
162 KiB
TypeScript
4040 lines
162 KiB
TypeScript
import { randomBytes } from "node:crypto";
|
|
import { mkdir, mkdtemp, 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 { createApiRuntimeConfig } from "../src/config.js";
|
|
import { createApiDatabase } from "../src/database.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 — 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);
|
|
}
|
|
});
|
|
});
|