Files
Ekklesia/core/tests/module-lifecycle-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

422 lines
15 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 type { DiscoveredModule } from "../src/modules/discovery.js";
import {
createModuleLifecycleStore,
createModuleLifecycleView,
type ModuleLifecycleStore,
ModuleLifecycleStoreError,
resolveModuleEligibility,
} from "../src/modules/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<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 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<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 lifecycle 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-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",
"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);
}
});
/** 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<string, unknown>): DiscoveredModule {
const manifest = parseModuleManifest({
manifestVersion: 8,
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 {
contributions: [],
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<void>;
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();
});
});