Files
Ekklesia/core/tests/module-storage-lifecycle.test.ts
T
ekklesia 2e0fe059c3
CI / verify (pull_request) Successful in 38s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 59s
feat: add display kiosk host
2026-08-23 20:50:56 -05:00

310 lines
11 KiB
TypeScript

import { parseModuleManifest } from "@ekklesia/contracts";
import { describe, expect, it } from "vitest";
import type { DiscoveredModule } from "../src/modules/discovery.js";
import {
isStorageBlockedDetail,
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 buckets?: readonly string[];
readonly dependencies?: Record<string, unknown>;
readonly configuration?: readonly Record<string, unknown>[];
} = {},
): DiscoveredModule {
const manifest = parseModuleManifest({
manifestVersion: 6,
id,
name: `${id} module`,
version: "1.0.0",
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
...(options.configuration ? { configuration: options.configuration } : {}),
...(options.dependencies ? { dependencies: options.dependencies } : {}),
...(options.buckets ? { storage: { buckets: options.buckets.map((id) => ({ id })) } } : {}),
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 })) };
}
const noConfiguration = { settings: { records: [] } };
const withStorage = { providerConfigured: true };
const withoutStorage = { providerConfigured: false };
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("declared storage decides eligibility", () => {
it("keeps a module requiring storage eligible when a provider is configured", () => {
const resolution = resolveModuleEligibility(
[fakeModule("announcements", { buckets: ["announcement-assets"] })],
enablement(),
noConfiguration,
withStorage,
);
expect(ids(resolution.eligibleModules)).toEqual(["announcements"]);
expect(entry(resolution, "announcements").blocked).toBeUndefined();
});
it("blocks a module requiring storage when the host has no provider, naming the logical bucket", () => {
const resolution = resolveModuleEligibility(
[fakeModule("announcements", { buckets: ["announcement-assets"] })],
enablement(),
noConfiguration,
withoutStorage,
);
expect(resolution.eligibleModules).toEqual([]);
expect(entry(resolution, "announcements").blocked).toEqual({
reason: "storage_unavailable",
storageBucketId: "announcement-assets",
});
});
it("leaves a module declaring no buckets entirely unaffected by storage", () => {
const resolution = resolveModuleEligibility(
[fakeModule("calendar")],
enablement(),
noConfiguration,
withoutStorage,
);
// An installation with no object storage is a complete, valid deployment.
expect(ids(resolution.eligibleModules)).toEqual(["calendar"]);
expect(entry(resolution, "calendar").blocked).toBeUndefined();
});
it("does not evaluate a disabled module's storage requirement", () => {
const resolution = resolveModuleEligibility(
[fakeModule("announcements", { buckets: ["announcement-assets"] })],
enablement(["announcements", false]),
noConfiguration,
withoutStorage,
);
// Disabled is not blocked: reporting a storage 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("defaults to no provider when the caller supplies no storage input at all", () => {
// Every pre-existing caller keeps its exact behaviour: the parameter is optional, and a module
// that declares no bucket is unaffected either way.
const withoutArgument = resolveModuleEligibility([fakeModule("calendar")], enablement());
expect(ids(withoutArgument.eligibleModules)).toEqual(["calendar"]);
const requiring = resolveModuleEligibility(
[fakeModule("announcements", { buckets: ["assets"] })],
enablement(),
);
expect(requiring.eligibleModules).toEqual([]);
});
it("names only the logical bucket — never an endpoint, credential, or physical bucket", () => {
const resolution = resolveModuleEligibility(
[fakeModule("announcements", { buckets: ["announcement-assets"] })],
enablement(),
noConfiguration,
withoutStorage,
);
const serialized = JSON.stringify(resolution.entries);
expect(serialized).toContain("announcement-assets");
expect(serialized).not.toContain("ekklesia-announcements--");
expect(serialized).not.toContain("http");
expect(serialized).not.toMatch(/accessKey|secretKey|endpoint/iu);
});
});
describe("storage blocking propagates through the dependency graph", () => {
const graph = [
fakeModule("media-storage", { buckets: ["objects"] }),
fakeModule("media", { dependencies: { modules: { required: { "media-storage": "^1.0.0" } } } }),
fakeModule("announcements", { dependencies: { modules: { required: { media: "^1.0.0" } } } }),
];
it("blocks required dependents of a storage-blocked module, transitively", () => {
const resolution = resolveModuleEligibility(
graph,
enablement(),
noConfiguration,
withoutStorage,
);
expect(resolution.eligibleModules).toEqual([]);
// One mechanism, not two: the chain terminates at the module whose own requirement is
// unsatisfiable, exactly as it terminates at a disabled or misconfigured module.
expect(entry(resolution, "media-storage").blocked).toEqual({
reason: "storage_unavailable",
storageBucketId: "objects",
});
expect(entry(resolution, "media").blocked).toEqual({
dependency: { kind: "module", moduleId: "media-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 a provider is configured", () => {
const resolution = resolveModuleEligibility(graph, enablement(), noConfiguration, withStorage);
expect(ids(resolution.eligibleModules)).toEqual(["media-storage", "media", "announcements"]);
});
it("never blocks an unrelated module because another module needs storage", () => {
const resolution = resolveModuleEligibility(
[...graph, fakeModule("calendar")],
enablement(),
noConfiguration,
withoutStorage,
);
expect(ids(resolution.eligibleModules)).toEqual(["calendar"]);
});
});
describe("storage and configuration blocking coexist without a second mechanism", () => {
it("reports the storage requirement first when both are unsatisfiable", () => {
const resolution = resolveModuleEligibility(
[
fakeModule("announcements", {
buckets: ["assets"],
configuration: [{ key: "bucketLabel", type: "string", required: true }],
}),
],
enablement(),
noConfiguration,
withoutStorage,
);
// Deterministic ordering — storage is evaluated before configuration — so an operator always
// sees the same reason for the same state rather than one that depends on evaluation order.
const blocked = entry(resolution, "announcements").blocked;
expect(blocked && isStorageBlockedDetail(blocked)).toBe(true);
});
it("falls through to the configuration reason once storage is available", () => {
const resolution = resolveModuleEligibility(
[
fakeModule("announcements", {
buckets: ["assets"],
configuration: [{ key: "bucketLabel", type: "string", required: true }],
}),
],
enablement(),
noConfiguration,
withStorage,
);
expect(entry(resolution, "announcements").blocked).toEqual({
configurationKey: "bucketLabel",
reason: "configuration_missing",
});
});
});
describe("the lifecycle view reports storage state honestly", () => {
const discoveredModules = [fakeModule("announcements", { buckets: ["announcement-assets"] })];
function bootSnapshot(storage: { providerConfigured: boolean }) {
const resolution = resolveModuleEligibility(
discoveredModules,
enablement(),
noConfiguration,
storage,
);
return {
discoveredModules,
eligibility: resolution.entries,
startedModuleIds: resolution.eligibleModules.map((module) => module.manifest.id),
};
}
it("reports a storage-blocked module with the logical bucket and no pending restart", () => {
const view = resolveModuleLifecycleView(
bootSnapshot(withoutStorage),
enablement(),
noConfiguration,
withoutStorage,
);
expect(view[0]?.runtime).toEqual({
reason: "storage_unavailable",
state: "blocked",
storageBucketId: "announcement-assets",
});
expect(view[0]?.restartRequired).toBe(false);
});
it("reports a pending restart once a provider is configured for a blocked module", () => {
const view = resolveModuleLifecycleView(
bootSnapshot(withoutStorage),
enablement(),
noConfiguration,
withStorage,
);
// The next boot really will start it, so the honest answer is that a restart is pending — while
// the runtime line still describes what *this* process did.
expect(view[0]?.runtime.state).toBe("blocked");
expect(view[0]?.restartRequired).toBe(true);
});
it("reports a running module with storage as ordinary and unremarkable", () => {
const view = resolveModuleLifecycleView(
bootSnapshot(withStorage),
enablement(),
noConfiguration,
withStorage,
);
expect(view[0]?.runtime).toEqual({ state: "running" });
expect(view[0]?.restartRequired).toBe(false);
});
it("puts no infrastructure detail into the view", () => {
const view = resolveModuleLifecycleView(
bootSnapshot(withoutStorage),
enablement(),
noConfiguration,
withoutStorage,
);
const serialized = JSON.stringify(view);
expect(serialized).toContain("announcement-assets");
expect(serialized).not.toContain("ekklesia-announcements--");
expect(serialized).not.toMatch(/http|accessKey|secretKey|seaweed/iu);
});
});