Files
Ekklesia/surfaces/shell/tests/host-protocol.test.ts
ekklesiaandClaude Opus 5.5 c78f1318b4
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
Make the module navigation capability an optional context member
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]>
2026-10-15 09:13:03 -05:00

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`]);
});
});