import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { ANONYMOUS_PRINCIPAL, createAuthenticatedPrincipal, type EkklesiaPrincipal, type ModuleDatabase, type ModuleRuntimeServices, parseEkklesiaUserId, } from "@ekklesia/contracts"; import { type AuthorizationStore, createAuthorizationEvaluator, } from "@ekklesia/core/authorization"; import type { DashboardConfiguration } from "@ekklesia/core/dashboard"; import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity"; import { composePlatformPermissionRegistry, createModuleUiPublicationRegistry, discoverModules, EMPTY_MODULE_REALTIME_REGISTRY, } from "@ekklesia/core/modules"; import type { Hono } from "hono"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createApiApp } from "../src/app.js"; import { DASHBOARD_NAVIGATION_PATH } from "../src/dashboard-routes.js"; import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js"; import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; import { createInMemoryModuleLifecycleStore, moduleLifecycleSnapshotFor, } from "./support/in-memory-module-lifecycle.js"; import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js"; import { moduleStorageOptionFor } from "./support/in-memory-module-storage.js"; const trustedOrigin = "https://dashboard.example.test"; const untrustedOrigin = "https://evil.example.test"; const viewerId = parseEkklesiaUserId("33333333-3333-4333-8333-333333333333"); const temporaryDirectories: string[] = []; afterEach(async () => { await Promise.all( temporaryDirectories .splice(0) .map((directory) => rm(directory, { force: true, recursive: true })), ); }); interface ModuleFixture { readonly id: string; readonly name: string; readonly withUi?: boolean; } async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise { const moduleDirectory = path.join(modulesDirectory, fixture.id); await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true }); await writeFile( path.join(moduleDirectory, "package.json"), JSON.stringify({ ekklesia: { layer: "module" }, name: `@test/module-${fixture.id}`, type: "module", version: "1.0.0", }), ); await writeFile( path.join(moduleDirectory, "ekklesia.module.json"), JSON.stringify({ compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" }, entrypoints: { server: "./dist/server/index.js", ...(fixture.withUi === false ? {} : { ui: "./dist/ui/index.js" }), }, id: fixture.id, manifestVersion: 6, name: fixture.name, version: "1.0.0", }), ); await writeFile( path.join(moduleDirectory, "dist/server/index.js"), `export default { id: ${JSON.stringify(fixture.id)}, version: "1.0.0" };`, ); if (fixture.withUi !== false) { await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true }); await writeFile( path.join(moduleDirectory, "dist/ui/index.js"), `export default { id: ${JSON.stringify(fixture.id)}, version: "1.0.0", mount() {} };\n`, ); } } interface InstallOptions { readonly fixtures?: readonly ModuleFixture[]; readonly principal?: EkklesiaPrincipal; readonly platformOwner?: boolean; readonly configuration?: Partial; } const defaultFixtures: readonly ModuleFixture[] = [ { id: "video", name: "Video" }, { id: "livestream", name: "Livestream" }, { id: "calendar", name: "Calendar" }, ]; async function install(options: InstallOptions = {}): Promise<{ app: Hono }> { const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-dashboard-routes-")); temporaryDirectories.push(modulesDirectory); for (const fixture of options.fixtures ?? defaultFixtures) { await createModule(modulesDirectory, fixture); } const modules = await discoverModules({ moduleSdkVersion: "0.1.0", modulesDirectory, platformVersion: "0.1.0", }); const evaluator = createAuthorizationEvaluator({ grants: { hasActiveGrant: async () => false }, platformOwners: { isPlatformOwner: async () => options.platformOwner === true }, }); const runtime: EkklesiaIdentityRuntime = { handleAuthRequest: vi.fn().mockResolvedValue(new Response(null, { status: 200 })), resolvePrincipal: vi.fn().mockResolvedValue(options.principal ?? ANONYMOUS_PRINCIPAL), }; const store = { createGrant: vi.fn(), grantPlatformOwner: vi.fn(), hasActiveGrant: vi.fn().mockResolvedValue(false), isPlatformOwner: vi.fn().mockResolvedValue(false), listGrantsForUser: vi.fn().mockResolvedValue({ grants: [] }), revokeGrant: vi.fn(), revokePlatformOwner: vi.fn(), } as unknown as AuthorizationStore; const app = createApiApp({ authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store }, dashboard: createInMemoryDashboardStore(options.configuration ?? {}), moduleRoutes: Object.freeze([]), moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY, moduleServices: Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase, }) as ModuleRuntimeServices, moduleUi: createModuleUiPublicationRegistry(modules), moduleLifecycle: { snapshot: moduleLifecycleSnapshotFor(modules), store: createInMemoryModuleLifecycleStore(), }, moduleConfiguration: moduleConfigurationOptionFor(modules), moduleStorage: moduleStorageOptionFor(modules), runtime, trustedOrigins: [trustedOrigin], }); return { app }; } function signedIn(): EkklesiaPrincipal { return createAuthenticatedPrincipal(viewerId); } interface NavigationEntry { readonly name: string; readonly module: { readonly id: string; readonly version: string; readonly ui: { readonly url: string }; readonly api: { readonly baseUrl: string }; readonly permissions: readonly string[]; }; } interface NavigationBody { readonly groups: ReadonlyArray<{ readonly id: string; readonly label: string; readonly order: number; readonly modules: readonly NavigationEntry[]; }>; readonly ungrouped: readonly NavigationEntry[]; } async function navigationOf(app: Hono): Promise { const response = await app.request(DASHBOARD_NAVIGATION_PATH); expect(response.status).toBe(200); return (await response.json()) as NavigationBody; } /** An administrator: a platform owner, who passes the evaluator's owner bypass for the restricted * manage permission exactly as in production. */ async function administrator(configuration: Partial = {}) { return install({ configuration, platformOwner: true, principal: signedIn() }); } function mutate(app: Hono, method: "PUT" | "DELETE", url: string, body?: unknown) { return app.request(url, { ...(body === undefined ? {} : { body: JSON.stringify(body), headers: { "content-type": "application/json" } }), headers: { origin: trustedOrigin, ...(body === undefined ? {} : { "content-type": "application/json" }), }, method, }); } describe("Dashboard navigation — reading", () => { it("shows every mountable module as ungrouped when nothing is configured", async () => { const { app } = await install({ principal: signedIn() }); const navigation = await navigationOf(app); expect(navigation.groups).toEqual([]); // A fresh installation is immediately usable: no initialization write, no manual database edit. expect(navigation.ungrouped.map((entry) => entry.name)).toEqual([ "Calendar", "Livestream", "Video", ]); }); it("renders the module's own manifest name, never its id", async () => { const { app } = await install({ principal: signedIn() }); const navigation = await navigationOf(app); expect(navigation.ungrouped.map((entry) => entry.name)).not.toContain("livestream"); expect(navigation.ungrouped.find((entry) => entry.module.id === "livestream")?.name).toBe( "Livestream", ); }); it("groups and orders exactly as the stored configuration says", async () => { const { app } = await install({ configuration: { groups: [ { id: "planning", label: "Planning", sortOrder: 20 }, { id: "media", label: "Media", sortOrder: 10 }, ], placements: [ { groupId: "media", moduleId: "livestream", sortOrder: 20 }, { groupId: "media", moduleId: "video", sortOrder: 10 }, { groupId: "planning", moduleId: "calendar", sortOrder: 10 }, ], }, principal: signedIn(), }); const navigation = await navigationOf(app); expect( navigation.groups.map((group) => [group.label, group.modules.map((entry) => entry.name)]), ).toEqual([ ["Media", ["Video", "Livestream"]], ["Planning", ["Calendar"]], ]); expect(navigation.ungrouped).toEqual([]); }); it("embeds the generic mount descriptor unchanged inside each entry", async () => { const { app } = await install({ principal: signedIn() }); const navigation = await navigationOf(app); const livestream = navigation.ungrouped.find((entry) => entry.module.id === "livestream"); // Presentation wraps the host-neutral descriptor; it never rewrites it. expect(livestream?.module).toEqual({ api: { baseUrl: "/api/v1/modules/livestream" }, id: "livestream", permissions: [], realtime: { baseUrl: "/api/v1/module-realtime/livestream" }, ui: { url: "/api/v1/module-ui/livestream/1.0.0" }, version: "1.0.0", }); expect(Object.keys(livestream ?? {}).sort()).toEqual(["module", "name"]); }); it("agrees exactly with the generic inventory about what is mountable", async () => { const { app } = await install({ configuration: { groups: [{ id: "media", label: "Media", sortOrder: 10 }], placements: [{ groupId: "media", moduleId: "video", sortOrder: 10 }], }, principal: signedIn(), }); const navigation = await navigationOf(app); const inventory = (await (await app.request(MODULE_UI_INVENTORY_PATH)).json()) as { modules: ReadonlyArray<{ id: string }>; }; const navigationModules = [ ...navigation.groups.flatMap((group) => group.modules), ...navigation.ungrouped, ].map((entry) => entry.module); // Both derive from one publication registry through one shared description, so they cannot // disagree about what exists or about any URL. expect(navigationModules.map((module) => module.id).sort()).toEqual( inventory.modules.map((module) => module.id).sort(), ); }); it("keeps the generic inventory free of presentation metadata", async () => { const { app } = await install({ principal: signedIn() }); const body = await (await app.request(MODULE_UI_INVENTORY_PATH)).text(); // The host-neutral contract a standalone host consumes must not learn Ekklesia has folders or // display names. That layering is the whole reason navigation is a separate route. for (const field of ["name", "label", "group", "order", "ungrouped"]) { expect(body, field).not.toContain(`"${field}"`); } }); it("omits a module that is no longer installed, without breaking navigation", async () => { const { app } = await install({ configuration: { groups: [{ id: "media", label: "Media", sortOrder: 10 }], placements: [ { groupId: "media", moduleId: "video", sortOrder: 10 }, { groupId: "media", moduleId: "removed-module", sortOrder: 20 }, ], }, fixtures: [{ id: "video", name: "Video" }], principal: signedIn(), }); const navigation = await navigationOf(app); expect(navigation.groups[0]?.modules.map((entry) => entry.module.id)).toEqual(["video"]); expect(JSON.stringify(navigation)).not.toContain("removed-module"); }); it("keeps a newly installed module visible alongside an existing arrangement", async () => { const { app } = await install({ configuration: { groups: [{ id: "media", label: "Media", sortOrder: 10 }], placements: [{ groupId: "media", moduleId: "video", sortOrder: 10 }], }, principal: signedIn(), }); const navigation = await navigationOf(app); expect(navigation.groups[0]?.modules.map((entry) => entry.name)).toEqual(["Video"]); // Calendar and Livestream were never configured and are still reachable. expect(navigation.ungrouped.map((entry) => entry.name)).toEqual(["Calendar", "Livestream"]); }); it("excludes a module that ships no UI, exactly like the inventory does", async () => { const { app } = await install({ fixtures: [ { id: "video", name: "Video" }, { id: "roster", name: "Roster", withUi: false }, ], principal: signedIn(), }); const navigation = await navigationOf(app); expect(JSON.stringify(navigation)).not.toContain("Roster"); }); it("leaks no filesystem path, package root, or manifest internal", async () => { const { app } = await install({ principal: signedIn() }); const body = await (await app.request(DASHBOARD_NAVIGATION_PATH)).text(); expect(body).not.toContain(tmpdir()); expect(body).not.toContain("dist"); expect(body).not.toContain("@test/module-"); expect(body).not.toContain("ekklesia.module.json"); expect(body).not.toContain("entrypoints"); expect(body).not.toContain("manifestVersion"); }); it("is per-principal, uncached, and refused to anonymous callers", async () => { const { app: anonymous } = await install(); const { app } = await install({ principal: signedIn() }); const denied = await anonymous.request(DASHBOARD_NAVIGATION_PATH); const allowed = await app.request(DASHBOARD_NAVIGATION_PATH, { headers: { origin: trustedOrigin }, }); expect(denied.status).toBe(403); expect(await denied.text()).not.toContain("Livestream"); expect(allowed.headers.get("cache-control")).toBe("no-store"); const vary = allowed.headers.get("vary")?.toLowerCase() ?? ""; expect(vary).toContain("origin"); expect(vary).toContain("cookie"); }); }); describe("Dashboard navigation — organizing", () => { it("creates a group and places a module, and both persist into the resolved view", async () => { const { app } = await administrator(); expect( ( await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/groups/media`, { label: "Media", order: 10, }) ).status, ).toBe(200); expect( ( await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/placements/livestream`, { groupId: "media", order: 10, }) ).status, ).toBe(200); const navigation = await navigationOf(app); expect(navigation.groups.map((group) => group.label)).toEqual(["Media"]); expect(navigation.groups[0]?.modules.map((entry) => entry.name)).toEqual(["Livestream"]); expect(navigation.ungrouped.map((entry) => entry.name)).toEqual(["Calendar", "Video"]); }); it("renames a group without moving anything", async () => { const { app } = await administrator({ groups: [{ id: "media", label: "Media", sortOrder: 10 }], placements: [{ groupId: "media", moduleId: "livestream", sortOrder: 10 }], }); await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/groups/media`, { label: "Sunday Production", order: 10, }); const navigation = await navigationOf(app); expect(navigation.groups[0]).toMatchObject({ id: "media", label: "Sunday Production" }); expect(navigation.groups[0]?.modules.map((entry) => entry.name)).toEqual(["Livestream"]); }); it("moves a module between groups and changes only its placement", async () => { const { app } = await administrator({ groups: [ { id: "media", label: "Media", sortOrder: 10 }, { id: "sunday-production", label: "Sunday Production", sortOrder: 5 }, ], placements: [ { groupId: "media", moduleId: "livestream", sortOrder: 10 }, { groupId: "media", moduleId: "video", sortOrder: 20 }, ], }); const before = await navigationOf(app); const livestreamBefore = before.groups .flatMap((group) => group.modules) .find((entry) => entry.module.id === "livestream"); await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/placements/livestream`, { groupId: "sunday-production", order: 10, }); const after = await navigationOf(app); const livestreamAfter = after.groups .flatMap((group) => group.modules) .find((entry) => entry.module.id === "livestream"); // The one property this whole pass exists to guarantee: the module — its artifact URL, its API // root, its permissions, its version, its name — is byte-identical across the move. expect(livestreamAfter).toEqual(livestreamBefore); expect( after.groups.find((group) => group.id === "sunday-production")?.modules.map((e) => e.name), ).toEqual(["Livestream"]); expect(after.groups.find((group) => group.id === "media")?.modules.map((e) => e.name)).toEqual([ "Video", ]); }); it("ungroups a module deliberately", async () => { const { app } = await administrator({ groups: [{ id: "media", label: "Media", sortOrder: 10 }], placements: [{ groupId: "media", moduleId: "video", sortOrder: 10 }], }); await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/placements/video`, { groupId: null, order: 5, }); const navigation = await navigationOf(app); expect(navigation.groups[0]?.modules).toEqual([]); expect(navigation.ungrouped.map((entry) => entry.name)).toEqual([ "Video", "Calendar", "Livestream", ]); }); it("ungroups a deleted group's modules rather than losing them", async () => { const { app } = await administrator({ groups: [{ id: "media", label: "Media", sortOrder: 10 }], placements: [{ groupId: "media", moduleId: "video", sortOrder: 10 }], }); const deleted = await mutate(app, "DELETE", `${DASHBOARD_NAVIGATION_PATH}/groups/media`); const navigation = await navigationOf(app); expect(deleted.status).toBe(204); expect(navigation.groups).toEqual([]); expect(navigation.ungrouped.map((entry) => entry.name)).toEqual([ "Video", "Calendar", "Livestream", ]); }); it("refuses a placement naming a group that does not exist", async () => { const { app } = await administrator(); const response = await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/placements/video`, { groupId: "not-a-group", order: 10, }); expect(response.status).toBe(404); expect(await response.json()).toEqual({ code: "DASHBOARD_GROUP_NOT_FOUND" }); }); it("refuses a body carrying anything beyond the declared fields", async () => { const { app } = await administrator(); const bodies: readonly unknown[] = [ { label: "Media", order: 10, parentGroupId: "root" }, { label: "Media", order: 10, ownerModuleId: "video" }, { label: "Media" }, { label: "", order: 10 }, { label: "Media", order: -1 }, { label: "Media", order: 1.5 }, { label: 42, order: 10 }, [], null, ]; for (const body of bodies) { const response = await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/groups/media`, body); expect(response.status, JSON.stringify(body)).toBe(400); expect(await response.json(), JSON.stringify(body)).toEqual({ code: "INVALID_REQUEST" }); } }); it("refuses a malformed group identifier in the path", async () => { const { app } = await administrator(); const response = await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/groups/Not%20Valid`, { label: "Media", order: 10, }); expect(response.status).toBe(400); }); }); describe("Dashboard navigation — authority", () => { it("refuses organizing to a signed-in user without the manage permission", async () => { // Reading works for any signed-in user; organizing does not. const { app } = await install({ principal: signedIn() }); expect((await app.request(DASHBOARD_NAVIGATION_PATH)).status).toBe(200); const response = await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/groups/media`, { label: "Media", order: 10, }); expect(response.status).toBe(403); expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" }); }); it("refuses organizing from an untrusted origin, before authorization even runs", async () => { const { app } = await administrator(); const response = await app.request(`${DASHBOARD_NAVIGATION_PATH}/groups/media`, { body: JSON.stringify({ label: "Media", order: 10 }), headers: { "content-type": "application/json", origin: untrustedOrigin }, method: "PUT", }); expect(response.status).toBe(403); expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" }); }); it("refuses organizing to an anonymous caller", async () => { const { app } = await install(); for (const [method, url, body] of [ ["PUT", `${DASHBOARD_NAVIGATION_PATH}/groups/media`, { label: "Media", order: 10 }], ["DELETE", `${DASHBOARD_NAVIGATION_PATH}/groups/media`, undefined], ["PUT", `${DASHBOARD_NAVIGATION_PATH}/placements/video`, { groupId: null, order: 0 }], ] as const) { const response = await mutate(app, method, url, body); expect(response.status, `${method} ${url}`).toBe(403); } }); it("grants no module authority to an administrator who reorganizes the Dashboard", async () => { // The platform owner holds `core.dashboard.navigation.manage` through the owner bypass. That // says nothing about any module: the hints in the navigation view are still evaluated per // module permission, and every module route stays enforced on its own. const { app } = await administrator(); await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/groups/media`, { label: "Media", order: 10, }); await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/placements/livestream`, { groupId: "media", order: 10, }); const navigation = await navigationOf(app); const livestream = navigation.groups[0]?.modules[0]; // The fixture modules declare no permissions at all, so an owner still holds none for them: // moving a module into a folder cannot manufacture a permission hint, let alone a grant. expect(livestream?.module.permissions).toEqual([]); }); it("routes no method the navigation contract does not define", async () => { const { app } = await administrator(); for (const method of ["POST", "PATCH"] as const) { const response = await app.request(DASHBOARD_NAVIGATION_PATH, { headers: { origin: trustedOrigin }, method, }); expect(response.status, method).toBe(404); } }); });