Files
Ekklesia/core/tests/module-configuration-store.integration.ts
T
ekklesia 49b4d4165d
CI / verify (pull_request) Successful in 1m0s
CI / infrastructure-smoke (pull_request) Successful in 36s
CI / migration-smoke (pull_request) Successful in 1m53s
CI / image-build (pull_request) Successful in 23s
CI / deploy-smoke (same-origin) (pull_request) Successful in 50s
CI / deploy-smoke (split) (pull_request) Successful in 1m30s
feat: deliver module database ABI by manifest
2026-10-07 17:29:32 -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: 8,
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();
});
});