Files
Ekklesia/core/tests/dashboard-store.integration.ts
T
ekklesiaandClaude Opus 5 7729c99e24
CI / verify (pull_request) Successful in 35s
CI / infrastructure-smoke (pull_request) Successful in 13s
CI / migration-smoke (pull_request) Successful in 46s
feat: add the minimal operational module lifecycle
Ekklesia had no notion of a module being on or off: the accepted module set
was exactly "whatever discovery found on disk", and `ModuleLifecycleStage`
named nine states nothing produced or consumed. This replaces that vocabulary
with two axes that real code produces — persistent, administrator-owned
configuration (`enabled`/`disabled`) and per-boot runtime observation
(`running`/`disabled`/`blocked`) — and wires them through startup.

- One Core table (`ekklesia_core_module_state`, migration 0004) holding only
  `module_id` and `enabled`. A module with no record is enabled by default, so
  deploying a module is still sufficient to run it and discovery never needs an
  initialization write. A record for an undeployed module is retained and
  ignored, so redeploying restores the decision.
- `resolveModuleEligibility` is pure: a disabled module transitively blocks
  every module that *requires* it (module and capability edges alike) with a
  structured reason naming the provider; optional dependencies never block; and
  a missing, incompatible, or cyclic dependency stays discovery's concern.
- `startApi` starts only the eligible set and builds the UI publication
  registry from that same list, so routes, artifacts, browser inventory, and
  Dashboard navigation all follow one authoritative set. A disabled module is
  never imported, so there is no per-request enablement check anywhere.
- Startup stays all-or-nothing, deliberately; there is consequently no `failed`
  runtime state. `pnpm modules:enable`/`modules:disable` is the out-of-band
  recovery path for a module that breaks startup.
- Changes take effect at the next start, and the read model reports
  configuration, runtime, and `restartRequired` separately rather than claiming
  a module stopped when it has not.
- Migration participation and permission composition still span the deployed
  and discovered inventories respectively, unchanged.
- New restricted platform permission `core.modules.lifecycle.manage` gates both
  reading and changing enablement, at a sibling prefix (`/api/v1/module-lifecycle`)
  that cannot collide with the module route namespace.
- `pnpm doctor` gains a `modules.lifecycle` check so "why is this module
  unavailable?" is answerable without reading logs.

See ADR 0028.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-22 14:45:34 -05:00

