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 { createModuleLifecycleStore, createModuleLifecycleView, type ModuleLifecycleStore, ModuleLifecycleStoreError, resolveModuleEligibility, } from "../src/modules/index.js"; import { parseModuleManifest } from "@ekklesia/contracts"; import type { DiscoveredModule } from "../src/modules/discovery.js"; import { createValidatedPostgresPool } from "../src/database/index.js"; import { applyModuleMigrations, createPostgresMigrationDatabase, discoverAllMigrations, } from "../src/migrations/index.js"; /** * The module lifecycle configuration store against real PostgreSQL. * * `core/tests/module-lifecycle.test.ts` proves the resolution rules with no database at all; this * proves the things only a database can: that migration `0004` really creates the table with the * constraints the schema declares, that a decision survives a completely new connection, that the * upsert really is idempotent on `module_id`, that `enabled` has **no** column default (so the * platform's default for a new module is genuinely the absence of a row, not a materialized one), * and that a record for a module that is no longer deployed is retained and simply ignored. 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(); 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 lifecycle 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 lifecycle 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_lifecycle_${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 { 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 lifecycle integration database connection failed."); } } async function safeQuery( client: Client, text: string, values: unknown[] = [], ): Promise { try { const result = await client.query(text, values); return result.rows; } catch { throw harnessError("An integration database administration operation failed."); } } async function safeEnd(client: Client): Promise { try { await client.end(); } catch { throw harnessError("An integration database connection could not be closed."); } } async function dropDatabase(databaseName: string): Promise { 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 { const emptyModulesDirectory = await mkdtemp( path.join(tmpdir(), "ekklesia-lifecycle-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); } }); /** A minimal, in-memory `DiscoveredModule` double, built through `parseModuleManifest` so a fixture * real discovery would reject can never stand in for one it would accept. Only `manifest` is read * by eligibility resolution. */ function fakeModule(id: string, dependencies?: Record): DiscoveredModule { const manifest = parseModuleManifest({ manifestVersion: 5, id, name: `${id} module`, version: "1.0.0", compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" }, ...(dependencies ? { dependencies } : {}), entrypoints: { server: "./dist/does-not-exist/index.js" }, }); return { directory: `/modules/${id}`, entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` }, manifest, manifestPath: `/modules/${id}/ekklesia.module.json`, packageName: `@test/${id}`, }; } describe.sequential("live Core module lifecycle store", () => { let database: { name: string; url: string }; let admin: Client; let store: ModuleLifecycleStore; let close: () => Promise; it("creates exactly the one lifecycle table, with its declared constraints and no enabled default", async () => { database = await createDisposableDatabase("fresh"); await applyCoreMigrations(database.url); admin = await openAdministrativeClient(database.url); const tables = await safeQuery<{ table_name: string }>( admin, `select table_name from information_schema.tables where table_schema = 'public' and table_name = 'ekklesia_core_module_state' order by table_name`, ); const columns = await safeQuery<{ column_name: string; is_nullable: string; column_default: string | null; }>( admin, `select column_name, is_nullable, column_default from information_schema.columns where table_name = 'ekklesia_core_module_state' order by column_name`, ); // Only the explicitly declared CHECK constraint: 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_state' and tc.constraint_type = 'CHECK' and tc.constraint_name not like '%_not_null' order by tc.constraint_name`, ); expect(tables.map((table) => table.table_name)).toEqual(["ekklesia_core_module_state"]); // Deliberately minimal: an identity, a decision, and two audit timestamps. No install source, // download URL, update channel, signature, version, or JSON blob — the columns an installer // would need are absent, which is what keeps one from being built here by accident. expect(columns.map((column) => column.column_name)).toEqual([ "created_at", "enabled", "module_id", "updated_at", ]); const enabled = columns.find((column) => column.column_name === "enabled"); expect(enabled?.is_nullable).toBe("NO"); // The load-bearing assertion: a row exists only because somebody decided. The platform's // default for a newly discovered module is the *absence* of a row, resolved in // `resolveModuleEligibility` — never a column default that would materialize a decision // nobody made. expect(enabled?.column_default).toBeNull(); expect(checks.map((check) => check.constraint_name)).toEqual([ "ekklesia_core_module_state_module_check", ]); }); 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 = createModuleLifecycleStore(createModuleLifecycleView(view.pool)); expect(await store.readConfiguration()).toEqual({ modules: [] }); }); it("persists an explicit decision and reads it back over a completely new connection", async () => { await store.setEnabled({ enabled: false, moduleId: "announcements" }); const separate = createValidatedPostgresPool(database.url, { connectionTimeoutMs: 10_000, operationTimeoutMs: 10_000, }); try { const reread = await createModuleLifecycleStore( createModuleLifecycleView(separate.pool), ).readConfiguration(); expect(reread.modules).toEqual([{ enabled: false, moduleId: "announcements" }]); } finally { await separate.close(); } }); it("is idempotent on module_id: the same decision twice leaves exactly one row", async () => { await store.setEnabled({ enabled: false, moduleId: "announcements" }); await store.setEnabled({ enabled: false, moduleId: "announcements" }); const rows = await safeQuery<{ count: string }>( admin, `select count(*)::text as count from ekklesia_core_module_state where module_id = 'announcements'`, ); expect(rows[0]?.count).toBe("1"); }); it("replaces a decision in place, and bumps updated_at rather than inserting a second row", async () => { const before = await safeQuery<{ created_at: Date; updated_at: Date }>( admin, `select created_at, updated_at from ekklesia_core_module_state where module_id = 'announcements'`, ); await store.setEnabled({ enabled: true, moduleId: "announcements" }); const after = await safeQuery<{ created_at: Date; updated_at: Date }>( admin, `select created_at, updated_at from ekklesia_core_module_state where module_id = 'announcements'`, ); expect(after).toHaveLength(1); expect(after[0]?.created_at).toEqual(before[0]?.created_at); expect(after[0]?.updated_at.getTime()).toBeGreaterThanOrEqual( before[0]?.updated_at.getTime() ?? 0, ); expect((await store.readConfiguration()).modules).toEqual([ { enabled: true, moduleId: "announcements" }, ]); }); it("returns records in deterministic module-id order", async () => { await store.setEnabled({ enabled: false, moduleId: "video" }); await store.setEnabled({ enabled: true, moduleId: "calendar" }); expect((await store.readConfiguration()).modules.map((record) => record.moduleId)).toEqual([ "announcements", "calendar", "video", ]); }); it("rejects a malformed module identifier before it ever reaches PostgreSQL", async () => { await expect(store.setEnabled({ enabled: false, moduleId: "Not A Module" })).rejects.toThrow( ModuleLifecycleStoreError, ); await expect(store.setEnabled({ enabled: false, moduleId: "" })).rejects.toMatchObject({ code: "MODULE_LIFECYCLE_MODULE_ID_INVALID", }); const rows = await safeQuery<{ count: string }>( admin, `select count(*)::text as count from ekklesia_core_module_state`, ); expect(rows[0]?.count).toBe("3"); }); it("rejects an empty module id at the database level too, beneath the application contract", async () => { await expect( safeQuery( admin, `insert into ekklesia_core_module_state (module_id, enabled) values ('', true)`, ), ).rejects.toThrow(); }); it("retains a record for a module that is no longer deployed, and resolution ignores it", async () => { // The whole configuration, including the row for `video`, which is not in this inventory. const configuration = await store.readConfiguration(); const deployed = [fakeModule("announcements"), fakeModule("calendar")]; const resolution = resolveModuleEligibility(deployed, configuration); expect(resolution.entries.map((entry) => entry.moduleId)).toEqual([ "announcements", "calendar", ]); expect(resolution.eligibleModules.map((module) => module.manifest.id)).toEqual([ "announcements", "calendar", ]); // Retained, not deleted: redeploying `video` restores the decision with no new write. expect(configuration.modules.some((record) => record.moduleId === "video")).toBe(true); const redeployed = resolveModuleEligibility([...deployed, fakeModule("video")], configuration); expect(redeployed.eligibleModules.map((module) => module.manifest.id)).toEqual([ "announcements", "calendar", ]); }); it("drives real dependency blocking from real stored rows", async () => { await store.setEnabled({ enabled: false, moduleId: "storage" }); const resolution = resolveModuleEligibility( [ fakeModule("storage"), fakeModule("announcements", { modules: { required: { storage: "^1.0.0" } } }), ], await store.readConfiguration(), ); expect(resolution.eligibleModules).toEqual([]); expect(resolution.entries.find((entry) => entry.moduleId === "announcements")?.blocked).toEqual( { dependency: { kind: "module", moduleId: "storage" }, reason: "required_dependency_disabled", }, ); await close(); }); });