Modules had no way to be told anything. Announcements — the first real product Ekklesia will absorb — has zero environment variables and compiles a LAN IP address into its source, so this is the contract standing between it and the module system. - A module declares what it needs in its manifest, using six bounded types (string, integer, boolean, enum, url, secret) with no nesting and no regular expressions. Static declaration is what lets the platform validate configuration *before* deciding whether a module may start: an enabled module missing a required value is reported `blocked · configuration_missing · <key>` rather than taking startup down from inside its own `start` hook, and Doctor reports the same while still importing no module code. That forces `manifestVersion: 5`, a hard cutover. - Ordinary values persist in one Core table (migration 0005), one row per (module_id, key), stored as text until validated against the declaring module's current manifest. Nothing is coerced; an invalid stored value blocks the module rather than falling back to the default. A key the manifest no longer declares is retained and ignored. - Secrets are never persisted. There is no encryption at rest in this repository, so a `secret` key is deployment-supplied and mapped in by the host under a name the module never sees. A secret declaration has no `default` field, the write API refuses secret material, and the HTTP view has no `value` field for one — absent by construction, not filtered. - Modules receive a frozen `context.config` on the registration, runtime, and HTTP handler contexts: the same object identity, per module, beside `services` rather than inside it (services is deliberately one shared instance). No environment access, no database reference, no way to reach another module's values. - Configuration decides eligibility through ADR 0028's existing resolver: three new blocked reasons, each with a distinct operator fix, propagating to required dependents through the same mechanism as a disabled module. - Changes take effect at the next restart; both resources report restartRequired. New restricted permission `core.modules.configuration.manage`, separate from lifecycle, at the sibling prefix `/api/v1/module-configuration`. - Migration participation stays independent of configuration validity. Endpoints and credentials for shared infrastructure stay platform concerns; a module declares which bucket it needs, not an S3 endpoint. See ADR 0029. Co-Authored-By: Claude Opus 5 <[email protected]>
418 lines
16 KiB
TypeScript
418 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",
|
|
"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);
|
|
}
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|