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; } = {}, ): DiscoveredModule { const manifest = parseModuleManifest({ manifestVersion: 6, 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, moduleId: string, ): ReturnType["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, ): Promise { 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: 6, 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"]); }); });