import { ANONYMOUS_PRINCIPAL, createAuthenticatedPrincipal, type EkklesiaPrincipal, parseEkklesiaUserId, parseModuleManifest, } from "@ekklesia/contracts"; import { type AuthorizationStore, CORE_DEVICES_MANAGE, CORE_MODULES_CONFIGURATION_MANAGE, CORE_MODULES_LIFECYCLE_MANAGE, createAuthorizationEvaluator, } from "@ekklesia/core/authorization"; import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity"; import { composePlatformPermissionRegistry, createModuleUiPublicationRegistry, type DiscoveredModule, EMPTY_MODULE_REALTIME_REGISTRY, type LegacyModuleRuntimeServices, type ModuleDatabase, type ModuleLifecycleConfiguration, } from "@ekklesia/core/modules"; import { describe, expect, it, vi } from "vitest"; import { createApiApp } from "../src/app.js"; import { DASHBOARD_SETTINGS_PATH, type DashboardSettingsResponse, } from "../src/dashboard-settings-routes.js"; import { createInMemoryAccountStore } from "./support/in-memory-account-store.js"; import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; import { testDashboardHostAssets } from "./support/dashboard-host-assets.js"; import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js"; import { createInMemoryModuleLifecycleStore, moduleLifecycleSnapshotFor, } from "./support/in-memory-module-lifecycle.js"; import { emptyModuleStorage } from "./support/in-memory-module-storage.js"; import { readyHealth } from "./support/ready-health.js"; /** * `GET /api/v1/dashboard/settings` (ADR 0061): which Settings destinations the viewer may see, * evaluated by the server through Core's one authorization evaluator. Presentation hints, never * authority: every Settings data route keeps its own enforcement. */ const viewerId = parseEkklesiaUserId("44444444-4444-4444-8444-444444444444"); const viewer = createAuthenticatedPrincipal(viewerId); function discovered( id: string, options: { readonly name?: string; readonly settings: boolean }, ): DiscoveredModule { const manifest = parseModuleManifest({ capabilities: [], compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" }, configuration: options.settings ? [ { default: "Hello", description: "A greeting.", key: "greeting", maxLength: 40, type: "string", }, ] : [], entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" }, id, manifestVersion: 8, name: options.name ?? `${id} module`, permissions: [], version: "0.1.0", }); return { contributions: [], directory: `/modules/${id}`, entrypoints: { server: `/modules/${id}/dist/server/index.js`, ui: `/modules/${id}/dist/ui/index.js`, }, manifest, manifestPath: `/modules/${id}/ekklesia.module.json`, packageName: `@test/${id}`, }; } interface Options { readonly principal?: EkklesiaPrincipal; readonly owner?: boolean; readonly grants?: readonly string[]; readonly modules?: readonly DiscoveredModule[]; readonly configuration?: ModuleLifecycleConfiguration; readonly ownerLookupFails?: boolean; } function build(options: Options = {}) { const modules = options.modules ?? [ discovered("signage", { name: "Signage", settings: true }), discovered("calendar", { name: "Calendar", settings: true }), discovered("retired", { name: "Archive", settings: true }), discovered("plain", { name: "Plain", settings: false }), ]; const isPlatformOwner = vi.fn(async () => { if (options.ownerLookupFails) { throw new Error("database down"); } return options.owner === true; }); const grants = new Set(options.grants ?? []); const evaluator = createAuthorizationEvaluator({ grants: { hasActiveGrant: async ({ permission }) => grants.has(String(permission)) }, platformOwners: { isPlatformOwner }, }); const runtime: EkklesiaIdentityRuntime = { handleAuthRequest: vi.fn(), resolvePrincipal: vi.fn().mockResolvedValue(options.principal ?? viewer), }; const store = { createGrant: vi.fn(), grantPlatformOwner: vi.fn(), hasActiveGrant: vi.fn(), isPlatformOwner, listGrantsForUser: vi.fn(), revokeGrant: vi.fn(), revokePlatformOwner: vi.fn(), } as unknown as AuthorizationStore; const app = createApiApp({ authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store }, account: createInMemoryAccountStore(), dashboard: createInMemoryDashboardStore(), dashboardHostAssets: testDashboardHostAssets(), health: readyHealth(), moduleConfiguration: emptyModuleConfiguration(), moduleLifecycle: { snapshot: moduleLifecycleSnapshotFor( modules, options.configuration ?? { modules: [{ enabled: false, moduleId: "retired" }] }, ), store: createInMemoryModuleLifecycleStore(), }, moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY, moduleRoutes: Object.freeze([]), moduleServices: Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase, }) as LegacyModuleRuntimeServices, moduleStorage: emptyModuleStorage(), moduleUi: createModuleUiPublicationRegistry([]), runtime, trustedOrigins: ["https://dashboard.example.test"], }); return { app, isPlatformOwner }; } async function settings(options: Options = {}) { const { app } = build(options); const response = await app.request(DASHBOARD_SETTINGS_PATH); const body = response.status === 200 ? ((await response.json()) as DashboardSettingsResponse) : undefined; return { body, response }; } describe("GET /api/v1/dashboard/settings", () => { it("shows a signed-in person with no administrative permission nothing at all", async () => { const { body, response } = await settings(); expect(response.status).toBe(200); expect(response.headers.get("cache-control")).toBe("no-store"); expect(body).toEqual({ modules: [], sections: [] }); }); it("refuses an anonymous viewer, like the navigation it sits beside", async () => { const { response } = await settings({ principal: ANONYMOUS_PRINCIPAL }); expect(response.status).toBe(403); }); it("shows the platform owner every section, in order, and every module with settings", async () => { const { body } = await settings({ owner: true }); expect(body).toEqual({ modules: [ { available: false, id: "retired", name: "Archive" }, { available: true, id: "calendar", name: "Calendar" }, { available: true, id: "signage", name: "Signage" }, ], sections: ["general", "branding", "displays", "discover", "system"], }); }); it("shows Displays exactly to whoever may manage devices", async () => { const { body } = await settings({ grants: [String(CORE_DEVICES_MANAGE)] }); expect(body).toEqual({ modules: [], sections: ["displays"] }); }); it("shows Discover — module lifecycle — exactly to whoever may manage which modules run", async () => { const { body } = await settings({ grants: [String(CORE_MODULES_LIFECYCLE_MANAGE)] }); expect(body).toEqual({ modules: [], sections: ["discover"] }); }); it("lists modules' settings exactly to whoever may configure modules, flagging disabled ones", async () => { const { body } = await settings({ grants: [String(CORE_MODULES_CONFIGURATION_MANAGE)] }); // A module that declares no configuration has no settings page; a disabled module is listed so // its address can say it is unavailable, but marked so the Dashboard does not navigate to it. expect(body).toEqual({ modules: [ { available: false, id: "retired", name: "Archive" }, { available: true, id: "calendar", name: "Calendar" }, { available: true, id: "signage", name: "Signage" }, ], sections: [], }); }); it("keeps General, Branding, and System to the platform owner until each has a permission of its own", async () => { const { body } = await settings({ grants: [ String(CORE_DEVICES_MANAGE), String(CORE_MODULES_LIFECYCLE_MANAGE), String(CORE_MODULES_CONFIGURATION_MANAGE), ], }); expect(body?.sections).toEqual(["displays", "discover"]); }); it("is a 503, never an empty answer, when authorization cannot be evaluated", async () => { const { response } = await settings({ ownerLookupFails: true }); expect(response.status).toBe(503); expect(await response.json()).toEqual({ code: "AUTHORIZATION_EVALUATION_FAILED" }); }); it("is a hint, not authority: the data behind a section stays refused without its permission", async () => { const { app } = build({ grants: [] }); for (const path of [ "/api/v1/module-lifecycle", "/api/v1/module-configuration/signage", "/api/v1/grants", ]) { const response = await app.request(path); expect(response.status, path).toBe(403); } }); });