Files
Ekklesia/core/tests/module-configuration-lifecycle.test.ts
ekklesia 49b4d4165d
CI / verify (pull_request) Successful in 1m0s
CI / infrastructure-smoke (pull_request) Successful in 36s
CI / migration-smoke (pull_request) Successful in 1m53s
CI / image-build (pull_request) Successful in 23s
CI / deploy-smoke (same-origin) (pull_request) Successful in 50s
CI / deploy-smoke (split) (pull_request) Successful in 1m30s
feat: deliver module database ABI by manifest
2026-10-07 17:29:32 -05:00

356 lines
13 KiB
TypeScript

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<string, unknown>;
} = {},
): DiscoveredModule {
const manifest = parseModuleManifest({
manifestVersion: 8,
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 {
contributions: [],
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<Record<string, Readonly<Record<string, string>>>>,
): 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<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("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<typeof settings>) {
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"');
});
});