462 lines
17 KiB
TypeScript
462 lines
17 KiB
TypeScript
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<Record<string, () => 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<string, unknown>;
|
|
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: "[email protected]",
|
|
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",
|
|
);
|
|
});
|
|
});
|