Modules had no way to be told anything. Announcements — the first real product Ekklesia will absorb — has zero environment variables and compiles a LAN IP address into its source, so this is the contract standing between it and the module system. - A module declares what it needs in its manifest, using six bounded types (string, integer, boolean, enum, url, secret) with no nesting and no regular expressions. Static declaration is what lets the platform validate configuration *before* deciding whether a module may start: an enabled module missing a required value is reported `blocked · configuration_missing · <key>` rather than taking startup down from inside its own `start` hook, and Doctor reports the same while still importing no module code. That forces `manifestVersion: 5`, a hard cutover. - Ordinary values persist in one Core table (migration 0005), one row per (module_id, key), stored as text until validated against the declaring module's current manifest. Nothing is coerced; an invalid stored value blocks the module rather than falling back to the default. A key the manifest no longer declares is retained and ignored. - Secrets are never persisted. There is no encryption at rest in this repository, so a `secret` key is deployment-supplied and mapped in by the host under a name the module never sees. A secret declaration has no `default` field, the write API refuses secret material, and the HTTP view has no `value` field for one — absent by construction, not filtered. - Modules receive a frozen `context.config` on the registration, runtime, and HTTP handler contexts: the same object identity, per module, beside `services` rather than inside it (services is deliberately one shared instance). No environment access, no database reference, no way to reach another module's values. - Configuration decides eligibility through ADR 0028's existing resolver: three new blocked reasons, each with a distinct operator fix, propagating to required dependents through the same mechanism as a disabled module. - Changes take effect at the next restart; both resources report restartRequired. New restricted permission `core.modules.configuration.manage`, separate from lifecycle, at the sibling prefix `/api/v1/module-configuration`. - Migration participation stays independent of configuration validity. Endpoints and credentials for shared infrastructure stay platform concerns; a module declares which bucket it needs, not an S3 endpoint. See ADR 0029. Co-Authored-By: Claude Opus 5 <[email protected]>
748 lines
28 KiB
TypeScript
748 lines
28 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { mkdtemp, rm } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import {
|
|
applyModuleMigrations,
|
|
createPostgresMigrationDatabase,
|
|
discoverAllMigrations,
|
|
} from "@ekklesia/core/migrations";
|
|
import { Client, type QueryResultRow } from "pg";
|
|
import { afterAll, describe, expect, it } from "vitest";
|
|
import { createApiRuntimeConfig } from "../src/config.js";
|
|
import { createApiDatabase } from "../src/database.js";
|
|
import { startApi, type RunningApi } from "../src/server.js";
|
|
|
|
/**
|
|
* Proves `ModuleRuntimeServices.database` against real PostgreSQL: an installed test module
|
|
* (`core/tests/fixtures/module-database-integration/db-facade/`) whose lifecycle hooks and
|
|
* permissioned HTTP handlers perform real typed Drizzle operations — insert, select, update,
|
|
* transaction, execute — against its own migrated table, exclusively through the facade the
|
|
* platform hands it. Follows the exact harness conventions `apps/api/tests/api-http.integration.ts`
|
|
* and `core/tests/postgres-migration.integration.ts` already establish (self-contained per-file
|
|
* helpers, one disposable PostgreSQL database per scenario, `describe.sequential` blocks sharing a
|
|
* running API across related assertions) rather than introducing a second integration-test
|
|
* framework or Docker orchestration path.
|
|
*
|
|
* The fixture module is committed under `core/tests/fixtures/...`, not `apps/api/tests/fixtures/`,
|
|
* for one concrete reason: its schema imports `drizzle-orm/pg-core` and `@ekklesia/contracts`,
|
|
* neither of which `@ekklesia/api` declares as a direct dependency (pnpm's strict linker will not
|
|
* resolve them from anywhere under `apps/api/`), while `@ekklesia/core` already depends on both
|
|
* directly, so any file physically located under `core/`'s directory tree resolves them correctly
|
|
* regardless of which package's test actually points discovery at it. It is never registered as a
|
|
* pnpm workspace package (it matches none of `pnpm-workspace.yaml`'s glob patterns), never sits in
|
|
* the repository's live `modules/` directory, and is only ever discovered when this file explicitly
|
|
* passes its directory as `modulesDirectoryOverride` — normal production startup, Doctor, and
|
|
* `pnpm modules:validate` never see it.
|
|
*/
|
|
|
|
const integrationDatabaseUrlEnvironment = "EKKLESIA_MIGRATION_TEST_DATABASE_URL";
|
|
const thisDirectory = path.dirname(fileURLToPath(import.meta.url));
|
|
const coreMigrationsDirectory = path.join(thisDirectory, "..", "..", "..", "core", "drizzle");
|
|
const fixtureModulesDirectory = path.join(
|
|
thisDirectory,
|
|
"..",
|
|
"..",
|
|
"..",
|
|
"core",
|
|
"tests",
|
|
"fixtures",
|
|
"module-database-integration",
|
|
);
|
|
|
|
const RECORDS_TABLE = "db_facade_records";
|
|
const RECORDER_GLOBAL_KEY = "ekklesia.integration.db-facade.recorder";
|
|
// Must match `STARTUP_RECORD_ID` in the fixture's `server/index.js` exactly.
|
|
const STARTUP_RECORD_ID = "11111111-1111-1111-1111-111111111111";
|
|
|
|
const identityBaseUrl = "https://identity.example.test";
|
|
const dashboardOrigin = "https://dashboard.example.test";
|
|
const untrustedOrigin = "https://evil.example.test";
|
|
|
|
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 module database 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 module database 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}"`;
|
|
}
|
|
|
|
let databaseCounter = 0;
|
|
|
|
function makeDatabaseName(label: string): string {
|
|
const safeLabel = label
|
|
.toLowerCase()
|
|
.replaceAll(/[^a-z0-9]+/gu, "_")
|
|
.replaceAll(/^_+|_+$/gu, "")
|
|
.slice(0, 14);
|
|
databaseCounter += 1;
|
|
return `ekk_dbf_${process.pid}_${databaseCounter}_${safeLabel || "scenario"}_${randomUUID().slice(0, 8)}`;
|
|
}
|
|
|
|
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 module database integration 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.");
|
|
}
|
|
}
|
|
|
|
const trackedDatabases = new Set<string>();
|
|
|
|
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) };
|
|
}
|
|
|
|
/** Applies only Core's own migrations, against an empty modules directory — the module fixture's
|
|
* migration is deliberately absent, used only by the "no auto-migration" negative scenario below. */
|
|
async function applyCoreMigrations(url: string): Promise<void> {
|
|
const emptyModulesDirectory = await mkdtemp(
|
|
path.join(tmpdir(), "ekklesia-db-facade-no-modules-"),
|
|
);
|
|
try {
|
|
const aggregate = await discoverAllMigrations({
|
|
coreMigrationsDirectory,
|
|
moduleDiscovery: {
|
|
moduleSdkVersion: "0.1.0",
|
|
modulesDirectory: emptyModulesDirectory,
|
|
platformVersion: "0.1.0",
|
|
},
|
|
});
|
|
expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([
|
|
"core:0000_identity_core_tables",
|
|
"core:0001_authorization_core_tables",
|
|
"core:0002_authorization_integrity",
|
|
"core:0003_dashboard_navigation_tables",
|
|
"core:0004_module_lifecycle_state",
|
|
"core:0005_module_configuration",
|
|
]);
|
|
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
|
} finally {
|
|
await rm(emptyModulesDirectory, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
/** Applies Core's migrations plus the `db-facade` fixture module's own migration — explicitly,
|
|
* before any module runtime exists — proving the platform never infers or auto-generates this
|
|
* table. Pass-1's committed core migrations and checksums must not have drifted while this pass
|
|
* was built. */
|
|
async function applyMigrationsWithModule(url: string): Promise<void> {
|
|
const aggregate = await discoverAllMigrations({
|
|
coreMigrationsDirectory,
|
|
moduleDiscovery: {
|
|
moduleSdkVersion: "0.1.0",
|
|
modulesDirectory: fixtureModulesDirectory,
|
|
platformVersion: "0.1.0",
|
|
},
|
|
});
|
|
// Core migrations aggregate first, exactly once each, and always before any module migration.
|
|
expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([
|
|
"core:0000_identity_core_tables",
|
|
"core:0001_authorization_core_tables",
|
|
"core:0002_authorization_integrity",
|
|
"core:0003_dashboard_navigation_tables",
|
|
"core:0004_module_lifecycle_state",
|
|
"core:0005_module_configuration",
|
|
"db-facade:0000_initial",
|
|
]);
|
|
expect(aggregate.migrations[0]?.checksum).toBe(
|
|
"f8f2086ef06f7a6943e8ffc4350bb010b37aed04c0a72030d2b153c986b1325f",
|
|
);
|
|
expect(aggregate.migrations[1]?.checksum).toBe(
|
|
"830e1fbc26d05af08df8d8710c3d10e1f3016d32c41b8363cb661c49afcbe81e",
|
|
);
|
|
expect(aggregate.migrations[2]?.checksum).toBe(
|
|
"63429473118065dfe3877a1baec61b8f61663b88127d9cb17707c859f27f6036",
|
|
);
|
|
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
|
}
|
|
|
|
async function startMigratedApi(
|
|
url: string,
|
|
modulesDirectoryOverride?: string,
|
|
): Promise<RunningApi> {
|
|
const config = createApiRuntimeConfig(
|
|
{
|
|
databaseUrl: url,
|
|
host: "127.0.0.1",
|
|
identity: {
|
|
baseUrl: identityBaseUrl,
|
|
publicSignupEnabled: true,
|
|
secret: "integration-test-secret-0123456789abcdefgh",
|
|
trustedOrigins: [identityBaseUrl, dashboardOrigin],
|
|
},
|
|
port: 0,
|
|
},
|
|
// Development validation: this suite signs an owner account up over HTTP to exercise real
|
|
// authorized module database access, and production configuration refuses to enable public
|
|
// signup at all until durable abuse controls exist
|
|
// (`PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION`, `core/src/identity/config.ts`). `environment`
|
|
// affects only Ekklesia's own config validation — it never reaches Better Auth — and the base
|
|
// URL and trusted origins here are identical either way.
|
|
{ environment: "development" },
|
|
);
|
|
return startApi(config, {
|
|
rateLimitOverride: { max: 1000, window: 1 },
|
|
...(modulesDirectoryOverride === undefined ? {} : { modulesDirectoryOverride }),
|
|
});
|
|
}
|
|
|
|
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 SignUpResult {
|
|
readonly userId: string;
|
|
}
|
|
|
|
async function signUp(origin: string, email: string, password: string): Promise<SignUpResult> {
|
|
const response = await fetch(`${origin}/api/auth/sign-up/email`, {
|
|
body: JSON.stringify({ email, name: "Integration Test User", password }),
|
|
headers: { "content-type": "application/json", origin: dashboardOrigin },
|
|
method: "POST",
|
|
});
|
|
const body = (await response.json()) as { user: { id: string } };
|
|
return { userId: body.user.id };
|
|
}
|
|
|
|
async function signIn(origin: string, email: string, password: string): Promise<string> {
|
|
const response = await fetch(`${origin}/api/auth/sign-in/email`, {
|
|
body: JSON.stringify({ email, password }),
|
|
headers: { "content-type": "application/json", origin: dashboardOrigin },
|
|
method: "POST",
|
|
});
|
|
const setCookie = response.headers.get("set-cookie");
|
|
if (!setCookie) {
|
|
throw harnessError("Sign-in did not return a session cookie.");
|
|
}
|
|
return setCookie.split(",")[0]?.split(";")[0]?.trim() ?? "";
|
|
}
|
|
|
|
interface RecordedEvent {
|
|
readonly type: "register" | "start" | "stop" | "handler";
|
|
readonly services?: { readonly database: unknown };
|
|
readonly route?: string;
|
|
readonly contextKeys?: readonly string[];
|
|
}
|
|
|
|
function globalRecord(): Record<string, unknown> {
|
|
return globalThis as unknown as Record<string, unknown>;
|
|
}
|
|
|
|
function resetRecorder(): void {
|
|
globalRecord()[RECORDER_GLOBAL_KEY] = [];
|
|
}
|
|
|
|
function readRecorder(): RecordedEvent[] {
|
|
return (globalRecord()[RECORDER_GLOBAL_KEY] as RecordedEvent[] | undefined) ?? [];
|
|
}
|
|
|
|
async function tableExists(url: string): Promise<boolean> {
|
|
const admin = await openAdministrativeClient(url);
|
|
try {
|
|
const rows = await safeQuery<{ exists: boolean }>(
|
|
admin,
|
|
"select exists (select 1 from information_schema.tables where table_schema = 'public' and table_name = $1) as exists",
|
|
[RECORDS_TABLE],
|
|
);
|
|
return rows[0]?.exists ?? false;
|
|
} finally {
|
|
await safeEnd(admin);
|
|
}
|
|
}
|
|
|
|
async function fetchRecordRow(
|
|
url: string,
|
|
id: string,
|
|
): Promise<{ id: string; value: string } | undefined> {
|
|
const admin = await openAdministrativeClient(url);
|
|
try {
|
|
const rows = await safeQuery<{ id: string; value: string }>(
|
|
admin,
|
|
`select id, value from "${RECORDS_TABLE}" where id = $1`,
|
|
[id],
|
|
);
|
|
return rows[0];
|
|
} finally {
|
|
await safeEnd(admin);
|
|
}
|
|
}
|
|
|
|
async function recordExists(url: string, id: string): Promise<boolean> {
|
|
return (await fetchRecordRow(url, id)) !== undefined;
|
|
}
|
|
|
|
async function migrationHistoryCount(url: string, identity: string): Promise<number> {
|
|
const admin = await openAdministrativeClient(url);
|
|
try {
|
|
const rows = await safeQuery<{ count: string }>(
|
|
admin,
|
|
`select count(*)::text as count from "ekklesia_core_migrations" where identity = $1`,
|
|
[identity],
|
|
);
|
|
return Number(rows[0]?.count ?? "0");
|
|
} finally {
|
|
await safeEnd(admin);
|
|
}
|
|
}
|
|
|
|
afterAll(async () => {
|
|
delete globalRecord()[RECORDER_GLOBAL_KEY];
|
|
for (const databaseName of [...trackedDatabases]) {
|
|
await dropDatabase(databaseName).catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
describe.sequential("live API — module database explicit migration ownership", () => {
|
|
it("creates no table merely by applying core migrations or constructing the pool and facade — only an explicit module migration does", async () => {
|
|
const database = await createDisposableDatabase("mig_absent");
|
|
try {
|
|
expect(await tableExists(database.url)).toBe(false);
|
|
|
|
await applyCoreMigrations(database.url);
|
|
expect(await tableExists(database.url)).toBe(false);
|
|
|
|
const handle = createApiDatabase(database.url);
|
|
expect(handle.module).toBeDefined();
|
|
await handle.close();
|
|
expect(await tableExists(database.url)).toBe(false);
|
|
|
|
await applyMigrationsWithModule(database.url);
|
|
expect(await tableExists(database.url)).toBe(true);
|
|
} finally {
|
|
await dropDatabase(database.name);
|
|
}
|
|
});
|
|
|
|
it("applies the module's migration exactly once, never regenerating or reapplying it across repeated startups", async () => {
|
|
const database = await createDisposableDatabase("mig_once");
|
|
try {
|
|
await applyMigrationsWithModule(database.url);
|
|
expect(await migrationHistoryCount(database.url, "db-facade:0000_initial")).toBe(1);
|
|
|
|
const first = await startMigratedApi(database.url, fixtureModulesDirectory);
|
|
await first.shutdown();
|
|
const second = await startMigratedApi(database.url, fixtureModulesDirectory);
|
|
await second.shutdown();
|
|
|
|
expect(await migrationHistoryCount(database.url, "db-facade:0000_initial")).toBe(1);
|
|
expect(await migrationHistoryCount(database.url, "core:0000_identity_core_tables")).toBe(1);
|
|
expect(await migrationHistoryCount(database.url, "core:0001_authorization_core_tables")).toBe(
|
|
1,
|
|
);
|
|
expect(await migrationHistoryCount(database.url, "core:0002_authorization_integrity")).toBe(
|
|
1,
|
|
);
|
|
expect(
|
|
await migrationHistoryCount(database.url, "core:0003_dashboard_navigation_tables"),
|
|
).toBe(1);
|
|
expect(await migrationHistoryCount(database.url, "core:0004_module_lifecycle_state")).toBe(1);
|
|
} finally {
|
|
await dropDatabase(database.name);
|
|
}
|
|
});
|
|
|
|
it("fails module startup rather than silently creating the table when the module's own migration was never applied", async () => {
|
|
const database = await createDisposableDatabase("mig_skip");
|
|
try {
|
|
await applyCoreMigrations(database.url);
|
|
resetRecorder();
|
|
|
|
// The module whose `start` failed is now rolled back through its own `stop` hook first. That
|
|
// hook also writes to the table its missing migration would have created, so it fails too —
|
|
// which is exactly the documented reason a `stop` hook must tolerate partial initialization.
|
|
// Both outcomes are reported, in order, and neither is swallowed.
|
|
await expect(startMigratedApi(database.url, fixtureModulesDirectory)).rejects.toMatchObject({
|
|
issues: [
|
|
expect.objectContaining({ code: "MODULE_START_FAILED", moduleId: "db-facade" }),
|
|
expect.objectContaining({ code: "MODULE_ROLLBACK_FAILED", moduleId: "db-facade" }),
|
|
],
|
|
});
|
|
|
|
// No schema inference, no automatic table creation as a side effect of the failed attempt.
|
|
expect(await tableExists(database.url)).toBe(false);
|
|
} finally {
|
|
await dropDatabase(database.name);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe.sequential("live API — module database access through ModuleRuntimeServices", () => {
|
|
let database: { name: string; url: string };
|
|
let running: RunningApi;
|
|
let origin: string;
|
|
let ownerCookie: string;
|
|
|
|
it("starts a freshly migrated API; register receives no services; start writes the startup record through the real facade", async () => {
|
|
database = await createDisposableDatabase("db_facade");
|
|
await applyMigrationsWithModule(database.url);
|
|
resetRecorder();
|
|
|
|
running = await startMigratedApi(database.url, fixtureModulesDirectory);
|
|
origin = apiOrigin(running);
|
|
|
|
const events = readRecorder();
|
|
const registerEvents = events.filter((event) => event.type === "register");
|
|
const startEvents = events.filter((event) => event.type === "start");
|
|
expect(registerEvents).toHaveLength(1);
|
|
expect(registerEvents[0]?.services).toBeUndefined();
|
|
expect(startEvents).toHaveLength(1);
|
|
expect(startEvents[0]?.services?.database).toBeDefined();
|
|
|
|
const startupRow = await fetchRecordRow(database.url, STARTUP_RECORD_ID);
|
|
expect(startupRow).toEqual({ id: STARTUP_RECORD_ID, value: "startup-value" });
|
|
|
|
const signedUp = await signUp(origin, "[email protected]", "correct horse battery");
|
|
ownerCookie = await signIn(origin, "[email protected]", "correct horse battery");
|
|
expect(signedUp.userId).toBeDefined();
|
|
});
|
|
|
|
it("authorized read reflects real PostgreSQL state, exposes exactly moduleId/principal/params/services, and the handler's database is reference-equal to start's", async () => {
|
|
const response = await fetch(`${origin}/api/v1/modules/db-facade/records/${STARTUP_RECORD_ID}`);
|
|
expect(response.status).toBe(200);
|
|
expect(await response.json()).toEqual({ id: STARTUP_RECORD_ID, value: "startup-value" });
|
|
|
|
const events = readRecorder();
|
|
const startEvent = events.find((event) => event.type === "start");
|
|
const readHandlerEvent = [...events]
|
|
.reverse()
|
|
.find((event) => event.type === "handler" && event.route === "GET /records/:id");
|
|
expect(readHandlerEvent?.contextKeys).toEqual([
|
|
"config",
|
|
"moduleId",
|
|
"params",
|
|
"principal",
|
|
"services",
|
|
]);
|
|
expect(readHandlerEvent?.services).toBeDefined();
|
|
expect(readHandlerEvent?.services?.database).toBe(startEvent?.services?.database);
|
|
});
|
|
|
|
it("requires a trusted Origin for the module's mutating route, performs a real insert, and a later read observes it", async () => {
|
|
const id = randomUUID();
|
|
const noOrigin = await fetch(`${origin}/api/v1/modules/db-facade/records`, {
|
|
body: JSON.stringify({ id, value: "no-origin" }),
|
|
headers: { "content-type": "application/json", cookie: ownerCookie },
|
|
method: "POST",
|
|
});
|
|
expect(noOrigin.status).toBe(403);
|
|
expect(await noOrigin.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
|
|
expect(await recordExists(database.url, id)).toBe(false);
|
|
|
|
const created = await fetch(`${origin}/api/v1/modules/db-facade/records`, {
|
|
body: JSON.stringify({ id, value: "created-value" }),
|
|
headers: {
|
|
"content-type": "application/json",
|
|
cookie: ownerCookie,
|
|
origin: dashboardOrigin,
|
|
},
|
|
method: "POST",
|
|
});
|
|
expect(created.status).toBe(201);
|
|
expect(await created.json()).toEqual({ id, value: "created-value" });
|
|
|
|
const readBack = await fetch(`${origin}/api/v1/modules/db-facade/records/${id}`);
|
|
expect(readBack.status).toBe(200);
|
|
expect(await readBack.json()).toEqual({ id, value: "created-value" });
|
|
|
|
const patched = await fetch(`${origin}/api/v1/modules/db-facade/records/${id}`, {
|
|
body: JSON.stringify({ value: "patched-value" }),
|
|
headers: {
|
|
"content-type": "application/json",
|
|
cookie: ownerCookie,
|
|
origin: dashboardOrigin,
|
|
},
|
|
method: "PATCH",
|
|
});
|
|
expect(patched.status).toBe(200);
|
|
expect(await patched.json()).toEqual({ id, value: "patched-value" });
|
|
expect(await fetchRecordRow(database.url, id)).toEqual({ id, value: "patched-value" });
|
|
});
|
|
|
|
it("denies the mutating route to an unauthenticated caller, invoking neither the handler nor a database mutation", async () => {
|
|
const id = randomUUID();
|
|
const eventsBefore = readRecorder().length;
|
|
|
|
const response = await fetch(`${origin}/api/v1/modules/db-facade/records`, {
|
|
body: JSON.stringify({ id, value: "should-not-persist" }),
|
|
headers: {
|
|
"content-type": "application/json",
|
|
origin: dashboardOrigin,
|
|
},
|
|
method: "POST",
|
|
});
|
|
|
|
expect(response.status).toBe(403);
|
|
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
|
expect(readRecorder().length).toBe(eventsBefore);
|
|
expect(await recordExists(database.url, id)).toBe(false);
|
|
});
|
|
|
|
it("rejects an untrusted-Origin mutation before principal resolution, invoking neither the handler nor a database mutation", async () => {
|
|
const id = randomUUID();
|
|
const eventsBefore = readRecorder().length;
|
|
|
|
const response = await fetch(`${origin}/api/v1/modules/db-facade/records`, {
|
|
body: JSON.stringify({ id, value: "should-not-persist" }),
|
|
headers: {
|
|
"content-type": "application/json",
|
|
cookie: ownerCookie,
|
|
origin: untrustedOrigin,
|
|
},
|
|
method: "POST",
|
|
});
|
|
|
|
expect(response.status).toBe(403);
|
|
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
|
|
expect(readRecorder().length).toBe(eventsBefore);
|
|
expect(await recordExists(database.url, id)).toBe(false);
|
|
});
|
|
|
|
it("keeps OPTIONS platform-owned: never invokes module code, never mutates the table, and returns correct final CORS/Vary", async () => {
|
|
const id = randomUUID();
|
|
const eventsBefore = readRecorder().length;
|
|
|
|
const response = await fetch(`${origin}/api/v1/modules/db-facade/records`, {
|
|
headers: {
|
|
"access-control-request-method": "POST",
|
|
origin: dashboardOrigin,
|
|
},
|
|
method: "OPTIONS",
|
|
});
|
|
|
|
expect(response.status).toBe(204);
|
|
expect(response.headers.get("access-control-allow-origin")).toBe(dashboardOrigin);
|
|
const vary = (response.headers.get("vary") ?? "").split(",").map((token) => token.trim());
|
|
expect(vary.filter((token) => token.toLowerCase() === "origin")).toHaveLength(1);
|
|
expect(readRecorder().length).toBe(eventsBefore);
|
|
expect(await recordExists(database.url, id)).toBe(false);
|
|
});
|
|
|
|
it("cannot let HEAD bypass the authorized GET chain, and creates no mutation", async () => {
|
|
const anonymousHead = await fetch(`${origin}/api/v1/modules/db-facade/records-count`, {
|
|
method: "HEAD",
|
|
});
|
|
expect(anonymousHead.status).toBe(403);
|
|
expect(await anonymousHead.text()).toBe("");
|
|
|
|
const authorizedHead = await fetch(`${origin}/api/v1/modules/db-facade/records-count`, {
|
|
headers: { cookie: ownerCookie },
|
|
method: "HEAD",
|
|
});
|
|
expect(authorizedHead.status).toBe(200);
|
|
expect(await authorizedHead.text()).toBe("");
|
|
|
|
const countBefore = await countRecordRowsViaGet(origin, ownerCookie);
|
|
const countAfter = await countRecordRowsViaGet(origin, ownerCookie);
|
|
expect(countAfter).toBe(countBefore);
|
|
});
|
|
|
|
it("commits a real transaction and persists its result", async () => {
|
|
const id = randomUUID();
|
|
const response = await fetch(`${origin}/api/v1/modules/db-facade/records/transactional`, {
|
|
body: JSON.stringify({ id, value: "committed-value" }),
|
|
headers: {
|
|
"content-type": "application/json",
|
|
cookie: ownerCookie,
|
|
origin: dashboardOrigin,
|
|
},
|
|
method: "POST",
|
|
});
|
|
expect(response.status).toBe(201);
|
|
expect(await response.json()).toEqual({ committed: true });
|
|
expect(await fetchRecordRow(database.url, id)).toEqual({ id, value: "committed-value" });
|
|
});
|
|
|
|
it("rolls back a real transaction on a unique-constraint failure, leaving no partial mutation", async () => {
|
|
const id = randomUUID();
|
|
const conflictId = randomUUID();
|
|
const response = await fetch(`${origin}/api/v1/modules/db-facade/records/transactional`, {
|
|
body: JSON.stringify({ id, value: "should-roll-back", conflictId }),
|
|
headers: {
|
|
"content-type": "application/json",
|
|
cookie: ownerCookie,
|
|
origin: dashboardOrigin,
|
|
},
|
|
method: "POST",
|
|
});
|
|
expect(response.status).toBe(409);
|
|
expect(await response.json()).toEqual({ committed: false });
|
|
|
|
// The whole transaction rolled back atomically — including the first, otherwise-valid insert.
|
|
expect(await recordExists(database.url, id)).toBe(false);
|
|
expect(await recordExists(database.url, conflictId)).toBe(false);
|
|
});
|
|
|
|
it("preserves a module's own sanitized response for a database failure it deliberately catches", async () => {
|
|
const response = await fetch(`${origin}/api/v1/modules/db-facade/safe-boom`);
|
|
expect(response.status).toBe(409);
|
|
expect(await response.json()).toEqual({ code: "BOOM_HANDLED" });
|
|
});
|
|
|
|
it("sanitizes an uncaught real PostgreSQL failure to the existing MODULE_HANDLER_FAILED boundary, leaking no detail", async () => {
|
|
const response = await fetch(`${origin}/api/v1/modules/db-facade/boom`);
|
|
const text = await response.text();
|
|
|
|
expect(response.status).toBe(500);
|
|
expect(text).toBe(JSON.stringify({ code: "MODULE_HANDLER_FAILED" }));
|
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
|
|
|
const lowered = text.toLowerCase();
|
|
for (const forbidden of [
|
|
"division",
|
|
"sql",
|
|
"postgres",
|
|
"22012",
|
|
"select",
|
|
"relation",
|
|
"stack",
|
|
"node_modules",
|
|
fixtureModulesDirectory.toLowerCase(),
|
|
]) {
|
|
expect(lowered).not.toContain(forbidden);
|
|
}
|
|
});
|
|
|
|
it("stops cleanly: stop receives the same services and database as start, its write completes before the pool closes, and the API shuts down", async () => {
|
|
const events = readRecorder();
|
|
const startEvent = events.find((event) => event.type === "start");
|
|
|
|
await running.shutdown();
|
|
|
|
const remainingConnections = await activeConnectionCount(database.name);
|
|
expect(remainingConnections).toBe(0);
|
|
|
|
const eventsAfterShutdown = readRecorder();
|
|
const stopEvents = eventsAfterShutdown.filter((event) => event.type === "stop");
|
|
expect(stopEvents).toHaveLength(1);
|
|
expect(stopEvents[0]?.services?.database).toBe(startEvent?.services?.database);
|
|
|
|
// Read via a separate administrative connection, entirely independent of the API's own
|
|
// (now-closed) pool — proving the stop write completed and persisted before that pool closed.
|
|
const stoppedRow = await fetchRecordRow(database.url, STARTUP_RECORD_ID);
|
|
expect(stoppedRow).toEqual({ id: STARTUP_RECORD_ID, value: "stopped-by-shutdown" });
|
|
|
|
await dropDatabase(database.name);
|
|
});
|
|
});
|
|
|
|
async function countRecordRowsViaGet(origin: string, cookie: string): Promise<number> {
|
|
const response = await fetch(`${origin}/api/v1/modules/db-facade/records-count`, {
|
|
headers: { cookie },
|
|
});
|
|
const body = (await response.json()) as { count: number };
|
|
return body.count;
|
|
}
|