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]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
db67cf7700
commit
7729c99e24
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
||||
CORE_DASHBOARD_NAVIGATION_MANAGE,
|
||||
CORE_MODULES_LIFECYCLE_MANAGE,
|
||||
CORE_MODULES_UI_READ,
|
||||
CORE_PERMISSION_DECLARATIONS,
|
||||
CORE_PERMISSION_REGISTRY,
|
||||
@@ -110,11 +111,22 @@ describe("CORE_PERMISSION_REGISTRY", () => {
|
||||
expect(declaration?.id).toBe("core.dashboard.navigation.manage");
|
||||
});
|
||||
|
||||
it("contains exactly these three permissions and no others", () => {
|
||||
it("registers core.modules.lifecycle.manage as restricted", () => {
|
||||
const declaration = CORE_PERMISSION_REGISTRY.get(CORE_MODULES_LIFECYCLE_MANAGE);
|
||||
expect(declaration).toBeDefined();
|
||||
// Deciding which installed modules run is administrative, and so is *reading* that: the
|
||||
// lifecycle view is the only place a disabled or blocked module is visible at all, unlike the
|
||||
// merely-authenticated mountable inventory (ADR 0028).
|
||||
expect(declaration?.baseline).toBe("restricted");
|
||||
expect(declaration?.id).toBe("core.modules.lifecycle.manage");
|
||||
});
|
||||
|
||||
it("contains exactly these four permissions and no others", () => {
|
||||
expect(CORE_PERMISSION_REGISTRY.declarations.map((declaration) => declaration.id)).toEqual([
|
||||
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
||||
CORE_MODULES_UI_READ,
|
||||
CORE_DASHBOARD_NAVIGATION_MANAGE,
|
||||
CORE_MODULES_LIFECYCLE_MANAGE,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -137,11 +149,16 @@ describe("CORE_PERMISSION_DECLARATIONS", () => {
|
||||
description: "Organize the Dashboard: create, rename, order, and delete navigation groups.",
|
||||
id: CORE_DASHBOARD_NAVIGATION_MANAGE,
|
||||
},
|
||||
{
|
||||
baseline: "restricted",
|
||||
description: "Read and change which installed modules are enabled.",
|
||||
id: CORE_MODULES_LIFECYCLE_MANAGE,
|
||||
},
|
||||
]);
|
||||
expect(Object.isFrozen(CORE_PERMISSION_DECLARATIONS)).toBe(true);
|
||||
expect(Object.isFrozen(CORE_PERMISSION_DECLARATIONS[0])).toBe(true);
|
||||
expect(Object.isFrozen(CORE_PERMISSION_DECLARATIONS[1])).toBe(true);
|
||||
expect(Object.isFrozen(CORE_PERMISSION_DECLARATIONS[2])).toBe(true);
|
||||
for (const declaration of CORE_PERMISSION_DECLARATIONS) {
|
||||
expect(Object.isFrozen(declaration)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("matches exactly what CORE_PERMISSION_REGISTRY exposes as its own declarations, so a composer building on top of it never diverges from what Core itself registers", () => {
|
||||
|
||||
@@ -174,6 +174,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
||||
"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 {
|
||||
@@ -228,6 +229,7 @@ describe.sequential("live Core authorization store", () => {
|
||||
"ekklesia_core_dashboard_placement",
|
||||
"ekklesia_core_grant",
|
||||
"ekklesia_core_migrations",
|
||||
"ekklesia_core_module_state",
|
||||
"ekklesia_core_platform_owner",
|
||||
"ekklesia_core_platform_owner_bootstrap",
|
||||
"ekklesia_core_session",
|
||||
|
||||
@@ -173,6 +173,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
||||
"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 {
|
||||
|
||||
@@ -47,6 +47,9 @@ function noConfigurationDependencies(): DoctorDependencies {
|
||||
planMigrations: async () => {
|
||||
throw new Error("database must not be planned");
|
||||
},
|
||||
readModuleLifecycle: async () => {
|
||||
throw new Error("module lifecycle configuration must not be read");
|
||||
},
|
||||
runtimeSatisfies: () => true,
|
||||
};
|
||||
}
|
||||
@@ -78,7 +81,9 @@ Modules
|
||||
✓ Dependency graph valid
|
||||
Dependency order is empty.
|
||||
✓ Permission registry composed
|
||||
3 permissions registered
|
||||
4 permissions registered
|
||||
○ Module lifecycle skipped
|
||||
DATABASE_URL is not configured.
|
||||
|
||||
Data definitions
|
||||
✓ Schemas validated
|
||||
|
||||
@@ -137,6 +137,7 @@ function dependencies(overrides: Partial<DoctorDependencies> = {}): DoctorDepend
|
||||
discoverModules: vi.fn(async () => Object.freeze([])),
|
||||
discoverSchemas: vi.fn(async () => schemaAggregate()),
|
||||
planMigrations: vi.fn(async () => plan()),
|
||||
readModuleLifecycle: vi.fn(async () => Object.freeze({ modules: Object.freeze([]) })),
|
||||
runtimeSatisfies: vi.fn(() => true),
|
||||
...overrides,
|
||||
};
|
||||
@@ -204,6 +205,7 @@ describe("Ekklesia Doctor diagnostics", () => {
|
||||
"modules.discovery",
|
||||
"modules.dependencies",
|
||||
"modules.permissions",
|
||||
"modules.lifecycle",
|
||||
"schemas.discovery",
|
||||
"migrations.discovery",
|
||||
"database.transport",
|
||||
@@ -844,3 +846,173 @@ describe("Ekklesia Doctor diagnostics", () => {
|
||||
expect(services.createMigrationDatabase).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
const databaseUrl = "postgresql://not-rendered.invalid/ekklesia";
|
||||
|
||||
describe("Ekklesia Doctor — module lifecycle", () => {
|
||||
/** A discovered module double with a real required-dependency edge, so blocking is exercised
|
||||
* through the same resolver production uses rather than a hand-written eligibility record. */
|
||||
function dependentModule(id: string, requires: string): DiscoveredModule {
|
||||
const base = discoveredModule(id);
|
||||
return {
|
||||
...base,
|
||||
manifest: {
|
||||
...base.manifest,
|
||||
dependencies: {
|
||||
capabilities: { optional: {}, required: {} },
|
||||
modules: { optional: {}, required: { [requires]: "^0.1.0" } },
|
||||
},
|
||||
} as unknown as ModuleManifest,
|
||||
};
|
||||
}
|
||||
|
||||
it("skips the check, rather than guessing a default, when DATABASE_URL is not configured", async () => {
|
||||
const services = dependencies({
|
||||
discoverModules: vi.fn(async () => Object.freeze([discoveredModule("calendar")])),
|
||||
});
|
||||
|
||||
const report = await diagnoseEkklesia(options(), services);
|
||||
|
||||
expect(check(report, "modules.lifecycle").status).toBe("skipped");
|
||||
expect(check(report, "modules.lifecycle").details).toEqual(["DATABASE_URL is not configured."]);
|
||||
// Configuration lives in PostgreSQL; reporting "everything is enabled" without reading it would
|
||||
// be a guess an operator could act on.
|
||||
expect(services.readModuleLifecycle).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips the check when discovery itself failed", async () => {
|
||||
const services = dependencies({
|
||||
discoverModules: vi.fn(async () => {
|
||||
throw new Error("broken inventory");
|
||||
}),
|
||||
});
|
||||
|
||||
const report = await diagnoseEkklesia(options(databaseUrl), services);
|
||||
|
||||
expect(check(report, "modules.lifecycle").status).toBe("skipped");
|
||||
expect(check(report, "modules.lifecycle").details).toEqual([
|
||||
"Complete module discovery is required.",
|
||||
]);
|
||||
});
|
||||
|
||||
it("passes and lists each module when everything enabled can run", async () => {
|
||||
const services = dependencies({
|
||||
discoverModules: vi.fn(async () =>
|
||||
Object.freeze([discoveredModule("storage"), discoveredModule("calendar", "0.2.0")]),
|
||||
),
|
||||
});
|
||||
|
||||
const report = await diagnoseEkklesia(options(databaseUrl), services);
|
||||
|
||||
expect(check(report, "modules.lifecycle").status).toBe("pass");
|
||||
expect(check(report, "modules.lifecycle").details).toEqual([
|
||||
"2 eligible · 0 disabled · 0 blocked",
|
||||
"storage 0.1.0 · enabled",
|
||||
"calendar 0.2.0 · enabled",
|
||||
]);
|
||||
});
|
||||
|
||||
it("still passes when a module is deliberately disabled — a decision is not a problem", async () => {
|
||||
const services = dependencies({
|
||||
discoverModules: vi.fn(async () => Object.freeze([discoveredModule("calendar")])),
|
||||
readModuleLifecycle: vi.fn(async () =>
|
||||
Object.freeze({ modules: Object.freeze([{ enabled: false, moduleId: "calendar" }]) }),
|
||||
),
|
||||
});
|
||||
|
||||
const report = await diagnoseEkklesia(options(databaseUrl), services);
|
||||
|
||||
expect(check(report, "modules.lifecycle").status).toBe("pass");
|
||||
expect(check(report, "modules.lifecycle").details).toEqual([
|
||||
"0 eligible · 1 disabled · 0 blocked",
|
||||
"calendar 0.1.0 · disabled",
|
||||
]);
|
||||
});
|
||||
|
||||
it("distinguishes the platform default from a decision somebody made", async () => {
|
||||
const services = dependencies({
|
||||
discoverModules: vi.fn(async () => Object.freeze([discoveredModule("calendar")])),
|
||||
readModuleLifecycle: vi.fn(async () => Object.freeze({ modules: Object.freeze([]) })),
|
||||
});
|
||||
|
||||
const report = await diagnoseEkklesia(options(databaseUrl), services);
|
||||
|
||||
// Nothing stored: the module is enabled by the platform default, and the detail says so.
|
||||
expect(check(report, "modules.lifecycle").details).toContain("calendar 0.1.0 · enabled");
|
||||
});
|
||||
|
||||
it("warns when an enabled module cannot run, naming the dependency an operator must act on", async () => {
|
||||
const services = dependencies({
|
||||
discoverModules: vi.fn(async () =>
|
||||
Object.freeze([discoveredModule("storage"), dependentModule("announcements", "storage")]),
|
||||
),
|
||||
readModuleLifecycle: vi.fn(async () =>
|
||||
Object.freeze({ modules: Object.freeze([{ enabled: false, moduleId: "storage" }]) }),
|
||||
),
|
||||
});
|
||||
|
||||
const report = await diagnoseEkklesia(options(databaseUrl), services);
|
||||
const lifecycle = check(report, "modules.lifecycle");
|
||||
|
||||
// An enabled module that cannot run is the anomaly worth surfacing — and it is exactly the
|
||||
// question an operator would otherwise take to the logs.
|
||||
expect(lifecycle.status).toBe("warning");
|
||||
expect(lifecycle.summary).toBe("1 enabled module cannot run");
|
||||
expect(lifecycle.details).toEqual([
|
||||
"0 eligible · 1 disabled · 1 blocked",
|
||||
"storage 0.1.0 · disabled",
|
||||
"announcements 0.1.0 · blocked · required_dependency_disabled · storage",
|
||||
]);
|
||||
expect(report.result).toBe("ready-with-warnings");
|
||||
});
|
||||
|
||||
it("fails safely, without a driver detail, when the configuration cannot be read", async () => {
|
||||
const services = dependencies({
|
||||
discoverModules: vi.fn(async () => Object.freeze([discoveredModule("calendar")])),
|
||||
readModuleLifecycle: vi.fn(async () => {
|
||||
throw new Error("connect ECONNREFUSED 10.40.0.2:5432 password=hunter2");
|
||||
}),
|
||||
});
|
||||
|
||||
const report = await diagnoseEkklesia(options(databaseUrl), services);
|
||||
const lifecycle = check(report, "modules.lifecycle");
|
||||
|
||||
expect(lifecycle.status).toBe("failure");
|
||||
expect(lifecycle.details).toEqual([
|
||||
"The stored module enablement configuration could not be read safely.",
|
||||
]);
|
||||
expect(JSON.stringify(lifecycle)).not.toContain("hunter2");
|
||||
expect(JSON.stringify(lifecycle)).not.toContain("10.40.0.2");
|
||||
});
|
||||
|
||||
it("reads with finite timeouts and the same validated transport policy every executable uses", async () => {
|
||||
const services = dependencies({
|
||||
discoverModules: vi.fn(async () => Object.freeze([discoveredModule("calendar")])),
|
||||
});
|
||||
|
||||
await diagnoseEkklesia(options(databaseUrl), services);
|
||||
|
||||
expect(services.readModuleLifecycle).toHaveBeenCalledWith(
|
||||
databaseUrl,
|
||||
expect.objectContaining({
|
||||
connectionTimeoutMs: 10_000,
|
||||
operationTimeoutMs: 10_000,
|
||||
transport: expect.objectContaining({ mode: "verify-full" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("still reports lifecycle when an unrelated check fails, and never blocks on one", async () => {
|
||||
const services = dependencies({
|
||||
composePermissions: vi.fn(() => {
|
||||
throw new Error("composition exploded");
|
||||
}),
|
||||
discoverModules: vi.fn(async () => Object.freeze([discoveredModule("calendar")])),
|
||||
});
|
||||
|
||||
const report = await diagnoseEkklesia(options(databaseUrl), services);
|
||||
|
||||
expect(check(report, "modules.permissions").status).toBe("failure");
|
||||
expect(check(report, "modules.lifecycle").status).toBe("pass");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
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<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",
|
||||
]);
|
||||
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: 4,
|
||||
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<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 like 'ekklesia_core_module%'
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,529 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { parseModuleManifest } from "@ekklesia/contracts";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { type DiscoveredModule, discoverModules } from "../src/modules/discovery.js";
|
||||
import {
|
||||
type ModuleLifecycleConfiguration,
|
||||
resolveModuleEligibility,
|
||||
resolveModuleLifecycleView,
|
||||
} from "../src/modules/lifecycle.js";
|
||||
|
||||
/** A minimal, in-memory `DiscoveredModule` double, built the same way `module-permissions.test.ts`
|
||||
* builds one: the manifest still goes through `parseModuleManifest`, so a fixture real discovery
|
||||
* would reject can never silently stand in for one it would accept. Only `manifest` is read by
|
||||
* eligibility resolution; the rest exist to satisfy the type. */
|
||||
function fakeModule(
|
||||
id: string,
|
||||
options: {
|
||||
readonly capabilities?: readonly { readonly id: string; readonly version: string }[];
|
||||
readonly dependencies?: Record<string, unknown>;
|
||||
} = {},
|
||||
): DiscoveredModule {
|
||||
const manifest = parseModuleManifest({
|
||||
manifestVersion: 4,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
|
||||
capabilities: options.capabilities ?? [],
|
||||
...(options.dependencies ? { dependencies: options.dependencies } : {}),
|
||||
entrypoints: {
|
||||
// Deliberately a path nothing will ever resolve: eligibility resolution must never import,
|
||||
// evaluate, or otherwise touch a module's server entrypoint.
|
||||
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}`,
|
||||
};
|
||||
}
|
||||
|
||||
function configuration(
|
||||
...records: readonly (readonly [string, boolean])[]
|
||||
): ModuleLifecycleConfiguration {
|
||||
return { modules: records.map(([moduleId, enabled]) => ({ moduleId, enabled })) };
|
||||
}
|
||||
|
||||
function ids(modules: readonly DiscoveredModule[]): readonly string[] {
|
||||
return modules.map((module) => module.manifest.id);
|
||||
}
|
||||
|
||||
function entry(
|
||||
resolution: ReturnType<typeof resolveModuleEligibility>,
|
||||
moduleId: string,
|
||||
): ReturnType<typeof resolveModuleEligibility>["entries"][number] {
|
||||
const found = resolution.entries.find((candidate) => candidate.moduleId === moduleId);
|
||||
if (!found) {
|
||||
throw new Error(`No eligibility entry for ${moduleId}.`);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
describe("resolveModuleEligibility — default policy for a newly discovered module", () => {
|
||||
it("treats a module with no stored record as enabled, so deploying a module is enough to run it", () => {
|
||||
const resolution = resolveModuleEligibility([fakeModule("announcements")], configuration());
|
||||
|
||||
expect(ids(resolution.eligibleModules)).toEqual(["announcements"]);
|
||||
expect(entry(resolution, "announcements").configured).toEqual({
|
||||
enabled: true,
|
||||
explicit: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("distinguishes the default from a decision somebody actually made", () => {
|
||||
const resolution = resolveModuleEligibility(
|
||||
[fakeModule("announcements")],
|
||||
configuration(["announcements", true]),
|
||||
);
|
||||
|
||||
// Same effect, different provenance — which is exactly what an operator needs to know when
|
||||
// they are wondering whether a module is deliberately on.
|
||||
expect(entry(resolution, "announcements").configured).toEqual({
|
||||
enabled: true,
|
||||
explicit: true,
|
||||
});
|
||||
expect(ids(resolution.eligibleModules)).toEqual(["announcements"]);
|
||||
});
|
||||
|
||||
it("never writes, and never enables anything: an explicit disable always wins over the default", () => {
|
||||
const resolution = resolveModuleEligibility(
|
||||
[fakeModule("announcements")],
|
||||
configuration(["announcements", false]),
|
||||
);
|
||||
|
||||
expect(resolution.eligibleModules).toEqual([]);
|
||||
expect(entry(resolution, "announcements")).toEqual({
|
||||
configured: { enabled: false, explicit: true },
|
||||
eligible: false,
|
||||
moduleId: "announcements",
|
||||
version: "0.1.0",
|
||||
});
|
||||
// A disabled module is not "blocked" — it is doing exactly what it was told.
|
||||
expect(entry(resolution, "announcements").blocked).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores a stored record for a module that is not deployed, rather than failing startup", () => {
|
||||
const resolution = resolveModuleEligibility(
|
||||
[fakeModule("announcements")],
|
||||
configuration(["announcements", true], ["removed-long-ago", false]),
|
||||
);
|
||||
|
||||
expect(resolution.entries.map((candidate) => candidate.moduleId)).toEqual(["announcements"]);
|
||||
expect(ids(resolution.eligibleModules)).toEqual(["announcements"]);
|
||||
});
|
||||
|
||||
it("restores a retained decision when the module is deployed again", () => {
|
||||
const stale = configuration(["announcements", false]);
|
||||
|
||||
// Absent: the record is inert.
|
||||
expect(resolveModuleEligibility([], stale).entries).toEqual([]);
|
||||
// Redeployed: the same record decides again, with no new write.
|
||||
expect(resolveModuleEligibility([fakeModule("announcements")], stale).eligibleModules).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveModuleEligibility — required dependencies", () => {
|
||||
it("keeps a dependent eligible while its required dependency is enabled", () => {
|
||||
const resolution = resolveModuleEligibility(
|
||||
[
|
||||
fakeModule("storage"),
|
||||
fakeModule("announcements", {
|
||||
dependencies: { modules: { required: { storage: "^0.1.0" } } },
|
||||
}),
|
||||
],
|
||||
configuration(),
|
||||
);
|
||||
|
||||
expect(ids(resolution.eligibleModules)).toEqual(["storage", "announcements"]);
|
||||
});
|
||||
|
||||
it("blocks a dependent when its required module dependency is disabled, and names the dependency", () => {
|
||||
const resolution = resolveModuleEligibility(
|
||||
[
|
||||
fakeModule("storage"),
|
||||
fakeModule("announcements", {
|
||||
dependencies: { modules: { required: { storage: "^0.1.0" } } },
|
||||
}),
|
||||
],
|
||||
configuration(["storage", false]),
|
||||
);
|
||||
|
||||
expect(resolution.eligibleModules).toEqual([]);
|
||||
expect(entry(resolution, "announcements").blocked).toEqual({
|
||||
dependency: { kind: "module", moduleId: "storage" },
|
||||
reason: "required_dependency_disabled",
|
||||
});
|
||||
// Administrator intent is never overridden: disabling `storage` does not silently re-enable it
|
||||
// because something needs it.
|
||||
expect(entry(resolution, "storage").configured.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks a dependent when the required capability's only provider is disabled", () => {
|
||||
const resolution = resolveModuleEligibility(
|
||||
[
|
||||
fakeModule("storage", { capabilities: [{ id: "storage.objects", version: "1.0.0" }] }),
|
||||
fakeModule("announcements", {
|
||||
dependencies: { capabilities: { required: { "storage.objects": "^1.0.0" } } },
|
||||
}),
|
||||
],
|
||||
configuration(["storage", false]),
|
||||
);
|
||||
|
||||
expect(resolution.eligibleModules).toEqual([]);
|
||||
// A required capability is exactly as required as a required module, and the reason names the
|
||||
// module an administrator would have to enable — not just the capability id.
|
||||
expect(entry(resolution, "announcements").blocked).toEqual({
|
||||
dependency: { capabilityId: "storage.objects", kind: "capability", moduleId: "storage" },
|
||||
reason: "required_dependency_disabled",
|
||||
});
|
||||
});
|
||||
|
||||
it("propagates blocking transitively, and distinguishes the direct cause from the inherited one", () => {
|
||||
const resolution = resolveModuleEligibility(
|
||||
[
|
||||
fakeModule("storage"),
|
||||
fakeModule("media", {
|
||||
dependencies: { modules: { required: { storage: "^0.1.0" } } },
|
||||
}),
|
||||
fakeModule("announcements", {
|
||||
dependencies: { modules: { required: { media: "^0.1.0" } } },
|
||||
}),
|
||||
],
|
||||
configuration(["storage", false]),
|
||||
);
|
||||
|
||||
expect(resolution.eligibleModules).toEqual([]);
|
||||
expect(entry(resolution, "media").blocked?.reason).toBe("required_dependency_disabled");
|
||||
// Following the chain always terminates at a `..._disabled` entry, so an operator can walk from
|
||||
// any blocked module to the one decision that caused it.
|
||||
expect(entry(resolution, "announcements").blocked).toEqual({
|
||||
dependency: { kind: "module", moduleId: "media" },
|
||||
reason: "required_dependency_blocked",
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks only the dependents of the disabled module, never an unrelated one", () => {
|
||||
const resolution = resolveModuleEligibility(
|
||||
[
|
||||
fakeModule("storage"),
|
||||
fakeModule("announcements", {
|
||||
dependencies: { modules: { required: { storage: "^0.1.0" } } },
|
||||
}),
|
||||
fakeModule("calendar"),
|
||||
],
|
||||
configuration(["storage", false]),
|
||||
);
|
||||
|
||||
expect(ids(resolution.eligibleModules)).toEqual(["calendar"]);
|
||||
expect(entry(resolution, "calendar").blocked).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves discovery order in the eligible list rather than re-sorting it", () => {
|
||||
const resolution = resolveModuleEligibility(
|
||||
[
|
||||
fakeModule("storage"),
|
||||
fakeModule("media", { dependencies: { modules: { required: { storage: "^0.1.0" } } } }),
|
||||
fakeModule("announcements", {
|
||||
dependencies: { modules: { required: { media: "^0.1.0" } } },
|
||||
}),
|
||||
],
|
||||
configuration(),
|
||||
);
|
||||
|
||||
// Filtering, never re-ordering: a started module's required dependencies still precede it.
|
||||
expect(ids(resolution.eligibleModules)).toEqual(["storage", "media", "announcements"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveModuleEligibility — optional dependencies", () => {
|
||||
it("leaves a dependent eligible when its optional module dependency is disabled", () => {
|
||||
const resolution = resolveModuleEligibility(
|
||||
[
|
||||
fakeModule("analytics"),
|
||||
fakeModule("announcements", {
|
||||
dependencies: { modules: { optional: { analytics: "^0.1.0" } } },
|
||||
}),
|
||||
],
|
||||
configuration(["analytics", false]),
|
||||
);
|
||||
|
||||
// A module declaring an optional dependency already accepted that it may be absent — discovery
|
||||
// permits an optional dependency to not be installed at all — and "disabled" is that same
|
||||
// situation arriving a different way.
|
||||
expect(ids(resolution.eligibleModules)).toEqual(["announcements"]);
|
||||
expect(entry(resolution, "announcements").blocked).toBeUndefined();
|
||||
});
|
||||
|
||||
it("leaves a dependent eligible when its optional capability's provider is disabled", () => {
|
||||
const resolution = resolveModuleEligibility(
|
||||
[
|
||||
fakeModule("analytics", { capabilities: [{ id: "analytics.events", version: "1.0.0" }] }),
|
||||
fakeModule("announcements", {
|
||||
dependencies: { capabilities: { optional: { "analytics.events": "^1.0.0" } } },
|
||||
}),
|
||||
],
|
||||
configuration(["analytics", false]),
|
||||
);
|
||||
|
||||
expect(ids(resolution.eligibleModules)).toEqual(["announcements"]);
|
||||
});
|
||||
|
||||
it("leaves a dependent eligible when its optional dependency is itself blocked", () => {
|
||||
const resolution = resolveModuleEligibility(
|
||||
[
|
||||
fakeModule("storage"),
|
||||
fakeModule("analytics", {
|
||||
dependencies: { modules: { required: { storage: "^0.1.0" } } },
|
||||
}),
|
||||
fakeModule("announcements", {
|
||||
dependencies: { modules: { optional: { analytics: "^0.1.0" } } },
|
||||
}),
|
||||
],
|
||||
configuration(["storage", false]),
|
||||
);
|
||||
|
||||
expect(ids(resolution.eligibleModules)).toEqual(["announcements"]);
|
||||
});
|
||||
|
||||
it("blocks on the required edge only, when a module declares both kinds", () => {
|
||||
const resolution = resolveModuleEligibility(
|
||||
[
|
||||
fakeModule("analytics"),
|
||||
fakeModule("storage"),
|
||||
fakeModule("announcements", {
|
||||
dependencies: {
|
||||
modules: { optional: { analytics: "^0.1.0" }, required: { storage: "^0.1.0" } },
|
||||
},
|
||||
}),
|
||||
],
|
||||
configuration(["analytics", false], ["storage", false]),
|
||||
);
|
||||
|
||||
expect(entry(resolution, "announcements").blocked).toEqual({
|
||||
dependency: { kind: "module", moduleId: "storage" },
|
||||
reason: "required_dependency_disabled",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveModuleEligibility — over a real discovered inventory", () => {
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })),
|
||||
);
|
||||
});
|
||||
|
||||
async function writeModule(
|
||||
modulesDirectory: string,
|
||||
id: string,
|
||||
dependencies?: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const moduleDirectory = path.join(modulesDirectory, id);
|
||||
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
|
||||
await Promise.all([
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "package.json"),
|
||||
JSON.stringify({
|
||||
ekklesia: { layer: "module" },
|
||||
name: `@test/module-${id}`,
|
||||
type: "module",
|
||||
version: "0.1.0",
|
||||
}),
|
||||
),
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||
JSON.stringify({
|
||||
manifestVersion: 4,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
|
||||
...(dependencies ? { dependencies } : {}),
|
||||
entrypoints: { server: "./dist/server/index.js" },
|
||||
}),
|
||||
),
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "dist/server/index.js"),
|
||||
'throw new Error("eligibility resolution must never import a module server entrypoint");',
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
it("resolves against the exact list real discovery produces, without importing any entrypoint", async () => {
|
||||
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-lifecycle-"));
|
||||
temporaryDirectories.push(modulesDirectory);
|
||||
await writeModule(modulesDirectory, "storage");
|
||||
await writeModule(modulesDirectory, "announcements", {
|
||||
modules: { required: { storage: "^0.1.0" } },
|
||||
});
|
||||
|
||||
const discovered = await discoverModules({
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
});
|
||||
|
||||
// Discovery's own topological order, which eligibility resolution relies on and never redoes.
|
||||
expect(ids(discovered)).toEqual(["storage", "announcements"]);
|
||||
|
||||
const blocked = resolveModuleEligibility(discovered, configuration(["storage", false]));
|
||||
expect(blocked.eligibleModules).toEqual([]);
|
||||
// Every fixture entrypoint throws on import; reaching this assertion is direct evidence that
|
||||
// nothing here imported one.
|
||||
expect(entry(blocked, "announcements").blocked?.reason).toBe("required_dependency_disabled");
|
||||
});
|
||||
|
||||
it("leaves a missing required dependency to discovery, rather than modelling it as a lifecycle state", async () => {
|
||||
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-lifecycle-"));
|
||||
temporaryDirectories.push(modulesDirectory);
|
||||
await writeModule(modulesDirectory, "announcements", {
|
||||
modules: { required: { storage: "^0.1.0" } },
|
||||
});
|
||||
|
||||
// There is deliberately no eligibility state for "required dependency not installed": discovery
|
||||
// rejects the whole inventory long before enablement is consulted, so there is exactly one place
|
||||
// the dependency graph is validated.
|
||||
await expect(
|
||||
discoverModules({
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
}),
|
||||
).rejects.toMatchObject({ name: "ModuleDiscoveryError" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveModuleLifecycleView — configuration versus what this process actually did", () => {
|
||||
const storage = fakeModule("storage");
|
||||
const announcements = fakeModule("announcements", {
|
||||
dependencies: { modules: { required: { storage: "^0.1.0" } } },
|
||||
});
|
||||
const discoveredModules = [storage, announcements];
|
||||
|
||||
function snapshotFor(bootConfiguration: ModuleLifecycleConfiguration) {
|
||||
const resolution = resolveModuleEligibility(discoveredModules, bootConfiguration);
|
||||
return {
|
||||
discoveredModules,
|
||||
eligibility: resolution.entries,
|
||||
startedModuleIds: resolution.eligibleModules.map((module) => module.manifest.id),
|
||||
};
|
||||
}
|
||||
|
||||
it("reports a started module as running, with no restart pending", () => {
|
||||
const view = resolveModuleLifecycleView(snapshotFor(configuration()), configuration());
|
||||
|
||||
expect(view).toEqual([
|
||||
{
|
||||
configured: { enabled: true, explicit: false },
|
||||
moduleId: "storage",
|
||||
restartRequired: false,
|
||||
runtime: { state: "running" },
|
||||
version: "0.1.0",
|
||||
},
|
||||
{
|
||||
configured: { enabled: true, explicit: false },
|
||||
moduleId: "announcements",
|
||||
restartRequired: false,
|
||||
runtime: { state: "running" },
|
||||
version: "0.1.0",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not claim a module stopped when configuration changed but the process did not", () => {
|
||||
const view = resolveModuleLifecycleView(
|
||||
snapshotFor(configuration()),
|
||||
configuration(["announcements", false]),
|
||||
);
|
||||
|
||||
const changed = view.find((entry) => entry.moduleId === "announcements");
|
||||
// The honest triple: configured off, still running, restart pending. Reporting `disabled` here
|
||||
// would be a lie about a process that is still serving that module's routes.
|
||||
expect(changed?.configured).toEqual({ enabled: false, explicit: true });
|
||||
expect(changed?.runtime).toEqual({ state: "running" });
|
||||
expect(changed?.restartRequired).toBe(true);
|
||||
// An unrelated module is untouched.
|
||||
expect(view.find((entry) => entry.moduleId === "storage")?.restartRequired).toBe(false);
|
||||
});
|
||||
|
||||
it("marks restart pending for a dependent too, because it really will stop on the next boot", () => {
|
||||
const view = resolveModuleLifecycleView(
|
||||
snapshotFor(configuration()),
|
||||
configuration(["storage", false]),
|
||||
);
|
||||
|
||||
expect(view.map((entry) => [entry.moduleId, entry.restartRequired])).toEqual([
|
||||
["storage", true],
|
||||
["announcements", true],
|
||||
]);
|
||||
expect(view.every((entry) => entry.runtime.state === "running")).toBe(true);
|
||||
});
|
||||
|
||||
it("marks restart pending in the other direction, without claiming the module started", () => {
|
||||
const view = resolveModuleLifecycleView(
|
||||
snapshotFor(configuration(["announcements", false])),
|
||||
configuration(["announcements", true]),
|
||||
);
|
||||
|
||||
const changed = view.find((entry) => entry.moduleId === "announcements");
|
||||
expect(changed?.configured).toEqual({ enabled: true, explicit: true });
|
||||
expect(changed?.runtime).toEqual({ state: "disabled" });
|
||||
expect(changed?.restartRequired).toBe(true);
|
||||
});
|
||||
|
||||
it("explains a blocked module with the boot-time reason, not a recomputed one", () => {
|
||||
const view = resolveModuleLifecycleView(
|
||||
snapshotFor(configuration(["storage", false])),
|
||||
configuration(["storage", false]),
|
||||
);
|
||||
|
||||
expect(view.find((entry) => entry.moduleId === "storage")?.runtime).toEqual({
|
||||
state: "disabled",
|
||||
});
|
||||
expect(view.find((entry) => entry.moduleId === "announcements")?.runtime).toEqual({
|
||||
dependency: { kind: "module", moduleId: "storage" },
|
||||
reason: "required_dependency_disabled",
|
||||
state: "blocked",
|
||||
});
|
||||
expect(view.every((entry) => entry.restartRequired === false)).toBe(true);
|
||||
});
|
||||
|
||||
it("reports what the runtime actually started, not what eligibility predicted", () => {
|
||||
// A snapshot whose started set disagrees with its own eligibility is not reachable while
|
||||
// startup stays all-or-nothing. It is constructed here deliberately: the read model must be an
|
||||
// observation of the runtime, so if that guarantee ever changes this can never silently report
|
||||
// a module as `running` because it was merely eligible.
|
||||
const eligible = resolveModuleEligibility(discoveredModules, configuration());
|
||||
const view = resolveModuleLifecycleView(
|
||||
{
|
||||
discoveredModules,
|
||||
eligibility: eligible.entries,
|
||||
startedModuleIds: ["storage"],
|
||||
},
|
||||
configuration(),
|
||||
);
|
||||
|
||||
expect(view.find((entry) => entry.moduleId === "storage")?.runtime.state).toBe("running");
|
||||
expect(view.find((entry) => entry.moduleId === "announcements")?.runtime.state).toBe("blocked");
|
||||
expect(view.find((entry) => entry.moduleId === "announcements")?.restartRequired).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores a stored record for an undeployed module rather than inventing a row for it", () => {
|
||||
const view = resolveModuleLifecycleView(
|
||||
snapshotFor(configuration()),
|
||||
configuration(["removed-long-ago", false]),
|
||||
);
|
||||
|
||||
expect(view.map((entry) => entry.moduleId)).toEqual(["storage", "announcements"]);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
||||
CORE_DASHBOARD_NAVIGATION_MANAGE,
|
||||
CORE_MODULES_LIFECYCLE_MANAGE,
|
||||
CORE_MODULES_UI_READ,
|
||||
CORE_PERMISSION_DECLARATIONS,
|
||||
PermissionRegistryError,
|
||||
@@ -69,8 +70,9 @@ describe("composePlatformPermissionRegistry", () => {
|
||||
expect(registry.has("calendar.events.read")).toBe(true);
|
||||
expect(registry.has("people.directory.read")).toBe(true);
|
||||
expect(registry.has(CORE_DASHBOARD_NAVIGATION_MANAGE)).toBe(true);
|
||||
// Core's own three declarations plus one from each module.
|
||||
expect(registry.declarations).toHaveLength(5);
|
||||
expect(registry.has(CORE_MODULES_LIFECYCLE_MANAGE)).toBe(true);
|
||||
// Core's own four declarations plus one from each module.
|
||||
expect(registry.declarations).toHaveLength(6);
|
||||
});
|
||||
|
||||
it("preserves deterministic order: Core first, then modules in the supplied order, then declarations in manifest order", () => {
|
||||
@@ -88,6 +90,7 @@ describe("composePlatformPermissionRegistry", () => {
|
||||
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
||||
CORE_MODULES_UI_READ,
|
||||
CORE_DASHBOARD_NAVIGATION_MANAGE,
|
||||
CORE_MODULES_LIFECYCLE_MANAGE,
|
||||
"calendar.events.read",
|
||||
"calendar.events.write",
|
||||
"people.directory.read",
|
||||
|
||||
Reference in New Issue
Block a user