Files
Ekklesia/core/tests/module-configuration-store.integration.ts
T
ekklesiaandClaude Opus 5 baa689b2a7
CI / verify (pull_request) Successful in 35s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 46s
feat: add the module configuration and secrets contract
Modules had no way to be told anything. Announcements — the first real
product Ekklesia will absorb — has zero environment variables and compiles
a LAN IP address into its source, so this is the contract standing between
it and the module system.

- A module declares what it needs in its manifest, using six bounded types
  (string, integer, boolean, enum, url, secret) with no nesting and no
  regular expressions. Static declaration is what lets the platform validate
  configuration *before* deciding whether a module may start: an enabled
  module missing a required value is reported `blocked ·
  configuration_missing · <key>` rather than taking startup down from inside
  its own `start` hook, and Doctor reports the same while still importing no
  module code. That forces `manifestVersion: 5`, a hard cutover.
- Ordinary values persist in one Core table (migration 0005), one row per
  (module_id, key), stored as text until validated against the declaring
  module's current manifest. Nothing is coerced; an invalid stored value
  blocks the module rather than falling back to the default. A key the
  manifest no longer declares is retained and ignored.
- Secrets are never persisted. There is no encryption at rest in this
  repository, so a `secret` key is deployment-supplied and mapped in by the
  host under a name the module never sees. A secret declaration has no
  `default` field, the write API refuses secret material, and the HTTP view
  has no `value` field for one — absent by construction, not filtered.
- Modules receive a frozen `context.config` on the registration, runtime,
  and HTTP handler contexts: the same object identity, per module, beside
  `services` rather than inside it (services is deliberately one shared
  instance). No environment access, no database reference, no way to reach
  another module's values.
- Configuration decides eligibility through ADR 0028's existing resolver:
  three new blocked reasons, each with a distinct operator fix, propagating
  to required dependents through the same mechanism as a disabled module.
- Changes take effect at the next restart; both resources report
  restartRequired. New restricted permission
  `core.modules.configuration.manage`, separate from lifecycle, at the
  sibling prefix `/api/v1/module-configuration`.
- Migration participation stays independent of configuration validity.
  Endpoints and credentials for shared infrastructure stay platform
  concerns; a module declares which bucket it needs, not an S3 endpoint.

See ADR 0029.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-22 21:44:43 -05:00

460 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 { Client, type QueryResultRow } from "pg";
import { afterAll, describe, expect, it } from "vitest";
import {
createModuleConfigurationStore,
createModuleConfigurationView,
type ModuleConfigurationStore,
ModuleConfigurationStoreError,
resolveModuleConfigurationFor,
} from "../src/modules/index.js";
import { parseModuleManifest } from "@ekklesia/contracts";
import { createValidatedPostgresPool } from "../src/database/index.js";
import {
applyModuleMigrations,
createPostgresMigrationDatabase,
discoverAllMigrations,
} from "../src/migrations/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",
]);
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: 5,
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();
});
});