import { readFile } from "node:fs/promises"; import { EMPTY_MODULE_CONFIGURATION, parseModuleManifest } from "@ekklesia/module-sdk"; import { describe, expect, it } from "vitest"; import moduleRuntime from "../src/server/index.js"; import { readSignageSettings } from "../src/server/settings.js"; import { SIGNAGE_DEFAULT_SETTINGS } from "../src/shared/settings.js"; import { fakeConfiguration, fakeHandlerContext, findRoute, registerModule, } from "./support/module-harness.js"; /** Signage's own copy of Core's overlap rule: two routes of one module under one method must never * be able to match the same request path, because they may carry different permissions. */ function overlappingRoutes(routes: readonly { method: string; path: string }[]): readonly string[] { const segmentsOf = (path: string) => (path === "/" ? [] : path.slice(1).split("/")); const collisions: string[] = []; for (let left = 0; left < routes.length; left += 1) { for (let right = left + 1; right < routes.length; right += 1) { const first = routes[left]; const second = routes[right]; if (!first || !second || first.method !== second.method) { continue; } const a = segmentsOf(first.path); const b = segmentsOf(second.path); if (a.length !== b.length) { continue; } const overlaps = a.every((segment, index) => { const other = b[index] as string; return segment.startsWith(":") || other.startsWith(":") || segment === other; }); if (overlaps) { collisions.push(`${first.method} ${first.path} ~ ${second.path}`); } } } return collisions; } async function readManifest() { const manifestUrl = new URL("../ekklesia.module.json", import.meta.url); return parseModuleManifest(JSON.parse(await readFile(manifestUrl, "utf8")) as unknown); } describe("Signage module manifest", () => { it("declares the extension seams other modules reach Signage through", async () => { const manifest = await readManifest(); // The backend seam and the browser seam are versioned separately on purpose: a provider's // registration contract and its renderer's input contract have different lifecycles, and // versioning them together would force every provider to rebuild whenever either moved. expect(manifest.capabilities).toEqual([ { id: "signage.content-providers", version: "1.0.0" }, { id: "signage.slide-renderer", version: "1.0.0" }, { id: "signage.playback", version: "1.0.0" }, { id: "signage.displays", version: "1.0.0" }, ]); }); it("declares the module's product identity at the current manifest version", async () => { const manifest = await readManifest(); expect(manifest.manifestVersion).toBe(7); expect(manifest.id).toBe("signage"); expect(manifest.name).toBe("Signage"); expect(manifest.version).toBe("0.1.2"); expect(manifest.compatibility).toEqual({ moduleSdk: "^0.1.0", platform: "^0.1.0" }); }); it("declares every Signage permission, and none of them publicly", async () => { const manifest = await readManifest(); expect(manifest.permissions.map((permission) => permission.id)).toEqual([ "signage.view", "signage.assets.manage", "signage.timelines.manage", "signage.playback.control", "signage.displays.manage", "signage.display.view", ]); // Nothing Signage owns is readable by an unauthenticated caller: a timeline names a church's // schedule, and a display is a screen someone paired. expect(manifest.permissions.every((permission) => permission.baseline === "restricted")).toBe( true, ); for (const permission of manifest.permissions) { expect(permission.description.length).toBeGreaterThan(0); } }); it("declares one logical storage bucket and names no storage service anywhere", async () => { const manifest = await readManifest(); const raw = await readFile(new URL("../ekklesia.module.json", import.meta.url), "utf8"); expect(manifest.storage.buckets).toEqual([ { id: "native-images", description: "Archived original uploads and generated renditions for Signage's native images.", }, ]); // A module that configured its own object store would be welded to one deployment (ADR 0030). for (const forbidden of ["s3", "seaweed", "minio", "endpoint", "accessKey", "region"]) { expect(raw.toLowerCase()).not.toContain(forbidden.toLowerCase()); } }); it("declares both browser artifacts, with the display gated on a non-public permission", async () => { const manifest = await readManifest(); expect(manifest.entrypoints.server).toBe("./dist/server/index.js"); expect(manifest.entrypoints.ui).toBe("./dist/ui/index.js"); expect(manifest.entrypoints.display).toEqual({ artifact: "./dist/display/index.js", permission: "signage.display.view", }); }); it("declares only operator settings, every one of them with a safe default and no secret", async () => { const manifest = await readManifest(); expect(manifest.configuration.map((entry) => entry.key)).toEqual([ "timezone", "nativeImageDurationSeconds", "crossfadeMilliseconds", "defaultBackgroundMode", "defaultBackgroundColor", "defaultBackgroundDarkened", "defaultBlurPixels", "providerTimeoutMilliseconds", "playbackHistoryLimit", "viewerHeartbeatWriteSeconds", ]); // Nothing Signage needs configured has no safe default, so nothing can block it from starting. expect(manifest.configuration.every((entry) => entry.required === false)).toBe(true); expect(manifest.configuration.some((entry) => entry.type === "secret")).toBe(false); }); }); describe("Signage server runtime", () => { it("exports the typed runtime definition the compiled server entrypoint publishes", async () => { expect(moduleRuntime).toMatchObject({ id: "signage", version: "0.1.2" }); }); it("registers its routes declaratively, with no database, storage, or realtime work", async () => { const registration = await registerModule(); expect(registration.routes.map((route) => `${route.method} ${route.path}`).sort()).toEqual([ "DELETE /channel-groups/:groupId", "DELETE /channels/:channelId", "DELETE /images/:imageId", "DELETE /timelines/:timelineId", "DELETE /timelines/:timelineId/slots/:slotId", "DELETE /timelines/:timelineId/sources/:sourceId", "DELETE /viewers/:viewerId", "GET /channel-groups", "GET /channels/:channelId/display-state", "GET /images", "GET /images/:imageId", "GET /playback/:groupId", "GET /providers", "GET /providers/:providerId/items", "GET /providers/:providerId/sources", "GET /settings", "GET /timelines", "GET /timelines/:timelineId", "GET /viewers", "PATCH /images/:imageId", "PATCH /timelines/:timelineId", "PATCH /timelines/:timelineId/slots/:slotId", "PATCH /viewers/:viewerId", "POST /channel-groups", "POST /channel-groups/:groupId/channels", "POST /images", "POST /playback/:groupId/activate", "POST /playback/:groupId/blackout", "POST /playback/:groupId/next", "POST /playback/:groupId/pause", "POST /playback/:groupId/previous", "POST /playback/:groupId/reload", "POST /playback/:groupId/resume", "POST /playback/:groupId/static", "POST /timelines", "POST /timelines/:timelineId/duplicate", "POST /timelines/:timelineId/slots", "POST /timelines/:timelineId/sources", "POST /viewers/:viewerId/heartbeat", "POST /viewers/session", "PUT /timelines/:timelineId/slot-order", ]); // Every route makes the body decision consciously, names a permission Signage's own manifest // declares, and cannot collide with another — Core refuses a module whose routes overlap, and // discovering that at API startup is discovering it in production. for (const route of registration.routes) { expect(route.permission.startsWith("signage.")).toBe(true); expect(Number.isSafeInteger(route.maxBodyBytes)).toBe(true); } expect(overlappingRoutes(registration.routes)).toEqual([]); // Every declared capability is registered during `register`, because Core refuses a module that // declares one and does not. expect([...registration.provided.keys()].sort()).toEqual([ "signage.content-providers", "signage.displays", "signage.playback", "signage.slide-renderer", ]); // Two realtime channels, each gated on a permission Signage owns: what a display needs, and // what the dashboard needs, are different audiences and different permissions. expect(registration.channels).toEqual([ { channel: "display-state", permission: "signage.display.view" }, { channel: "management-state", permission: "signage.view" }, ]); const upload = findRoute(registration, "POST", "/images"); expect(upload).toMatchObject({ bodyMode: "streamed", maxBodyBytes: 26_214_400, permission: "signage.assets.manage", }); // Exactly one route streams. Everything else takes a small buffered body, or none at all. expect(registration.routes.filter((route) => route.bodyMode === "streamed")).toHaveLength(1); expect(findRoute(registration, "GET", "/settings").maxBodyBytes).toBe(0); }); it("serves its own effective settings, and never a configuration value it did not choose to expose", async () => { const route = findRoute(await registerModule(), "GET", "/settings"); const response = await route.handler( new Request("http://localhost/settings"), fakeHandlerContext({ config: fakeConfiguration({ crossfadeMilliseconds: 400, nativeImageDurationSeconds: 12, timezone: "America/Denver", }), }), ); expect(response.status).toBe(200); expect(await response.json()).toEqual({ crossfadeMs: 400, moduleVersion: "0.1.2", nativeImageDurationMs: 12_000, nativePresentation: SIGNAGE_DEFAULT_SETTINGS.nativePresentation, rendererInputVersion: "1.0.0", timezone: "America/Denver", // The dashboard is told the limits the server enforces rather than hardcoding them: a UI that // believed a stale limit would offer an upload the server refuses. uploads: { batchMaxFiles: 20, concurrency: 3, maxBytes: 26_214_400, maxPixels: 40_000_000 }, }); }); }); describe("Signage settings", () => { it("falls back to exactly the defaults the manifest declares", async () => { const manifest = await readManifest(); const settings = readSignageSettings(EMPTY_MODULE_CONFIGURATION); const declared = new Map(manifest.configuration.map((entry) => [entry.key, entry])); expect(settings).toEqual(SIGNAGE_DEFAULT_SETTINGS); // The one place these two could drift is exactly the place a stale default would be invisible. expect(declared.get("timezone")).toMatchObject({ default: settings.timezone }); expect(declared.get("nativeImageDurationSeconds")).toMatchObject({ default: settings.nativeImageDurationMs / 1000, }); expect(declared.get("crossfadeMilliseconds")).toMatchObject({ default: settings.crossfadeMs }); expect(declared.get("defaultBlurPixels")).toMatchObject({ default: settings.nativePresentation.blurPixels, }); expect(declared.get("playbackHistoryLimit")).toMatchObject({ default: settings.playbackHistoryLimit, }); }); it("refuses a background colour that is not a plain six-digit hex value", () => { const settings = readSignageSettings( fakeConfiguration({ defaultBackgroundColor: "red; background: url(x)" }), ); expect(settings.nativePresentation.backgroundColor).toBe("#000000"); }); it("refuses a background mode Signage does not implement", () => { const settings = readSignageSettings(fakeConfiguration({ defaultBackgroundMode: "rainbow" })); expect(settings.nativePresentation.backgroundMode).toBe("blurred"); }); });