CI / verify (pull_request) Successful in 1m11s
CI / infrastructure-smoke (pull_request) Successful in 42s
CI / package-release (pull_request) Successful in 48s
CI / migration-smoke (pull_request) Successful in 2m27s
CI / image-build (pull_request) Successful in 1m8s
CI / deploy-smoke (same-origin) (pull_request) Successful in 52s
CI / package-publication (pull_request) Successful in 57s
CI / deploy-smoke (split) (pull_request) Successful in 3m35s
ModuleUiHostContext.navigation is now `navigation?: ModuleUiNavigation | undefined`, so the public context change is source-compatible: a context built before navigation existed (without the member) still compiles unchanged, as 0.x patch releases require. The Dashboard supplies navigation; hosts without addressable pages, including the display host, omit the member; modules feature-detect it. Existing hosts' tests are restored to their pre-PR shape, and a module-ui-sdk test proves a pre-navigation context still satisfies ModuleUiHostContext (and fails if the member is ever made required). Runtime behaviour, manifest v8/v9 compatibility, and security rules are unchanged. ADR 0063 and the module development guide say so. Co-Authored-By: Claude Opus 5.5 <[email protected]>
706 lines
26 KiB
TypeScript
706 lines
26 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import {
|
|
bootstrapHost,
|
|
type HostModuleUiDescriptor,
|
|
HostProtocolError,
|
|
loadDashboardNavigation,
|
|
loadHostAccount,
|
|
loadHostPrincipal,
|
|
parseDashboardNavigation,
|
|
parseDashboardSettings,
|
|
parseHostAccount,
|
|
parseHostAccountPreferences,
|
|
parseHostPrincipal,
|
|
resolveApiOrigin,
|
|
saveHostAccountPreferences,
|
|
} 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 accountPayload = {
|
|
email: "[email protected]",
|
|
name: "Ada Lovelace",
|
|
preferences: { appearance: "dark", motion: "reduce" },
|
|
};
|
|
|
|
const inventoryPayload = {
|
|
modules: [
|
|
{
|
|
contributions: [],
|
|
api: { baseUrl: "/api/v1/modules/example" },
|
|
display: { url: "/display/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 = {
|
|
contributions: [],
|
|
apiBaseUrl: "/api/v1/modules/calendar",
|
|
displayPageUrl: undefined,
|
|
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",
|
|
navigation: { icon: null, pages: [] },
|
|
},
|
|
],
|
|
order: 10,
|
|
},
|
|
],
|
|
ungrouped: [
|
|
{
|
|
module: {
|
|
contributions: [],
|
|
api: { baseUrl: "/api/v1/modules/calendar" },
|
|
display: null,
|
|
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",
|
|
navigation: { icon: null, pages: [] },
|
|
},
|
|
],
|
|
};
|
|
|
|
const exampleDescriptor: HostModuleUiDescriptor = {
|
|
contributions: [],
|
|
apiBaseUrl: "/api/v1/modules/example",
|
|
displayPageUrl: "/display/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", navigation: { icon: null, pages: [] } }],
|
|
};
|
|
}
|
|
|
|
describe("host protocol — API origin (ADR 0045)", () => {
|
|
const pageOrigin = "https://dash.example.test";
|
|
|
|
it("uses the page's own origin when the page declares none (same-origin topology)", () => {
|
|
expect(resolveApiOrigin(undefined, pageOrigin)).toBe(pageOrigin);
|
|
expect(resolveApiOrigin(null, pageOrigin)).toBe(pageOrigin);
|
|
expect(resolveApiOrigin("", pageOrigin)).toBe(pageOrigin);
|
|
});
|
|
|
|
it("uses a declared canonical origin (split topology)", () => {
|
|
expect(resolveApiOrigin("https://api.example.test", pageOrigin)).toBe(
|
|
"https://api.example.test",
|
|
);
|
|
expect(resolveApiOrigin("http://127.0.0.1:3000", pageOrigin)).toBe("http://127.0.0.1:3000");
|
|
});
|
|
|
|
it.each([
|
|
["a trailing slash", "https://api.example.test/"],
|
|
["a path", "https://api.example.test/api"],
|
|
["a query", "https://api.example.test?x=1"],
|
|
["credentials", "https://user:[email protected]"],
|
|
["a non-canonical spelling", "HTTPS://API.example.test"],
|
|
["a default port spelled out", "https://api.example.test:443"],
|
|
["another scheme", "javascript:alert(1)"],
|
|
["a protocol-relative value", "//api.example.test"],
|
|
["a relative value", "/api"],
|
|
["surrounding whitespace", " https://api.example.test"],
|
|
["something that is not a URL", "api.example.test"],
|
|
])("refuses %s, so the page fails closed", (_case, declared) => {
|
|
expect(resolveApiOrigin(declared, pageOrigin)).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
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, icon: undefined, name: "Example Module", pages: [] },
|
|
]);
|
|
expect(navigation.ungrouped).toEqual([
|
|
{ descriptor: calendarDescriptor, icon: undefined, name: "Calendar", pages: [] },
|
|
]);
|
|
});
|
|
|
|
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",
|
|
navigation: { icon: null, pages: [] },
|
|
},
|
|
],
|
|
},
|
|
];
|
|
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 and Settings together", async () => {
|
|
const { calls, implementation } = stubFetch({
|
|
"/api/v1/dashboard/navigation": () => jsonResponse(navigationPayload),
|
|
"/api/v1/dashboard/settings": () =>
|
|
jsonResponse({
|
|
modules: [{ available: true, id: "example", name: "Example" }],
|
|
sections: ["displays"],
|
|
}),
|
|
"/api/v1/principal": () => jsonResponse({ kind: "authenticated", userId: "user-1" }),
|
|
"/api/v1/account": () => jsonResponse(accountPayload),
|
|
});
|
|
|
|
const bootstrap = await bootstrapHost({ apiBaseUrl, fetch: implementation });
|
|
|
|
expect(calls.map((call) => call.url)).toEqual([
|
|
`${apiBaseUrl}/api/v1/principal`,
|
|
`${apiBaseUrl}/api/v1/dashboard/navigation`,
|
|
`${apiBaseUrl}/api/v1/dashboard/settings`,
|
|
`${apiBaseUrl}/api/v1/account`,
|
|
]);
|
|
expect(bootstrap.account).toEqual(accountPayload);
|
|
expect(bootstrap.principal).toEqual({ kind: "authenticated", userId: "user-1" });
|
|
expect(bootstrap.navigation.groups[0]?.modules[0]?.descriptor).toEqual(exampleDescriptor);
|
|
expect(bootstrap.settings).toEqual({
|
|
modules: [{ available: true, id: "example", name: "Example" }],
|
|
sections: ["displays"],
|
|
});
|
|
});
|
|
|
|
it("keeps the Dashboard working when Settings cannot be loaded", async () => {
|
|
const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
const { implementation } = stubFetch({
|
|
"/api/v1/dashboard/navigation": () => jsonResponse(navigationPayload),
|
|
"/api/v1/dashboard/settings": () => jsonResponse({ code: "UNAVAILABLE" }, 503),
|
|
"/api/v1/principal": () => jsonResponse({ kind: "authenticated", userId: "user-1" }),
|
|
});
|
|
|
|
const bootstrap = await bootstrapHost({ apiBaseUrl, fetch: implementation });
|
|
|
|
expect(bootstrap.settings).toBe("unavailable");
|
|
expect(bootstrap.navigation.groups[0]?.modules[0]?.descriptor).toEqual(exampleDescriptor);
|
|
consoleError.mockRestore();
|
|
});
|
|
|
|
it("parses the Settings view strictly", () => {
|
|
expect(parseDashboardSettings({ modules: [], sections: ["general", "system"] })).toEqual({
|
|
modules: [],
|
|
sections: ["general", "system"],
|
|
});
|
|
for (const malformed of [
|
|
null,
|
|
{ sections: [] },
|
|
{ modules: [], sections: ["billing"] },
|
|
{ modules: [], sections: ["general", "general"] },
|
|
{ extra: 1, modules: [], sections: [] },
|
|
{ modules: [{ available: "yes", id: "a", name: "A" }], sections: [] },
|
|
{ modules: [{ available: true, id: "", name: "A" }], sections: [] },
|
|
{ modules: [{ available: true, id: "a", name: " " }], sections: [] },
|
|
{
|
|
modules: [
|
|
{ available: true, id: "a", name: "A" },
|
|
{ available: false, id: "a", name: "B" },
|
|
],
|
|
sections: [],
|
|
},
|
|
]) {
|
|
expect(() => parseDashboardSettings(malformed), JSON.stringify(malformed)).toThrow(
|
|
HostProtocolError,
|
|
);
|
|
}
|
|
});
|
|
|
|
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 = {
|
|
contributions: [],
|
|
apiBaseUrl: "/api/v1/modules/calendar",
|
|
displayPageUrl: undefined,
|
|
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 the host-owned display page at the API origin in split-origin deployments", () => {
|
|
const { implementation } = recordingFetch();
|
|
const context = createModuleUiContext({
|
|
config: { apiBaseUrl: "https://api.example.test", fetch: implementation },
|
|
descriptor: exampleDescriptor,
|
|
principal: { kind: "authenticated", userId: "user-1" },
|
|
});
|
|
|
|
expect(context.display?.url).toBe("https://api.example.test/display/example");
|
|
});
|
|
|
|
it("exposes exactly the seven contract members and the browser principal", () => {
|
|
const { implementation } = recordingFetch();
|
|
|
|
const context = contextFor(implementation);
|
|
|
|
expect(Object.keys(context).sort()).toEqual([
|
|
"api",
|
|
"contributions",
|
|
"display",
|
|
"moduleId",
|
|
"permissions",
|
|
"principal",
|
|
"realtime",
|
|
]);
|
|
expect(context.moduleId).toBe("example");
|
|
// Navigation comes only from a host with addressable pages (ADR 0063). Without it the member is
|
|
// omitted, so the context is exactly the shape it was before navigation existed.
|
|
expect("navigation" in context).toBe(false);
|
|
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",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("host protocol — the signed-in person's account (ADR 0062)", () => {
|
|
it("reads the account strictly: exactly name, email, and two known preferences", () => {
|
|
expect(parseHostAccount(accountPayload)).toEqual(accountPayload);
|
|
for (const payload of [
|
|
null,
|
|
{},
|
|
{ ...accountPayload, image: "https://tracker.example.test/me.png" },
|
|
{ ...accountPayload, userId: "user-1" },
|
|
{ ...accountPayload, email: "" },
|
|
{ ...accountPayload, email: 1 },
|
|
{ ...accountPayload, name: null },
|
|
{ ...accountPayload, preferences: { appearance: "sepia", motion: "reduce" } },
|
|
{ ...accountPayload, preferences: { appearance: "dark", motion: "none" } },
|
|
{ ...accountPayload, preferences: { appearance: "dark" } },
|
|
{ ...accountPayload, preferences: { appearance: "dark", motion: "allow", radius: "lg" } },
|
|
]) {
|
|
expect(() => parseHostAccount(payload), JSON.stringify(payload)).toThrowError(
|
|
expect.objectContaining({ code: "HOST_RESPONSE_INVALID" }),
|
|
);
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
["system", "system"],
|
|
["light", "reduce"],
|
|
["dark", "allow"],
|
|
] as const)("accepts appearance %s with motion %s", (appearance, motion) => {
|
|
expect(parseHostAccountPreferences({ appearance, motion })).toEqual({ appearance, motion });
|
|
});
|
|
|
|
it("loads the account from its one platform path, with credentials, naming nobody", async () => {
|
|
const { calls, implementation } = stubFetch({
|
|
"/api/v1/account": () => jsonResponse(accountPayload),
|
|
});
|
|
|
|
expect(await loadHostAccount({ apiBaseUrl, fetch: implementation })).toEqual(accountPayload);
|
|
expect(calls).toHaveLength(1);
|
|
expect(calls[0]?.url).toBe(`${apiBaseUrl}/api/v1/account`);
|
|
expect(calls[0]?.init?.credentials).toBe("include");
|
|
});
|
|
|
|
it("saves a change with one credentialed PATCH of exactly the change, and returns the server's answer", async () => {
|
|
const { calls, implementation } = stubFetch({
|
|
"/api/v1/account/preferences": () => jsonResponse({ appearance: "light", motion: "reduce" }),
|
|
});
|
|
|
|
const saved = await saveHostAccountPreferences(
|
|
{ apiBaseUrl, fetch: implementation },
|
|
{ appearance: "light" },
|
|
);
|
|
|
|
expect(saved).toEqual({ appearance: "light", motion: "reduce" });
|
|
expect(calls).toHaveLength(1);
|
|
expect(calls[0]?.url).toBe(`${apiBaseUrl}/api/v1/account/preferences`);
|
|
expect(calls[0]?.init?.method).toBe("PATCH");
|
|
expect(calls[0]?.init?.credentials).toBe("include");
|
|
expect(calls[0]?.init?.redirect).toBe("error");
|
|
expect(JSON.parse(String(calls[0]?.init?.body))).toEqual({ appearance: "light" });
|
|
});
|
|
|
|
it("reports a refused save as signed out, and a failed one as unreachable", async () => {
|
|
for (const [status, code] of [
|
|
[403, "HOST_UNAUTHENTICATED"],
|
|
[503, "HOST_REQUEST_FAILED"],
|
|
[400, "HOST_REQUEST_FAILED"],
|
|
] as const) {
|
|
const { implementation } = stubFetch({
|
|
"/api/v1/account/preferences": () => jsonResponse({ code: "X" }, status),
|
|
});
|
|
await expect(
|
|
saveHostAccountPreferences({ apiBaseUrl, fetch: implementation }, { motion: "allow" }),
|
|
).rejects.toMatchObject({ code });
|
|
}
|
|
});
|
|
|
|
it("keeps the Dashboard working when the account cannot be loaded", async () => {
|
|
const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
const { implementation } = stubFetch({
|
|
"/api/v1/account": () => jsonResponse({ code: "ACCOUNT_READ_FAILED" }, 503),
|
|
"/api/v1/dashboard/navigation": () => jsonResponse(navigationPayload),
|
|
"/api/v1/dashboard/settings": () => jsonResponse({ modules: [], sections: [] }),
|
|
"/api/v1/principal": () => jsonResponse({ kind: "authenticated", userId: "user-1" }),
|
|
});
|
|
|
|
const bootstrap = await bootstrapHost({ apiBaseUrl, fetch: implementation });
|
|
|
|
expect(bootstrap.account).toBe("unavailable");
|
|
expect(bootstrap.navigation.groups[0]?.modules[0]?.descriptor).toEqual(exampleDescriptor);
|
|
consoleError.mockRestore();
|
|
});
|
|
|
|
it("never asks for an account on behalf of an anonymous viewer", async () => {
|
|
const { calls, implementation } = stubFetch({
|
|
"/api/v1/principal": () => jsonResponse({ kind: "anonymous" }),
|
|
});
|
|
|
|
const bootstrap = await bootstrapHost({ apiBaseUrl, fetch: implementation });
|
|
|
|
expect(bootstrap.account).toBe("unavailable");
|
|
expect(calls.map((call) => call.url)).toEqual([`${apiBaseUrl}/api/v1/principal`]);
|
|
});
|
|
});
|