460 lines
16 KiB
TypeScript
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: 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();
|
|
});
|
|
});
|