Files
Ekklesia/core/tests/module-configuration-store.integration.ts
T
ekklesiaandClaude Opus 5 ea28f8aa2e
CI / verify (pull_request) Successful in 42s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 1m42s
feat: verify email addresses and recover forgotten passwords
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]>
2026-08-25 07:21:17 -05:00

462 lines
16 KiB
TypeScript

import { randomBytes } 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 { parseModuleManifest } from "@ekklesia/contracts";
import { Client, type QueryResultRow } from "pg";
import { afterAll, describe, expect, it } from "vitest";
import { createValidatedPostgresPool } from "../src/database/index.js";
import {
applyModuleMigrations,
createPostgresMigrationDatabase,
discoverAllMigrations,
} from "../src/migrations/index.js";
import {
createModuleConfigurationStore,
createModuleConfigurationView,
type ModuleConfigurationStore,
ModuleConfigurationStoreError,
resolveModuleConfigurationFor,
} from "../src/modules/index.js";
/**
* The module configuration store against real PostgreSQL.
*
* `core/tests/module-configuration.test.ts` proves the validation and defaulting rules with no
* database at all; this proves the things only a database can: that migration `0005` really creates
* the table with the composite primary key and value ceiling the schema declares, that a replace is
* transactional and scoped to one module, that stored values survive a completely new connection,
* and — the security-relevant one — that a module id or key is only ever *data* in a parameterized
* column, never an SQL identifier. The in-memory double the API route tests use reproduces none of
* that, which is why it lives here.
*/
const integrationDatabaseUrlEnvironment = "EKKLESIA_MIGRATION_TEST_DATABASE_URL";
const coreMigrationsDirectory = path.join(
path.dirname(fileURLToPath(import.meta.url)),
"..",
"drizzle",
);
const trackedDatabases = new Set<string>();
let databaseCounter = 0;
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 configuration 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 configuration 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}"`;
}
function makeDatabaseName(label: string): string {
const safeLabel = label
.toLowerCase()
.replaceAll(/[^a-z0-9]+/gu, "_")
.replaceAll(/^_+|_+$/gu, "")
.slice(0, 10);
databaseCounter += 1;
return `ekk_modcfg_${process.pid}_${databaseCounter}_${safeLabel || "scenario"}_${randomBytes(4).toString("hex")}`;
}
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 stays static when connection cleanup also fails.
}
throw harnessError("The module configuration integration database 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.");
}
}
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 createDisposableDatabase(label: string): Promise<{ name: string; url: string }> {
const name = makeDatabaseName(label);
const admin = await openAdministrativeClient(baseDatabaseUrl());
try {
await safeQuery(admin, `CREATE DATABASE ${quoteDatabaseName(name)} TEMPLATE template0`);
} finally {
await safeEnd(admin);
}
trackedDatabases.add(name);
return { name, url: databaseUrl(name) };
}
async function applyCoreMigrations(url: string): Promise<void> {
const emptyModulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-modcfg-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, { force: true, recursive: true });
}
}
afterAll(async () => {
for (const databaseName of [...trackedDatabases]) {
await dropDatabase(databaseName).catch(() => undefined);
}
});
function manifestWith(configuration: readonly Record<string, unknown>[], id = "announcements") {
return parseModuleManifest({
manifestVersion: 6,
id,
name: `${id} module`,
version: "1.0.0",
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
configuration,
entrypoints: { server: "./dist/does-not-exist/index.js" },
});
}
describe.sequential("live Core module configuration store", () => {
let database: { name: string; url: string };
let admin: Client;
let store: ModuleConfigurationStore;
let close: () => Promise<void>;
it("creates exactly the one configuration table, with its composite key and declared constraints", async () => {
database = await createDisposableDatabase("fresh");
await applyCoreMigrations(database.url);
admin = await openAdministrativeClient(database.url);
const columns = await safeQuery<{ column_name: string; is_nullable: string }>(
admin,
`select column_name, is_nullable
from information_schema.columns
where table_name = 'ekklesia_core_module_configuration'
order by column_name`,
);
const primaryKey = await safeQuery<{ column_name: string }>(
admin,
`select kcu.column_name
from information_schema.table_constraints tc
join information_schema.key_column_usage kcu
on kcu.constraint_name = tc.constraint_name
where tc.table_name = 'ekklesia_core_module_configuration'
and tc.constraint_type = 'PRIMARY KEY'
order by kcu.ordinal_position`,
);
// Only the explicitly declared CHECKs: PostgreSQL 17 also materializes a system `..._not_null`
// CHECK per NOT NULL column, which is not something this schema declares.
const checks = await safeQuery<{ constraint_name: string }>(
admin,
`select tc.constraint_name
from information_schema.table_constraints tc
where tc.table_name = 'ekklesia_core_module_configuration'
and tc.constraint_type = 'CHECK'
and tc.constraint_name not like '%_not_null'
order by tc.constraint_name`,
);
expect(columns.map((column) => column.column_name)).toEqual([
"created_at",
"key",
"module_id",
"updated_at",
"value",
]);
// One row per (module, key): a module can never have two values for one key, enforced by the
// database rather than by application care.
expect(primaryKey.map((column) => column.column_name)).toEqual(["module_id", "key"]);
expect(checks.map((check) => check.constraint_name)).toEqual([
"ekklesia_core_module_configuration_key_check",
"ekklesia_core_module_configuration_module_check",
"ekklesia_core_module_configuration_value_check",
]);
// Deliberately absent: there is no column that could hold secret material, and none that
// records where a value came from. Secrets are deployment-supplied and never persisted.
expect(columns.map((column) => column.column_name)).not.toContain("secret");
expect(columns.map((column) => column.column_name)).not.toContain("encrypted");
});
it("reads an empty configuration from a freshly migrated database", async () => {
const view = createValidatedPostgresPool(database.url, {
connectionTimeoutMs: 10_000,
operationTimeoutMs: 10_000,
});
close = view.close;
store = createModuleConfigurationStore(createModuleConfigurationView(view.pool));
expect(await store.readSettings()).toEqual({ records: [] });
});
it("persists values and reads them back over a completely new connection", async () => {
await store.replace({
moduleId: "announcements",
values: [
{ key: "bucket", value: "slides" },
{ key: "slideSeconds", value: "12" },
],
});
const separate = createValidatedPostgresPool(database.url, {
connectionTimeoutMs: 10_000,
operationTimeoutMs: 10_000,
});
try {
const reread = await createModuleConfigurationStore(
createModuleConfigurationView(separate.pool),
).readSettings();
expect(reread.records).toEqual([
{ key: "bucket", moduleId: "announcements", value: "slides" },
{ key: "slideSeconds", moduleId: "announcements", value: "12" },
]);
} finally {
await separate.close();
}
});
it("replaces the whole document for one module, clearing keys the submission omits", async () => {
await store.replace({
moduleId: "announcements",
values: [{ key: "bucket", value: "slides" }],
});
expect((await store.readSettings()).records).toEqual([
{ key: "bucket", moduleId: "announcements", value: "slides" },
]);
});
it("never touches another module's rows during a replace", async () => {
await store.replace({ moduleId: "calendar", values: [{ key: "bucket", value: "events" }] });
await store.replace({
moduleId: "announcements",
values: [{ key: "bucket", value: "renamed" }],
});
expect((await store.readSettings()).records).toEqual([
{ key: "bucket", moduleId: "announcements", value: "renamed" },
{ key: "bucket", moduleId: "calendar", value: "events" },
]);
});
it("clears a module's document entirely when given no values", async () => {
await store.replace({ moduleId: "calendar", values: [] });
expect(
(await store.readSettings()).records.filter((record) => record.moduleId === "calendar"),
).toEqual([]);
});
it("treats a module id and a key as data, never as an SQL identifier", async () => {
// The store validates identifiers before they reach PostgreSQL, so this is rejected by the
// application. The row count afterwards proves nothing was executed either way.
await expect(
store.replace({
moduleId: 'announcements"; drop table ekklesia_core_module_configuration; --',
values: [{ key: "bucket", value: "x" }],
}),
).rejects.toMatchObject({ code: "MODULE_CONFIGURATION_MODULE_ID_INVALID" });
await expect(
store.replace({
moduleId: "announcements",
values: [{ key: 'bucket"; drop table ekklesia_core_module_configuration; --', value: "x" }],
}),
).rejects.toMatchObject({ code: "MODULE_CONFIGURATION_KEY_INVALID" });
const tables = await safeQuery<{ count: string }>(
admin,
`select count(*)::text as count from information_schema.tables
where table_name = 'ekklesia_core_module_configuration'`,
);
expect(tables[0]?.count).toBe("1");
});
it("rejects a duplicate key in one submission before reaching the database", async () => {
await expect(
store.replace({
moduleId: "announcements",
values: [
{ key: "bucket", value: "a" },
{ key: "bucket", value: "b" },
],
}),
).rejects.toBeInstanceOf(ModuleConfigurationStoreError);
});
it("rejects an oversized value at the application boundary and at the database", async () => {
await expect(
store.replace({
moduleId: "announcements",
values: [{ key: "bucket", value: "x".repeat(2049) }],
}),
).rejects.toMatchObject({ code: "MODULE_CONFIGURATION_VALUE_INVALID" });
// Defense in depth: the same ceiling restated where the data lives, so no future caller can
// store a value the resolver would then reject as invalid on every boot.
await expect(
safeQuery(
admin,
`insert into ekklesia_core_module_configuration (module_id, key, value)
values ('announcements', 'oversized', repeat('x', 2049))`,
),
).rejects.toThrow();
});
it("never carries a configuration value in a store error", async () => {
let captured: unknown;
try {
await store.replace({
moduleId: "announcements",
values: [{ key: "bucket", value: "a-value-nobody-should-see".repeat(200) }],
});
} catch (error) {
captured = error;
}
const rendered = `${String(captured)} ${JSON.stringify(captured)}`;
expect(rendered).not.toContain("a-value-nobody-should-see");
});
it("retains a value for a module that is no longer deployed, and resolution ignores it", async () => {
await store.replace({
moduleId: "announcements",
values: [{ key: "bucket", value: "slides" }],
});
await store.replace({ moduleId: "retired", values: [{ key: "bucket", value: "old" }] });
const settings = await store.readSettings();
const resolved = resolveModuleConfigurationFor(
manifestWith([{ key: "bucket", type: "string", required: true }]),
{ settings },
);
expect(resolved.configuration?.getString("bucket")).toBe("slides");
// Retained rather than deleted: redeploying `retired` restores its value with no new write.
expect(settings.records.some((record) => record.moduleId === "retired")).toBe(true);
});
it("drives real validation and defaulting from real stored rows", async () => {
await store.replace({
moduleId: "announcements",
values: [
{ key: "bucket", value: "slides" },
{ key: "slideSeconds", value: "900" },
],
});
const manifest = manifestWith([
{ key: "bucket", type: "string", required: true },
{ key: "slideSeconds", type: "integer", default: 7, minimum: 1, maximum: 600 },
{ key: "pollIntervalSeconds", type: "integer", default: 30 },
]);
const resolved = resolveModuleConfigurationFor(manifest, {
settings: await store.readSettings(),
});
// 900 exceeds the module's own declared maximum: reported, never clamped and never silently
// replaced by the default.
expect(resolved.configuration).toBeUndefined();
expect(resolved.issue).toEqual({ key: "slideSeconds", reason: "configuration_invalid" });
await store.replace({
moduleId: "announcements",
values: [
{ key: "bucket", value: "slides" },
{ key: "slideSeconds", value: "12" },
],
});
const fixed = resolveModuleConfigurationFor(manifest, { settings: await store.readSettings() });
expect(fixed.configuration?.getInteger("slideSeconds")).toBe(12);
expect(fixed.configuration?.getInteger("pollIntervalSeconds")).toBe(30);
await close();
});
});