Phase 6C pass 2. Every link in the module UI chain existed except the one that closes it: a module could be authored, built, validated, published at a browser URL, and loaded by a generic loader — and nothing in Ekklesia did any of it. There was no browser application at all. Two pieces were missing. A browser could not discover what to mount, and it must not work that out for itself. GET /api/v1/module-ui is the inventory: per publishable module, its id and version, the server-produced artifact URL, the server-produced root of the module's own API, and the subset of that module's declared permissions the caller holds. Five fields, each because building a ModuleUiHostContext needs it — no filesystem paths, and no name/icon/folder/order, because navigation metadata is a design that does not exist yet. Its module set is the ADR 0025 publication registry rather than a rescan, so the inventory and the artifact route cannot disagree. It requires a new Core permission, core.modules.ui.read (baseline authenticated), because it is precisely the enumeration the anonymous artifact route deliberately withholds. Both URLs are server-produced. moduleUiArtifactUrl already existed; moduleApiBaseUrl is now exported and moduleRoutePath builds on it, so each URL shape has exactly one author. Without it the shell would hardcode /api/v1/modules/<id> — the prefix ADR 0022 forbids a module UI from spelling, reappearing in the host instead. surfaces/shell (@ekklesia/shell) is the repository's first surface: a small React application built to static files by its own esbuild script. It resolves the principal, fetches the inventory, builds the host context entirely from server-derived data, and hands the artifact URL to @ekklesia/module-ui-host. One frame per selection — keyed by module identity — defeats the stale-load race; the host owns the container, the loading state, and every failure, so a broken module costs its own panel rather than the shell. The shell statically imports no module, and both levels are checked: no source import may name a module, and the emitted bundle must contain exactly one dynamic import (the loader's) with no module id, no /api/v1/modules, no eval, and no blob URL. Permission hints come from Core's own evaluator and stay presentation-only — the end-to-end test has a UI holding no grant call its API anyway and still receive 403. Surfaces may now depend only on browser-safe shared roles; @ekklesia/contracts is deliberately excluded, since its barrel pulls Drizzle's types and Node's globals into the browser — the hole ADR 0024 closed one layer down. Not the Dashboard: no navigation, grouping, icons, ordering, design system, or routing. The selector is a list of module ids. Co-Authored-By: Claude Opus 5 <[email protected]>
3154 lines
126 KiB
TypeScript
3154 lines
126 KiB
TypeScript
import { randomBytes } from "node:crypto";
|
|
import { request as httpRequest } from "node:http";
|
|
import net from "node:net";
|
|
import { fileURLToPath } from "node:url";
|
|
import path from "node:path";
|
|
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 { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
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 {
|
|
MODULE_UI_CACHE_CONTROL,
|
|
MODULE_UI_CONTENT_TYPE,
|
|
moduleUiArtifactUrl,
|
|
} from "../src/module-ui-routes.js";
|
|
import { startApi, type NodeServerTimeouts, type RunningApi } 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);
|
|
}
|
|
|
|
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",
|
|
]);
|
|
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: 4,
|
|
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 }) {
|
|
http.route({
|
|
method: "GET",
|
|
path: "/gated",
|
|
permission: ${JSON.stringify(permissionId)},
|
|
maxBodyBytes: 0,
|
|
async handler() {
|
|
${recorder.expression}.push(${JSON.stringify(`${id}:handler-entered`)});
|
|
${gate.expression}.enter();
|
|
await ${gate.expression}.released;
|
|
${recorder.expression}.push(${JSON.stringify(`${id}:handler-finished`)});
|
|
return Response.json({ gated: true });
|
|
},
|
|
});
|
|
},
|
|
start() {},
|
|
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: 4,
|
|
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",
|
|
);
|
|
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 }) {
|
|
${recorder.expression}.push(${JSON.stringify(`${id}:register`)});
|
|
${
|
|
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 });
|
|
},
|
|
});`
|
|
: ""
|
|
}
|
|
},
|
|
start() {
|
|
${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: 4,
|
|
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}`;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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 },
|
|
);
|
|
return 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 }),
|
|
});
|
|
}
|
|
|
|
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 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",
|
|
});
|
|
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 };
|
|
}
|
|
|
|
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);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(setCookie).toBeDefined();
|
|
expect(cookiePair).toBeDefined();
|
|
sessionCookie = cookiePair as string;
|
|
|
|
const cookieText = setCookie as string;
|
|
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 — startup and migration guarantees", () => {
|
|
it("does not create any tables or migration history when started against an unmigrated database", 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
|
|
// drives a real sign-up request, which production configuration refuses to enable at all.
|
|
{ environment: "development" },
|
|
);
|
|
const running = await startApi(config, { rateLimitOverride: { max: 1000, window: 1 } });
|
|
const origin = apiOrigin(running);
|
|
|
|
try {
|
|
// A request that touches the (nonexistent) identity tables fails, but must not repair
|
|
// or create anything. The exact failure body is not asserted here (Better Auth's
|
|
// internal-error shape for this case is not a stable public contract); only that it is
|
|
// not a successful sign-up.
|
|
const response = await fetch(`${origin}/api/auth/sign-up/email`, {
|
|
body: JSON.stringify({
|
|
email: "[email protected]",
|
|
name: "Integration Test User",
|
|
password: "correct horse battery staple",
|
|
}),
|
|
headers: { "content-type": "application/json" },
|
|
method: "POST",
|
|
});
|
|
expect(response.status).not.toBe(200);
|
|
} finally {
|
|
await running.shutdown();
|
|
}
|
|
|
|
const admin = await openAdministrativeClient(database.url);
|
|
try {
|
|
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 two (`core.authorization.grants.manage`, `core.modules.ui.read`) plus the
|
|
// fixture module's one.
|
|
details: ["3 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("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 — 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 — 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 handler finished and the module stopped, in that order.
|
|
const finished = recorder.events.indexOf("drainorder:handler-finished");
|
|
const stopped = recorder.events.indexOf("drainorder:stop");
|
|
expect(finished).toBeGreaterThanOrEqual(0);
|
|
expect(stopped).toBeGreaterThanOrEqual(0);
|
|
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);
|
|
}
|
|
});
|
|
});
|