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.
This commit is contained in:
@@ -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));
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user