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]>
417 lines
16 KiB
TypeScript
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);
|
|
});
|
|
});
|