417 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 {
createDashboardDrizzleView,
createDashboardStore,
type DashboardStore,
DashboardStoreError,
resolveDashboardNavigation,
} from "../src/dashboard/index.js";
import { createValidatedPostgresPool } from "../src/database/index.js";
import {
applyModuleMigrations,
createPostgresMigrationDatabase,
discoverAllMigrations,
} from "../src/migrations/index.js";
/**
* The Dashboard navigation store against real PostgreSQL.
*
* The unit suites prove the resolution rules; this proves the things only a database can: that the
* migration really creates these tables, that configuration survives a completely new connection,
* that `on delete set null` really ungroups a deleted group's modules, and that a placement can
* never point at a group that does not exist. The in-memory double used by the API route tests
* deliberately reproduces only two of those behaviours, so this file is where they are actually
* established rather than assumed.
*/
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 Dashboard 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 Dashboard 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, 14);
databaseCounter += 1;
return `ekk_dash_${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 Dashboard 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-dash-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",
]);
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);
}
});
describe.sequential("live Core Dashboard navigation store", () => {
let database: { name: string; url: string };
let admin: Client;
let store: DashboardStore;
let close: () => Promise<void>;
it("creates exactly the two Dashboard tables, with their constraints", 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 like 'ekklesia_core_dashboard%'
order by table_name`,
);
const constraints = await safeQuery<{ constraint_name: string; delete_rule: string }>(
admin,
`select rc.constraint_name, rc.delete_rule
from information_schema.referential_constraints rc
join information_schema.table_constraints tc
on tc.constraint_name = rc.constraint_name
where tc.table_name = 'ekklesia_core_dashboard_placement'`,
);
expect(tables.map((table) => table.table_name)).toEqual([
"ekklesia_core_dashboard_group",
"ekklesia_core_dashboard_placement",
]);
// The database, not the application, is what guarantees a deleted group leaves no dangling
// reference.
expect(constraints.map((constraint) => constraint.delete_rule)).toEqual(["SET NULL"]);
const handle = createValidatedPostgresPool(database.url);
close = handle.close;
store = createDashboardStore(createDashboardDrizzleView(handle.pool));
});
it("starts empty, so an installation with no configuration is a normal state", async () => {
expect(await store.readConfiguration()).toEqual({ groups: [], placements: [] });
});
it("persists groups and placements, and reads them back in deterministic order", async () => {
await store.upsertGroup({ id: "planning", label: "Planning", sortOrder: 20 });
await store.upsertGroup({ id: "media", label: "Media", sortOrder: 10 });
await store.setPlacement({ groupId: "media", moduleId: "livestream", sortOrder: 20 });
await store.setPlacement({ groupId: "media", moduleId: "video", sortOrder: 10 });
await store.setPlacement({ groupId: "planning", moduleId: "calendar", sortOrder: 10 });
const configuration = await store.readConfiguration();
expect(configuration.groups.map((group) => group.id)).toEqual(["media", "planning"]);
expect(configuration.placements.map((placement) => placement.moduleId)).toEqual([
"calendar",
"video",
"livestream",
]);
});
it("survives a completely new connection, so a church configures its Dashboard once", async () => {
// A different pool, as a restarted process would use — nothing is cached in the old one.
const reopened = createValidatedPostgresPool(database.url);
try {
const configuration = await createDashboardStore(
createDashboardDrizzleView(reopened.pool),
).readConfiguration();
const view = resolveDashboardNavigation(configuration, [
{ id: "livestream", name: "Livestream" },
{ id: "video", name: "Video" },
{ id: "calendar", name: "Calendar" },
]);
expect(
view.groups.map((group) => [group.label, group.modules.map((entry) => entry.name)]),
).toEqual([
["Media", ["Video", "Livestream"]],
["Planning", ["Calendar"]],
]);
} finally {
await reopened.close();
}
});
it("renames a group without disturbing a single placement", async () => {
const before = await store.readConfiguration();
await store.upsertGroup({ id: "media", label: "Media & Streaming", sortOrder: 10 });
const after = await store.readConfiguration();
expect(after.groups.find((group) => group.id === "media")?.label).toBe("Media & Streaming");
// The stable id is what placements point at, so a rename can never re-organize the Dashboard.
expect(after.placements).toEqual(before.placements);
});
it("re-orders a group and a module in place, without creating duplicates", async () => {
await store.upsertGroup({ id: "media", label: "Media & Streaming", sortOrder: 30 });
await store.setPlacement({ groupId: "media", moduleId: "video", sortOrder: 99 });
const configuration = await store.readConfiguration();
expect(configuration.groups).toHaveLength(2);
expect(configuration.groups.map((group) => group.id)).toEqual(["planning", "media"]);
expect(configuration.placements.filter((entry) => entry.moduleId === "video")).toHaveLength(1);
expect(configuration.placements.find((entry) => entry.moduleId === "video")?.sortOrder).toBe(
99,
);
});
it("moves a module between groups by changing exactly one row", async () => {
await store.upsertGroup({ id: "sunday-production", label: "Sunday Production", sortOrder: 5 });
await store.setPlacement({
groupId: "sunday-production",
moduleId: "livestream",
sortOrder: 10,
});
const configuration = await store.readConfiguration();
expect(configuration.placements.find((entry) => entry.moduleId === "livestream")?.groupId).toBe(
"sunday-production",
);
// Video did not move.
expect(configuration.placements.find((entry) => entry.moduleId === "video")?.groupId).toBe(
"media",
);
});
it("ungroups a module deliberately, which is distinct from having no record", async () => {
await store.setPlacement({ groupId: null, moduleId: "calendar", sortOrder: 7 });
const configuration = await store.readConfiguration();
const calendar = configuration.placements.find((entry) => entry.moduleId === "calendar");
expect(calendar).toEqual({ groupId: null, moduleId: "calendar", sortOrder: 7 });
});
it("ungroups a deleted group's modules instead of leaving a dangling reference", async () => {
await store.deleteGroup("sunday-production");
const configuration = await store.readConfiguration();
const groupIds = new Set(configuration.groups.map((group) => group.id));
expect(groupIds.has("sunday-production")).toBe(false);
expect(
configuration.placements.find((entry) => entry.moduleId === "livestream")?.groupId,
).toBeNull();
// No placement anywhere names a group that no longer exists.
for (const placement of configuration.placements) {
if (placement.groupId !== null) {
expect(groupIds.has(placement.groupId)).toBe(true);
}
}
});
it("refuses a placement naming a group that was never created", async () => {
await expect(
store.setPlacement({ groupId: "not-a-group", moduleId: "video", sortOrder: 10 }),
).rejects.toMatchObject({ code: "DASHBOARD_GROUP_NOT_FOUND" });
});
it("refuses to delete a group that does not exist", async () => {
await expect(store.deleteGroup("not-a-group")).rejects.toMatchObject({
code: "DASHBOARD_GROUP_NOT_FOUND",
});
});
it("stores a placement for a module that is not installed, and renders nothing for it", async () => {
await store.upsertGroup({ id: "media", label: "Media & Streaming", sortOrder: 30 });
await store.setPlacement({ groupId: "media", moduleId: "uninstalled", sortOrder: 1 });
const configuration = await store.readConfiguration();
const view = resolveDashboardNavigation(configuration, [{ id: "video", name: "Video" }]);
// Retained, so a reinstall restores the position...
expect(configuration.placements.map((entry) => entry.moduleId)).toContain("uninstalled");
// ...and never rendered while the module is absent.
expect(
[...view.groups.flatMap((group) => group.modules), ...view.ungrouped].map(
(entry) => entry.id,
),
).toEqual(["video"]);
});
it("rejects malformed input before it reaches the database", async () => {
await expect(
store.upsertGroup({ id: "Not Kebab Case", label: "Nope", sortOrder: 0 }),
).rejects.toBeInstanceOf(DashboardStoreError);
await expect(
store.upsertGroup({ id: "media", label: " ", sortOrder: 0 }),
).rejects.toMatchObject({ code: "DASHBOARD_GROUP_LABEL_INVALID" });
await expect(
store.upsertGroup({ id: "media", label: "x".repeat(121), sortOrder: 0 }),
).rejects.toMatchObject({ code: "DASHBOARD_GROUP_LABEL_INVALID" });
await expect(
store.upsertGroup({ id: "media", label: "Media", sortOrder: -1 }),
).rejects.toMatchObject({ code: "DASHBOARD_SORT_ORDER_INVALID" });
await expect(
store.setPlacement({ groupId: null, moduleId: "Not Valid", sortOrder: 0 }),
).rejects.toMatchObject({ code: "DASHBOARD_MODULE_ID_INVALID" });
await expect(
store.setPlacement({ groupId: null, moduleId: "video", sortOrder: 1.5 }),
).rejects.toMatchObject({ code: "DASHBOARD_SORT_ORDER_INVALID" });
});
it("trims a stored label rather than persisting surrounding whitespace", async () => {
await store.upsertGroup({ id: "planning", label: " Planning ", sortOrder: 20 });
const configuration = await store.readConfiguration();
expect(configuration.groups.find((group) => group.id === "planning")?.label).toBe("Planning");
});
it("leaks no database detail in a sanitized failure", async () => {
await close();
const error = await store.readConfiguration().catch((caught: unknown) => caught);
const text = `${String(error)}\n${(error as Error).stack ?? ""}`;
expect(error).toMatchObject({ code: "DASHBOARD_READ_FAILED" });
expect(text).not.toContain(database.name);
expect(text).not.toContain("select");
expect(text).not.toContain("ekklesia_core_dashboard");
await safeEnd(admin);
await dropDatabase(database.name);
});
});