Ekklesia could authenticate a person and, since Pass 1, send a transactional email — but it had no way to establish that an address belonged to whoever claimed it, and no way back in for someone who forgot their password. A verified address is now a precondition for holding a session. Better Auth enforces that after the password check and before any session exists, so an unverified sign-in returns 403 with no cookie. That is why no "unverified principal" was needed: a correct password on an unverified address carries exactly the authority of no password, and /api/v1/principal is unchanged. Better Auth keeps every token. Ekklesia adds no token table, no hashing, and no expiry clock. It supplies a recipient, a subject, and two bodies, and the mail provider supplies everything about SMTP. Verification and reset links are rebuilt from a validated Dashboard origin, taking only the token — never the URL Better Auth prebuilds from a caller-supplied callbackURL — so no request can steer where a recipient lands. Following a verification link mints no session, because mail infrastructure dereferences links. Reset revokes that person's other sessions and leaves managed devices alone. Recovery answers identically for an address with an account, without one, already verified, or undeliverable — including when SMTP is broken, which is why the delivery adapter never throws. The initial platform owner is the one exception: created already verified by an operator command with no mail prerequisite, so a fresh server can be signed in to before a relay exists. Migration 0007 extends that to owners of installations that already exist. Public signup stays disabled; verification is not a licence to self-register. Co-Authored-By: Claude Opus 5 <[email protected]>
781 lines
30 KiB
TypeScript
781 lines
30 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 { type RunningApi, startApi } 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",
|
|
"core:0006_device_identity",
|
|
"core:0007_verified_bootstrap_owners",
|
|
]);
|
|
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",
|
|
"core:0006_device_identity",
|
|
"core:0007_verified_bootstrap_owners",
|
|
"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 };
|
|
}
|
|
|
|
/**
|
|
* Marks an address verified out of band, the same trusted step the initial-owner bootstrap performs.
|
|
*
|
|
* A verified address is a precondition for holding a session (ADR 0038), and this suite is about
|
|
* module database access rather than identity, so it completes that step directly rather than
|
|
* standing up an SMTP server. That the policy is genuinely enforced is asserted in
|
|
* `email-verification.integration.ts`.
|
|
*/
|
|
async function markEmailVerified(url: string, email: string): Promise<void> {
|
|
const client = new Client({ connectionString: url });
|
|
await client.connect();
|
|
try {
|
|
await client.query(
|
|
'update "ekklesia_core_user" set "email_verified" = true where lower("email") = $1',
|
|
[email.trim().toLowerCase()],
|
|
);
|
|
} finally {
|
|
await client.end().catch(() => undefined);
|
|
}
|
|
}
|
|
|
|
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");
|
|
await markEmailVerified(database.url, "[email protected]");
|
|
ownerCookie = await signIn(origin, "[email protected]", "correct horse battery");
|
|
expect(signedUp.userId).toBeDefined();
|
|
});
|
|
|
|
it("authorized read reflects real PostgreSQL state, exposes the exact scoped handler context, 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");
|
|
// The complete handler-context surface, asserted exactly so a new field is a deliberate change
|
|
// here rather than an unnoticed one: `config` (ADR 0029), `storage` (ADR 0030), and `realtime`
|
|
// (ADR 0032) are scoped, per-module additions; everything else a module might want — a Hono
|
|
// Context, the authorization store, raw PostgreSQL, the storage provider, an endpoint or
|
|
// credential — remains unreachable.
|
|
expect(readHandlerEvent?.contextKeys).toEqual([
|
|
"config",
|
|
"moduleId",
|
|
"params",
|
|
"principal",
|
|
"realtime",
|
|
"services",
|
|
"storage",
|
|
]);
|
|
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;
|
|
}
|