Phase 6D pass 1. The shell's navigation was a flat list of module ids, which is not a usable experience — and the obvious fix carries an obvious trap. Letting a module declare its folder, or modelling "Media" as something Livestream and Video belong to, turns a rendering preference into architecture: a folder becomes an owner, membership starts to look like a dependency, and the flat module graph acquires a hierarchy nobody decided on. So the rule comes first: a Dashboard group is a label on a rendering bucket. No manifest, no version, no artifact, no API, no lifecycle; nothing depends on it. Livestream depends on Video because it calls Video's functionality, and that orders startup. Livestream is shown under Media, and that orders nothing. No parent module, child module, owning module, or group-derived dependency appears anywhere in the contracts. No manifest change and no version 5. `manifest.name` has been required since version 1 and already is the module's product identity, so the shell renders "Livestream" instead of `livestream` with nothing added. `suggestedGroup` is deliberately deferred rather than shipped: it would force a hard manifest-version cutover for a hint, and its only correct semantics needs initialization logic that risks a suggestion overwriting an administrator's decision. Defaulting and ownership are different things. Two Core tables (migration 0003) hold groups and placements — server-side, because a church configures its Dashboard rather than one browser; platform-global, because no organization model exists to scope them to. `group_id` is `on delete set null`, so deleting a group ungroups its modules and a dangling reference is impossible. `module_id` carries no foreign key, because modules are packages rather than rows, so a placement for an uninstalled module is retained and restores its position on reinstall. `resolveDashboardNavigation` is a pure function of configuration plus the mountable set: an unplaced module is ungrouped and visible with no initialization write, an absent module renders nothing, empty groups are kept, and ordering is total with the module id as final tie-break. `GET /api/v1/dashboard/navigation` returns the resolved view, layered on top of the host-neutral module UI inventory rather than merged into it — each generic descriptor is embedded untouched, so a standalone host never inherits Dashboard semantics. Reading needs the permission the inventory already needs; organizing needs the new platform permission `core.dashboard.navigation.manage` through a narrow, idempotent, closed-validation mutation contract. No editor UI. The flatness rule is executable: a test discovers real fixtures with a genuine dependency, arranges them four ways including both in one folder and the dependency inverted in presentation, and asserts discovery order, dependency declarations, and the module set stay byte-identical. The end-to-end test organizes a real installation through the real API, mounts a module from inside a group, moves it, and asserts the navigation entry is byte-identical across the move and still mounts the same artifact. The shell's client for the generic inventory became dead code and was removed rather than kept speculatively; the endpoint itself is unchanged. Co-Authored-By: Claude Opus 5 <[email protected]>
737 lines
28 KiB
TypeScript
737 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",
|
|
]);
|
|
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",
|
|
"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);
|
|
} 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(["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;
|
|
}
|