From d1b9efd4d229dc4f6601ed493f46258b70dd7210 Mon Sep 17 00:00:00 2001 From: ekklesia Date: Sat, 25 Jul 2026 21:26:15 -0500 Subject: [PATCH] test: prove module database access with PostgreSQL Add a real installed-module fixture (core/tests/fixtures/module-database-integration/db-facade) with its own manifest, Drizzle schema, drizzle-kit-generated migration, and permissioned HTTP routes, then exercise it end to end in a new apps/api PostgreSQL integration suite: explicit migration application before the module runtime starts (never auto-created), start/stop/HTTP handlers sharing one ModuleDatabase facade by reference, authorized read/mutation/transaction commit and rollback against real PostgreSQL, denied/rejected-origin/OPTIONS/HEAD requests never reaching the module or its table, and both a module-caught and an uncaught real database failure sanitizing exactly as pass 1 established. Registered alongside the existing suites in vitest.migrations.integration.config.ts; no production code, schema, or migration changed. --- apps/api/tests/module-database.integration.ts | 708 ++++++++++++++++++ .../db-facade/drizzle/0000_initial.sql | 4 + .../db-facade/drizzle/meta/0000_snapshot.json | 44 ++ .../db-facade/drizzle/meta/_ekklesia.json | 12 + .../db-facade/drizzle/meta/_journal.json | 13 + .../db-facade/ekklesia.module.json | 28 + .../db-facade/package.json | 9 + .../db-facade/schema/index.js | 14 + .../db-facade/server/index.js | 211 ++++++ vitest.migrations.integration.config.ts | 1 + 10 files changed, 1044 insertions(+) create mode 100644 apps/api/tests/module-database.integration.ts create mode 100644 core/tests/fixtures/module-database-integration/db-facade/drizzle/0000_initial.sql create mode 100644 core/tests/fixtures/module-database-integration/db-facade/drizzle/meta/0000_snapshot.json create mode 100644 core/tests/fixtures/module-database-integration/db-facade/drizzle/meta/_ekklesia.json create mode 100644 core/tests/fixtures/module-database-integration/db-facade/drizzle/meta/_journal.json create mode 100644 core/tests/fixtures/module-database-integration/db-facade/ekklesia.module.json create mode 100644 core/tests/fixtures/module-database-integration/db-facade/package.json create mode 100644 core/tests/fixtures/module-database-integration/db-facade/schema/index.js create mode 100644 core/tests/fixtures/module-database-integration/db-facade/server/index.js diff --git a/apps/api/tests/module-database.integration.ts b/apps/api/tests/module-database.integration.ts new file mode 100644 index 0000000..82112a7 --- /dev/null +++ b/apps/api/tests/module-database.integration.ts @@ -0,0 +1,708 @@ +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 { + 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( + client: Client, + text: string, + values: unknown[] = [], +): Promise { + try { + const result = await client.query(text, values); + return result.rows; + } catch { + throw harnessError("An integration database administration operation failed."); + } +} + +async function safeEnd(client: Client): Promise { + try { + await client.end(); + } catch { + throw harnessError("An integration database connection could not be closed."); + } +} + +const trackedDatabases = new Set(); + +async function createDatabase(databaseName: string): Promise { + 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 { + 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 { + 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 { + 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", + ]); + 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 { + const aggregate = await discoverAllMigrations({ + coreMigrationsDirectory, + moduleDiscovery: { + moduleSdkVersion: "0.1.0", + modulesDirectory: fixtureModulesDirectory, + platformVersion: "0.1.0", + }, + }); + expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([ + "core:0000_identity_core_tables", + "core:0001_authorization_core_tables", + "db-facade:0000_initial", + ]); + expect(aggregate.migrations[0]?.checksum).toBe( + "f8f2086ef06f7a6943e8ffc4350bb010b37aed04c0a72030d2b153c986b1325f", + ); + expect(aggregate.migrations[1]?.checksum).toBe( + "830e1fbc26d05af08df8d8710c3d10e1f3016d32c41b8363cb661c49afcbe81e", + ); + await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); +} + +async function startMigratedApi( + url: string, + modulesDirectoryOverride?: string, +): Promise { + const config = createApiRuntimeConfig( + { + databaseUrl: url, + host: "127.0.0.1", + identity: { + baseUrl: identityBaseUrl, + secret: "integration-test-secret-0123456789abcdefgh", + trustedOrigins: [identityBaseUrl, dashboardOrigin], + }, + port: 0, + }, + { environment: "production" }, + ); + 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 { + 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 { + 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 { + return globalThis as unknown as Record; +} + +function resetRecorder(): void { + globalRecord()[RECORDER_GLOBAL_KEY] = []; +} + +function readRecorder(): RecordedEvent[] { + return (globalRecord()[RECORDER_GLOBAL_KEY] as RecordedEvent[] | undefined) ?? []; +} + +async function tableExists(url: string): Promise { + 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 { + return (await fetchRecordRow(url, id)) !== undefined; +} + +async function migrationHistoryCount(url: string, identity: string): Promise { + 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, + ); + } 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(); + + await expect(startMigratedApi(database.url, fixtureModulesDirectory)).rejects.toMatchObject({ + issues: [expect.objectContaining({ code: "MODULE_START_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, "db-facade-owner@example.test", "correct horse battery"); + ownerCookie = await signIn(origin, "db-facade-owner@example.test", "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 { + 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; +} diff --git a/core/tests/fixtures/module-database-integration/db-facade/drizzle/0000_initial.sql b/core/tests/fixtures/module-database-integration/db-facade/drizzle/0000_initial.sql new file mode 100644 index 0000000..59a1b3f --- /dev/null +++ b/core/tests/fixtures/module-database-integration/db-facade/drizzle/0000_initial.sql @@ -0,0 +1,4 @@ +CREATE TABLE "db_facade_records" ( + "id" uuid PRIMARY KEY NOT NULL, + "value" text NOT NULL +); diff --git a/core/tests/fixtures/module-database-integration/db-facade/drizzle/meta/0000_snapshot.json b/core/tests/fixtures/module-database-integration/db-facade/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..cf3f05a --- /dev/null +++ b/core/tests/fixtures/module-database-integration/db-facade/drizzle/meta/0000_snapshot.json @@ -0,0 +1,44 @@ +{ + "id": "8764dbfb-a411-4727-a382-530e4cdf3a2c", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.db_facade_records": { + "name": "db_facade_records", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/core/tests/fixtures/module-database-integration/db-facade/drizzle/meta/_ekklesia.json b/core/tests/fixtures/module-database-integration/db-facade/drizzle/meta/_ekklesia.json new file mode 100644 index 0000000..bac0d21 --- /dev/null +++ b/core/tests/fixtures/module-database-integration/db-facade/drizzle/meta/_ekklesia.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "moduleId": "db-facade", + "entries": [ + { + "tag": "0000_initial", + "destructive": { + "mode": "none" + } + } + ] +} diff --git a/core/tests/fixtures/module-database-integration/db-facade/drizzle/meta/_journal.json b/core/tests/fixtures/module-database-integration/db-facade/drizzle/meta/_journal.json new file mode 100644 index 0000000..e50ac98 --- /dev/null +++ b/core/tests/fixtures/module-database-integration/db-facade/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1700000000000, + "tag": "0000_initial", + "breakpoints": true + } + ] +} diff --git a/core/tests/fixtures/module-database-integration/db-facade/ekklesia.module.json b/core/tests/fixtures/module-database-integration/db-facade/ekklesia.module.json new file mode 100644 index 0000000..2cde66c --- /dev/null +++ b/core/tests/fixtures/module-database-integration/db-facade/ekklesia.module.json @@ -0,0 +1,28 @@ +{ + "manifestVersion": 3, + "id": "db-facade", + "name": "Database Facade Integration Test Module", + "description": "Test-only installed module fixture proving ModuleDatabase against real PostgreSQL. Never discovered by production startup, Doctor, or normal migration aggregation — only apps/api/tests/module-database.integration.ts points discovery at it explicitly.", + "version": "0.1.0", + "compatibility": { + "platform": "^0.1.0", + "moduleSdk": "^0.1.0" + }, + "permissions": [ + { + "id": "db-facade.records.read", + "baseline": "public", + "description": "Read database facade test records." + }, + { + "id": "db-facade.records.write", + "baseline": "authenticated", + "description": "Write database facade test records." + } + ], + "entrypoints": { + "server": "./server/index.js", + "schema": "./schema/index.js", + "migrations": "./drizzle" + } +} diff --git a/core/tests/fixtures/module-database-integration/db-facade/package.json b/core/tests/fixtures/module-database-integration/db-facade/package.json new file mode 100644 index 0000000..b92baab --- /dev/null +++ b/core/tests/fixtures/module-database-integration/db-facade/package.json @@ -0,0 +1,9 @@ +{ + "name": "@test/module-db-facade", + "version": "0.1.0", + "type": "module", + "private": true, + "ekklesia": { + "layer": "module" + } +} diff --git a/core/tests/fixtures/module-database-integration/db-facade/schema/index.js b/core/tests/fixtures/module-database-integration/db-facade/schema/index.js new file mode 100644 index 0000000..dffb014 --- /dev/null +++ b/core/tests/fixtures/module-database-integration/db-facade/schema/index.js @@ -0,0 +1,14 @@ +import { moduleTableName } from "@ekklesia/contracts"; +import { pgTableCreator, text, uuid } from "drizzle-orm/pg-core"; + +const moduleTable = pgTableCreator((localName) => moduleTableName("db-facade", localName)); + +export const records = moduleTable("records", { + id: uuid("id").primaryKey(), + value: text("value").notNull(), +}); + +export default { + moduleId: "db-facade", + tables: { records }, +}; diff --git a/core/tests/fixtures/module-database-integration/db-facade/server/index.js b/core/tests/fixtures/module-database-integration/db-facade/server/index.js new file mode 100644 index 0000000..bf04f57 --- /dev/null +++ b/core/tests/fixtures/module-database-integration/db-facade/server/index.js @@ -0,0 +1,211 @@ +import { eq, sql } from "drizzle-orm"; +import { records } from "../schema/index.js"; + +// A fixed (not per-run-random) globalThis symbol key: this fixture is a static, committed file +// (not a per-test generated template string), so it cannot bake a unique key into itself the way +// every other dynamically-written fixture in this codebase does. A fixed key is safe here because +// the PostgreSQL integration suite never runs its files in parallel (`fileParallelism: false`, +// `vitest.migrations.integration.config.ts`), so there is never more than one active recorder at a +// time. The test file resets this array immediately before each scenario and deletes the property +// during cleanup. +const RECORDER_KEY = "ekklesia.integration.db-facade.recorder"; + +// A fixed row id `start()` writes and `stop()` later updates, so both the integration test and the +// module's own HTTP handlers can address the exact same row deterministically, without needing a +// side channel to learn a generated id. +export const STARTUP_RECORD_ID = "11111111-1111-1111-1111-111111111111"; + +function recordEvent(event) { + const globalValues = globalThis; + if (!Array.isArray(globalValues[RECORDER_KEY])) { + globalValues[RECORDER_KEY] = []; + } + globalValues[RECORDER_KEY].push(event); +} + +export default { + id: "db-facade", + version: "0.1.0", + + register({ http }) { + // Registration is declarative and database-free: there is no `services` field on this + // context at all (see `ModuleRegistrationContext`), so `context.services` reads as + // `undefined` here — recorded for the integration test to assert on directly. + recordEvent({ services: undefined, type: "register" }); + + http.route({ + method: "GET", + path: "/records/:id", + permission: "db-facade.records.read", + async handler(_request, context) { + recordEvent({ + contextKeys: Object.keys(context).sort(), + services: context.services, + type: "handler", + route: "GET /records/:id", + }); + const [row] = await context.services.database + .select({ id: records.id, value: records.value }) + .from(records) + .where(eq(records.id, context.params.id)); + if (!row) { + return Response.json({ code: "RECORD_NOT_FOUND" }, { status: 404 }); + } + return Response.json(row, { status: 200 }); + }, + }); + + http.route({ + method: "GET", + path: "/records-count", + // Deliberately the "write" (authenticated) permission, not "read" (public) — this is the + // fixture's one authenticated-baseline GET route, used to prove HEAD cannot bypass + // authorization the way it could if HEAD skipped the same middleware chain GET uses. + permission: "db-facade.records.write", + async handler(_request, context) { + recordEvent({ + contextKeys: Object.keys(context).sort(), + services: context.services, + type: "handler", + route: "GET /records-count", + }); + const result = await context.services.database.execute( + sql`select count(*)::int as count from ${records}`, + ); + const row = Array.isArray(result) ? result[0] : result.rows?.[0]; + return Response.json({ count: row?.count ?? 0 }, { status: 200 }); + }, + }); + + http.route({ + method: "POST", + path: "/records", + permission: "db-facade.records.write", + async handler(request, context) { + recordEvent({ + contextKeys: Object.keys(context).sort(), + services: context.services, + type: "handler", + route: "POST /records", + }); + const { id, value } = await request.json(); + await context.services.database.insert(records).values({ id, value }); + return Response.json({ id, value }, { status: 201 }); + }, + }); + + http.route({ + method: "PATCH", + path: "/records/:id", + permission: "db-facade.records.write", + async handler(request, context) { + recordEvent({ + contextKeys: Object.keys(context).sort(), + services: context.services, + type: "handler", + route: "PATCH /records/:id", + }); + const { value } = await request.json(); + const updated = await context.services.database + .update(records) + .set({ value }) + .where(eq(records.id, context.params.id)) + .returning({ id: records.id, value: records.value }); + if (updated.length === 0) { + return Response.json({ code: "RECORD_NOT_FOUND" }, { status: 404 }); + } + return Response.json(updated[0], { status: 200 }); + }, + }); + + http.route({ + method: "POST", + path: "/records/transactional", + permission: "db-facade.records.write", + async handler(request, context) { + recordEvent({ + contextKeys: Object.keys(context).sort(), + services: context.services, + type: "handler", + route: "POST /records/transactional", + }); + const { id, value, conflictId } = await request.json(); + try { + await context.services.database.transaction(async (tx) => { + await tx.insert(records).values({ id, value }); + if (conflictId) { + // The same primary key inserted twice inside one transaction: PostgreSQL rejects + // the second insert with a unique-violation, and the whole transaction — including + // the first, otherwise-valid insert above — must roll back atomically. + await tx.insert(records).values({ id: conflictId, value: "conflict" }); + await tx.insert(records).values({ id: conflictId, value: "conflict-again" }); + } + }); + return Response.json({ committed: true }, { status: 201 }); + } catch { + return Response.json({ committed: false }, { status: 409 }); + } + }, + }); + + http.route({ + method: "GET", + path: "/boom", + permission: "db-facade.records.read", + async handler(_request, context) { + recordEvent({ + contextKeys: Object.keys(context).sort(), + services: context.services, + type: "handler", + route: "GET /boom", + }); + // Deliberately uncaught: a real PostgreSQL error (division by zero) must escape this + // handler so the platform's own sanitization boundary is exercised end to end. + await context.services.database.execute(sql`select 1/0`); + return Response.json({ unreachable: true }); + }, + }); + + http.route({ + method: "GET", + path: "/safe-boom", + permission: "db-facade.records.read", + async handler(_request, context) { + recordEvent({ + contextKeys: Object.keys(context).sort(), + services: context.services, + type: "handler", + route: "GET /safe-boom", + }); + try { + await context.services.database.execute(sql`select 1/0`); + return Response.json({ unreachable: true }); + } catch { + // A module deliberately catching its own database failure and returning a valid, + // sanitized response of its own choosing — the platform must preserve this untouched. + return Response.json({ code: "BOOM_HANDLED" }, { status: 409 }); + } + }, + }); + }, + + async start(context) { + recordEvent({ services: context.services, type: "start" }); + // `onConflictDoNothing` makes repeated startups against an already-started database safe — + // a real module's own idempotency choice, not a platform requirement — so a test can start + // the same migrated database more than once without `start()` itself failing on the fixed + // `STARTUP_RECORD_ID` row a previous run already inserted. + await context.services.database + .insert(records) + .values({ id: STARTUP_RECORD_ID, value: "startup-value" }) + .onConflictDoNothing(); + }, + + async stop(context) { + recordEvent({ services: context.services, type: "stop" }); + await context.services.database + .update(records) + .set({ value: "stopped-by-shutdown" }) + .where(eq(records.id, STARTUP_RECORD_ID)); + }, +}; diff --git a/vitest.migrations.integration.config.ts b/vitest.migrations.integration.config.ts index 49e7c36..108e0c0 100644 --- a/vitest.migrations.integration.config.ts +++ b/vitest.migrations.integration.config.ts @@ -9,6 +9,7 @@ export default defineConfig({ "core/tests/authorization-store.integration.ts", "core/tests/authorization-bootstrap.integration.ts", "apps/api/tests/api-http.integration.ts", + "apps/api/tests/module-database.integration.ts", ], passWithNoTests: false, testTimeout: 30_000,