import { describe, expect, it, vi } from "vitest"; import { bootstrapHost, type HostModuleUiDescriptor, loadDashboardNavigation, loadHostPrincipal, parseDashboardNavigation, parseHostPrincipal, } from "../src/host-protocol.js"; import { createModuleUiContext, moduleUiSourceUrl } from "../src/module-context.js"; const apiBaseUrl = "https://ekklesia.example.test"; function jsonResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { headers: { "content-type": "application/json" }, status, }); } /** A fetch double that answers the two platform paths and records every call, so the tests can * assert both the URL the shell built and the credentials it used. */ function stubFetch(routes: Readonly Response>>) { const calls: Array<{ init: RequestInit | undefined; url: string }> = []; const implementation = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); calls.push({ init, url }); const route = Object.entries(routes).find(([path]) => url === `${apiBaseUrl}${path}`); if (!route) { return new Response(JSON.stringify({ code: "NOT_FOUND" }), { status: 404 }); } return route[1](); }); return { calls, implementation: implementation as unknown as typeof fetch }; } const inventoryPayload = { modules: [ { api: { baseUrl: "/api/v1/modules/example" }, id: "example", permissions: ["example.records.read"], realtime: { baseUrl: "/api/v1/module-realtime/example" }, ui: { url: "/api/v1/module-ui/example/0.1.0" }, version: "0.1.0", }, ], }; const calendarDescriptor: HostModuleUiDescriptor = { apiBaseUrl: "/api/v1/modules/calendar", id: "calendar", permissions: [], realtimeBaseUrl: "/api/v1/module-realtime/calendar", uiUrl: "/api/v1/module-ui/calendar/2.0.0", version: "2.0.0", }; const navigationPayload = { groups: [ { id: "media", label: "Media", modules: [{ module: inventoryPayload.modules[0], name: "Example Module" }], order: 10, }, ], ungrouped: [ { module: { api: { baseUrl: "/api/v1/modules/calendar" }, id: "calendar", permissions: [], realtime: { baseUrl: "/api/v1/module-realtime/calendar" }, ui: { url: "/api/v1/module-ui/calendar/2.0.0" }, version: "2.0.0", }, name: "Calendar", }, ], }; const exampleDescriptor: HostModuleUiDescriptor = { apiBaseUrl: "/api/v1/modules/example", id: "example", permissions: ["example.records.read"], realtimeBaseUrl: "/api/v1/module-realtime/example", uiUrl: "/api/v1/module-ui/example/0.1.0", version: "0.1.0", }; function navigationWithModule(module: unknown): unknown { return { groups: [], ungrouped: [{ module, name: "Example Module" }] }; } describe("host protocol — descriptor validation", () => { it("parses a well-formed descriptor out of the navigation view", () => { const navigation = parseDashboardNavigation(navigationWithModule(inventoryPayload.modules[0])); expect(navigation.ungrouped[0]?.descriptor).toEqual(exampleDescriptor); }); it("rejects a malformed descriptor rather than yielding a half-built one", () => { const base = inventoryPayload.modules[0] as Record; const malformed: unknown[] = [ {}, { id: "example", version: "0.1.0" }, { ...base, ui: {} }, { ...base, api: { baseUrl: "" } }, { ...base, realtime: {} }, { ...base, permissions: "example.records.read" }, { ...base, permissions: [42] }, // Every URL must be root-relative, so a value that would resolve against another origin is // refused rather than silently used to fetch browser code. { ...base, ui: { url: "https://evil.example.test/x.js" } }, { ...base, ui: { url: "//evil.example.test/x.js" } }, { ...base, ui: { url: "relative/x.js" } }, { ...base, api: { baseUrl: "https://evil.example.test/api" } }, { ...base, realtime: { baseUrl: "https://evil.example.test/live" } }, { ...base, realtime: { baseUrl: "//evil.example.test/live" } }, { ...base, realtime: { baseUrl: "/\\evil.example.test/live" } }, ]; for (const module of malformed) { expect( () => parseDashboardNavigation(navigationWithModule(module)), JSON.stringify(module), ).toThrowError(expect.objectContaining({ code: "HOST_RESPONSE_INVALID" })); } }); it("distinguishes an authentication challenge from an authorization denial", async () => { const unauthenticated = stubFetch({ "/api/v1/dashboard/navigation": () => jsonResponse({ code: "AUTHORIZATION_DENIED" }, 401), }); await expect( loadDashboardNavigation({ apiBaseUrl, fetch: unauthenticated.implementation }), ).rejects.toMatchObject({ code: "HOST_UNAUTHENTICATED" }); const forbidden = stubFetch({ "/api/v1/dashboard/navigation": () => jsonResponse({ code: "AUTHORIZATION_DENIED" }, 403), }); await expect( loadDashboardNavigation({ apiBaseUrl, fetch: forbidden.implementation }), ).rejects.toMatchObject({ code: "HOST_ACCESS_DENIED" }); const { implementation } = stubFetch({ "/api/v1/dashboard/navigation": () => jsonResponse({ code: "INTERNAL_ERROR" }, 500), }); await expect( loadDashboardNavigation({ apiBaseUrl, fetch: implementation }), ).rejects.toMatchObject({ code: "HOST_REQUEST_FAILED" }); }); it("never renders a caught cause, a URL, or a server path in its message", async () => { const failing = vi.fn(async () => { throw new Error("connect ECONNREFUSED /run/postgres/.s.PGSQL.5432"); }) as unknown as typeof fetch; const error = await loadDashboardNavigation({ apiBaseUrl, fetch: failing }).catch( (caught: unknown) => caught, ); expect(error).toMatchObject({ code: "HOST_REQUEST_FAILED" }); expect((error as Error).message).toBe("Ekklesia could not be reached."); expect((error as Error).message).not.toContain("PGSQL"); }); }); describe("host protocol — principal", () => { it("maps the platform principal onto the browser-safe SDK shape", () => { expect(parseHostPrincipal({ kind: "anonymous" })).toEqual({ kind: "anonymous" }); expect(parseHostPrincipal({ kind: "authenticated", userId: "user-1" })).toEqual({ kind: "authenticated", userId: "user-1", }); }); it("carries nothing beyond kind and userId, even when the server sends more", () => { const principal = parseHostPrincipal({ email: "someone@example.test", kind: "authenticated", roles: ["owner"], sessionToken: "secret", userId: "user-1", }); expect(Object.keys(principal).sort()).toEqual(["kind", "userId"]); expect(JSON.stringify(principal)).not.toContain("secret"); }); it("rejects a principal it does not recognize", () => { for (const payload of [null, {}, { kind: "authenticated" }, { kind: "owner" }]) { expect(() => parseHostPrincipal(payload)).toThrowError( expect.objectContaining({ code: "HOST_RESPONSE_INVALID" }), ); } }); }); describe("host protocol — Dashboard navigation", () => { it("parses the server's resolved arrangement, keeping the mount descriptor nested", () => { const navigation = parseDashboardNavigation(navigationPayload); expect(navigation.groups.map((group) => [group.id, group.label])).toEqual([["media", "Media"]]); expect(navigation.groups[0]?.modules).toEqual([ { descriptor: exampleDescriptor, name: "Example Module" }, ]); expect(navigation.ungrouped).toEqual([{ descriptor: calendarDescriptor, name: "Calendar" }]); }); it("rejects a malformed navigation document rather than rendering half of it", () => { const malformed: unknown[] = [ {}, { groups: [] }, { groups: {}, ungrouped: [] }, { groups: [{ id: "media", label: "Media" }], ungrouped: [] }, { groups: [{ id: "media", modules: [] }], ungrouped: [] }, { groups: [], ungrouped: [{ name: "Example" }] }, { groups: [], ungrouped: [{ module: inventoryPayload.modules[0] }] }, // An absolute artifact URL must be refused here exactly as in the inventory. { groups: [], ungrouped: [ { module: { ...inventoryPayload.modules[0], ui: { url: "https://evil.example.test/x.js" }, }, name: "Example", }, ], }, ]; for (const payload of malformed) { expect(() => parseDashboardNavigation(payload), JSON.stringify(payload)).toThrowError( expect.objectContaining({ code: "HOST_RESPONSE_INVALID" }), ); } }); it("fetches navigation from the platform path with credentials", async () => { const { calls, implementation } = stubFetch({ "/api/v1/dashboard/navigation": () => jsonResponse(navigationPayload), }); await loadDashboardNavigation({ apiBaseUrl, fetch: implementation }); expect(calls[0]?.url).toBe(`${apiBaseUrl}/api/v1/dashboard/navigation`); expect(calls[0]?.init?.credentials).toBe("include"); }); }); describe("host protocol — bootstrap", () => { it("resolves the principal and then the Dashboard navigation", async () => { const { calls, implementation } = stubFetch({ "/api/v1/dashboard/navigation": () => jsonResponse(navigationPayload), "/api/v1/principal": () => jsonResponse({ kind: "authenticated", userId: "user-1" }), }); const bootstrap = await bootstrapHost({ apiBaseUrl, fetch: implementation }); expect(calls.map((call) => call.url)).toEqual([ `${apiBaseUrl}/api/v1/principal`, `${apiBaseUrl}/api/v1/dashboard/navigation`, ]); expect(bootstrap.principal).toEqual({ kind: "authenticated", userId: "user-1" }); expect(bootstrap.navigation.groups[0]?.modules[0]?.descriptor).toEqual(exampleDescriptor); }); it("never requests navigation for an anonymous viewer", async () => { const { calls, implementation } = stubFetch({ "/api/v1/dashboard/navigation": () => jsonResponse(navigationPayload), "/api/v1/principal": () => jsonResponse({ kind: "anonymous" }), }); const bootstrap = await bootstrapHost({ apiBaseUrl, fetch: implementation }); expect(bootstrap.principal).toEqual({ kind: "anonymous" }); expect(bootstrap.navigation).toEqual({ groups: [], ungrouped: [] }); expect(calls.map((call) => call.url)).toEqual([`${apiBaseUrl}/api/v1/principal`]); }); it("sends the platform principal request with credentials too", async () => { const { calls, implementation } = stubFetch({ "/api/v1/principal": () => jsonResponse({ kind: "anonymous" }), }); await loadHostPrincipal({ apiBaseUrl, fetch: implementation }); expect(calls[0]?.init?.credentials).toBe("include"); }); }); describe("module UI host context", () => { function contextFor(fetchImplementation: typeof fetch, descriptor = exampleDescriptor) { return createModuleUiContext({ config: { apiBaseUrl, fetch: fetchImplementation }, descriptor, principal: { kind: "authenticated", userId: "user-1" }, }); } function recordingFetch() { const calls: Array<{ init: RequestInit | undefined; url: string }> = []; const implementation = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { calls.push({ init, url: String(input) }); return new Response("[]", { status: 200 }); }); return { calls, implementation: implementation as unknown as typeof fetch }; } it("resolves a module-relative path onto the server-produced module API root", async () => { const { calls, implementation } = recordingFetch(); await contextFor(implementation).api.request("records"); expect(calls[0]?.url).toBe(`${apiBaseUrl}/api/v1/modules/example/records`); }); it("normalizes a leading slash and preserves query strings and nested paths", async () => { const { calls, implementation } = recordingFetch(); const api = contextFor(implementation).api; await api.request("/records"); await api.request("records?since=2026-01-01&limit=10"); await api.request("records/abc/comments"); await api.request(""); expect(calls.map((call) => call.url)).toEqual([ `${apiBaseUrl}/api/v1/modules/example/records`, `${apiBaseUrl}/api/v1/modules/example/records?since=2026-01-01&limit=10`, `${apiBaseUrl}/api/v1/modules/example/records/abc/comments`, `${apiBaseUrl}/api/v1/modules/example`, ]); }); it("forwards the module's own RequestInit and imposes credentials", async () => { const { calls, implementation } = recordingFetch(); await contextFor(implementation).api.request("records", { body: JSON.stringify({ value: "one" }), headers: { "content-type": "application/json" }, method: "POST", }); expect(calls[0]?.init).toMatchObject({ credentials: "include", headers: { "content-type": "application/json" }, method: "POST", }); }); it("reports only 401/403 to the host while returning the module's original response", async () => { const statuses = [401, 403, 404, 200]; const onAuthorizationResponse = vi.fn(); const implementation = vi.fn(async () => { const status = statuses.shift(); if (status === undefined) { throw new Error("unexpected request"); } return new Response("module response", { status }); }) as unknown as typeof fetch; const context = createModuleUiContext({ config: { apiBaseUrl, fetch: implementation }, descriptor: exampleDescriptor, onAuthorizationResponse, principal: { kind: "authenticated", userId: "user-1" }, }); const responses = await Promise.all([ context.api.request("one"), context.api.request("two"), context.api.request("three"), context.api.request("four"), ]); expect(responses.map((response) => response.status)).toEqual([401, 403, 404, 200]); expect(onAuthorizationResponse.mock.calls).toEqual([[401], [403]]); }); it("refuses to address anything outside the module's own API", async () => { const { calls, implementation } = recordingFetch(); const api = contextFor(implementation).api; const escapes = [ "../calendar/records", "records/../../calendar/records", "/../../v1/grants", "https://evil.example.test/steal", "//evil.example.test/steal", "HTTPS://evil.example.test/steal", "javascript:alert(1)", ]; for (const path of escapes) { await expect(api.request(path), path).rejects.toMatchObject({ code: "MODULE_API_PATH_INVALID", }); } // Not one of them reached the network. expect(calls).toEqual([]); }); it("gives each module only its own API root", async () => { const { calls, implementation } = recordingFetch(); const calendar: HostModuleUiDescriptor = { apiBaseUrl: "/api/v1/modules/calendar", id: "calendar", permissions: [], realtimeBaseUrl: "/api/v1/module-realtime/calendar", uiUrl: "/api/v1/module-ui/calendar/2.0.0", version: "2.0.0", }; await contextFor(implementation).api.request("records"); await contextFor(implementation, calendar).api.request("events"); expect(calls.map((call) => call.url)).toEqual([ `${apiBaseUrl}/api/v1/modules/example/records`, `${apiBaseUrl}/api/v1/modules/calendar/events`, ]); }); it("builds permission hints from the server's granted set, never a default of true", () => { const { implementation } = recordingFetch(); const granted = contextFor(implementation).permissions; const none = contextFor(implementation, { ...exampleDescriptor, permissions: [] }).permissions; expect(granted.has("example.records.read")).toBe(true); expect(granted.has("example.records.write")).toBe(false); // A module the viewer holds nothing for gets a hint set that answers false to everything. expect(none.has("example.records.read")).toBe(false); expect(none.has("core.authorization.grants.manage")).toBe(false); }); it("exposes exactly the five contract members and the browser principal", () => { const { implementation } = recordingFetch(); const context = contextFor(implementation); expect(Object.keys(context).sort()).toEqual([ "api", "moduleId", "permissions", "principal", "realtime", ]); expect(context.moduleId).toBe("example"); expect(context.principal).toEqual({ kind: "authenticated", userId: "user-1" }); }); it("resolves the artifact URL from the descriptor the server produced", () => { expect(moduleUiSourceUrl({ apiBaseUrl }, exampleDescriptor)).toBe( `${apiBaseUrl}/api/v1/module-ui/example/0.1.0`, ); // Same descriptor, a different deployment origin: the shell composes, it never authors. expect(moduleUiSourceUrl({ apiBaseUrl: "http://127.0.0.1:3000" }, exampleDescriptor)).toBe( "http://127.0.0.1:3000/api/v1/module-ui/example/0.1.0", ); }); });