import { type ModuleConfigurationDeclarationInput, type ModuleSecretSource, parseModuleManifest, } from "@ekklesia/contracts"; import { describe, expect, it } from "vitest"; import type { DiscoveredModule } from "../src/modules/discovery.js"; import { resolveModuleEligibility, resolveModuleLifecycleView } from "../src/modules/lifecycle.js"; /** A minimal in-memory `DiscoveredModule`, built through `parseModuleManifest` so a fixture real * discovery would reject can never stand in for one it would accept. */ function fakeModule( id: string, options: { readonly configuration?: readonly ModuleConfigurationDeclarationInput[]; readonly dependencies?: Record; } = {}, ): DiscoveredModule { const manifest = parseModuleManifest({ manifestVersion: 5, id, name: `${id} module`, version: "1.0.0", compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" }, configuration: options.configuration ?? [], ...(options.dependencies ? { dependencies: options.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}`, }; } function enablement(...records: readonly (readonly [string, boolean])[]) { return { modules: records.map(([moduleId, enabled]) => ({ moduleId, enabled })) }; } function settings(...records: readonly (readonly [string, string, string])[]) { return { records: records.map(([moduleId, key, value]) => ({ key, moduleId, value })), }; } function secretsFor( map: Readonly>>>, ): ModuleSecretSource { return { get: (moduleId, key) => map[moduleId]?.[key] }; } 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("configuration decides eligibility, through the existing lifecycle resolver", () => { it("keeps a module with satisfied configuration eligible, and hands it a snapshot", () => { const resolution = resolveModuleEligibility( [ fakeModule("announcements", { configuration: [{ key: "bucket", type: "string", required: true }], }), ], enablement(), { settings: settings(["announcements", "bucket", "announcements"]) }, ); expect(ids(resolution.eligibleModules)).toEqual(["announcements"]); expect(entry(resolution, "announcements").blocked).toBeUndefined(); // The same pass that decided eligibility produced the snapshot, so the configuration that made // the module eligible is the identical object it will receive. expect(resolution.configurations.get("announcements")?.getString("bucket")).toBe( "announcements", ); }); it("blocks an enabled module whose required configuration is missing, naming the key", () => { const resolution = resolveModuleEligibility( [ fakeModule("announcements", { configuration: [{ key: "bucket", type: "string", required: true }], }), ], enablement(), { settings: settings() }, ); expect(resolution.eligibleModules).toEqual([]); expect(entry(resolution, "announcements").blocked).toEqual({ configurationKey: "bucket", reason: "configuration_missing", }); // No snapshot for a module that is not going to start. expect(resolution.configurations.has("announcements")).toBe(false); }); it("blocks on an invalid stored value rather than starting on a default nobody chose", () => { const resolution = resolveModuleEligibility( [ fakeModule("announcements", { configuration: [{ key: "slideSeconds", type: "integer", default: 7, maximum: 600 }], }), ], enablement(), { settings: settings(["announcements", "slideSeconds", "100000"]) }, ); expect(entry(resolution, "announcements").blocked).toEqual({ configurationKey: "slideSeconds", reason: "configuration_invalid", }); }); it("blocks on a missing required secret, distinctly from a missing ordinary value", () => { const resolution = resolveModuleEligibility( [ fakeModule("announcements", { configuration: [{ key: "storageCredential", type: "secret", required: true }], }), ], enablement(), { settings: settings() }, ); // A different reason because it has a different fix: set an environment variable on the // deployment, not a value in the database. expect(entry(resolution, "announcements").blocked).toEqual({ configurationKey: "storageCredential", reason: "secret_missing", }); }); it("becomes eligible once the deployment supplies the secret, with no stored value involved", () => { const module = fakeModule("announcements", { configuration: [{ key: "storageCredential", type: "secret", required: true }], }); expect( resolveModuleEligibility([module], enablement(), { settings: settings() }).eligibleModules, ).toEqual([]); expect( ids( resolveModuleEligibility([module], enablement(), { secrets: secretsFor({ announcements: { storageCredential: "s3cret" } }), settings: settings(), }).eligibleModules, ), ).toEqual(["announcements"]); }); it("does not evaluate a disabled module's configuration at all", () => { const resolution = resolveModuleEligibility( [ fakeModule("announcements", { configuration: [{ key: "bucket", type: "string", required: true }], }), ], enablement(["announcements", false]), { settings: settings() }, ); // Disabled is not blocked: it is doing what it was told, and reporting a configuration problem // for something nobody asked to run would be noise. expect(entry(resolution, "announcements").blocked).toBeUndefined(); expect(entry(resolution, "announcements").configured.enabled).toBe(false); }); it("leaves a module declaring no configuration exactly as it was", () => { const resolution = resolveModuleEligibility([fakeModule("calendar")], enablement(), { settings: settings(), }); expect(ids(resolution.eligibleModules)).toEqual(["calendar"]); expect(resolution.configurations.get("calendar")?.keys()).toEqual([]); }); it("resolves identically when no configuration input is supplied at all", () => { // The third argument is optional, so every pre-existing caller keeps its exact behaviour. const withoutInput = resolveModuleEligibility([fakeModule("calendar")], enablement()); const withEmptyInput = resolveModuleEligibility([fakeModule("calendar")], enablement(), { settings: settings(), }); expect(withoutInput.entries).toEqual(withEmptyInput.entries); expect(ids(withoutInput.eligibleModules)).toEqual(["calendar"]); }); }); describe("configuration blocking propagates through the dependency graph", () => { const graph = [ fakeModule("storage", { configuration: [{ key: "bucket", type: "string", required: true }], }), fakeModule("media", { dependencies: { modules: { required: { storage: "^1.0.0" } } } }), fakeModule("announcements", { dependencies: { modules: { required: { media: "^1.0.0" } } } }), ]; it("blocks required dependents of a configuration-blocked module, transitively", () => { const resolution = resolveModuleEligibility(graph, enablement(), { settings: settings() }); expect(resolution.eligibleModules).toEqual([]); // The chain terminates at the module whose own configuration is at fault, exactly as it // terminates at a disabled module — one mechanism, not two. expect(entry(resolution, "storage").blocked).toEqual({ configurationKey: "bucket", reason: "configuration_missing", }); expect(entry(resolution, "media").blocked).toEqual({ dependency: { kind: "module", moduleId: "storage" }, reason: "required_dependency_blocked", }); expect(entry(resolution, "announcements").blocked).toEqual({ dependency: { kind: "module", moduleId: "media" }, reason: "required_dependency_blocked", }); }); it("unblocks the whole chain once the configuration is supplied", () => { const resolution = resolveModuleEligibility(graph, enablement(), { settings: settings(["storage", "bucket", "media-objects"]), }); expect(ids(resolution.eligibleModules)).toEqual(["storage", "media", "announcements"]); }); it("never blocks an unrelated module because of another module's configuration", () => { const resolution = resolveModuleEligibility([...graph, fakeModule("calendar")], enablement(), { settings: settings(), }); expect(ids(resolution.eligibleModules)).toEqual(["calendar"]); expect(entry(resolution, "calendar").blocked).toBeUndefined(); }); it("drops the snapshot of a module that resolved its own configuration but lost eligibility", () => { const resolution = resolveModuleEligibility( [ fakeModule("storage"), fakeModule("announcements", { configuration: [{ key: "bucket", type: "string", default: "b" }], dependencies: { modules: { required: { storage: "^1.0.0" } } }, }), ], enablement(["storage", false]), { settings: settings() }, ); // Its own configuration was fine; it is blocked by its dependency. The map must still contain // exactly the modules that are going to start. expect(entry(resolution, "announcements").blocked?.reason).toBe("required_dependency_disabled"); expect(resolution.configurations.has("announcements")).toBe(false); expect([...resolution.configurations.keys()]).toEqual([]); }); }); describe("configuration changes are restart-required, and reported honestly", () => { const module = fakeModule("announcements", { configuration: [{ key: "slideSeconds", type: "integer", default: 7, minimum: 1, maximum: 600 }], }); const discoveredModules = [module]; function bootSnapshot(bootSettings: ReturnType) { const resolution = resolveModuleEligibility(discoveredModules, enablement(), { settings: bootSettings, }); return { discoveredModules, eligibility: resolution.entries, startedModuleIds: resolution.eligibleModules.map((entry) => entry.manifest.id), }; } it("does not claim a running module changed when only stored configuration did", () => { const view = resolveModuleLifecycleView(bootSnapshot(settings()), enablement(), { settings: settings(["announcements", "slideSeconds", "12"]), }); // The module really is still running with 7; a configuration change is applied by the next // restart, and this reports exactly that rather than pretending the value took effect. expect(view[0]?.runtime).toEqual({ state: "running" }); expect(view[0]?.restartRequired).toBe(false); }); it("reports a pending restart when new configuration would change whether the module runs", () => { // Boot with a value that blocked the module, then correct it. const blocked = resolveModuleEligibility(discoveredModules, enablement(), { settings: settings(["announcements", "slideSeconds", "0"]), }); expect(blocked.eligibleModules).toEqual([]); const view = resolveModuleLifecycleView( { discoveredModules, eligibility: blocked.entries, startedModuleIds: [], }, enablement(), { settings: settings(["announcements", "slideSeconds", "12"]) }, ); // Correcting the value must show as restart-pending: the next boot really will start it. expect(view[0]?.runtime).toEqual({ configurationKey: "slideSeconds", reason: "configuration_invalid", state: "blocked", }); expect(view[0]?.restartRequired).toBe(true); }); it("reports the boot-time configuration reason, not a recomputed one", () => { const blocked = resolveModuleEligibility(discoveredModules, enablement(), { settings: settings(["announcements", "slideSeconds", "0"]), }); const view = resolveModuleLifecycleView( { discoveredModules, eligibility: blocked.entries, startedModuleIds: [] }, enablement(), { settings: settings(["announcements", "slideSeconds", "0"]) }, ); expect(view[0]?.runtime).toEqual({ configurationKey: "slideSeconds", reason: "configuration_invalid", state: "blocked", }); expect(view[0]?.restartRequired).toBe(false); }); it("never puts a configuration value into the lifecycle view, only the key", () => { const blocked = resolveModuleEligibility(discoveredModules, enablement(), { settings: settings(["announcements", "slideSeconds", "0"]), }); const view = resolveModuleLifecycleView( { discoveredModules, eligibility: blocked.entries, startedModuleIds: [] }, enablement(), { settings: settings(["announcements", "slideSeconds", "0"]) }, ); expect(JSON.stringify(view)).toContain("slideSeconds"); expect(JSON.stringify(view)).not.toContain('"0"'); }); });