Stage 1 PR 7 (ADR 0063). A module becomes a first-class part of the Dashboard. - Manifest v9 = v8 plus optional `navigation`: a Lucide icon from the curated MODULE_NAVIGATION_ICONS set, and up to 12 primary pages (stable id, safe label, kebab-case path relative to the module root; no traversal, schemes, queries, duplicates, or nesting). Validated before activation. - Version 8 stays accepted beside version 9, each read strictly as itself (discriminated union); no released manifest is rewritten. The template moves to v9; the released Signage 0.1.5 manifest is a test fixture. - Real module routes: /<id> is the landing view, /<id>/<page> a declared page, anything beneath a page a module-owned detail route; anything else is not-found and mounts nothing. api/assets/display are never pages. - Sidebar: declared icons, pages nested under an open module, one aria-current, collapsed and drawer behaviour; v8 modules unchanged. - module-ui-sdk: ModuleUiHostContext.navigation (location, subscribe, navigate, link, settings), refusing anything outside the module's routes; undefined on the display host. Mount ABI unchanged. - Return-to-origin: one `?return=` carrier on Settings URLs, validated by parseReturnTarget and against running modules' declared routes; Settings Back returns to exactly the originating module page, across refreshes. Co-Authored-By: Claude Opus 5.5 <[email protected]>
654 lines
25 KiB
TypeScript
654 lines
25 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import {
|
|
ANONYMOUS_PRINCIPAL,
|
|
type ModuleRouteHandler,
|
|
parseModuleManifest,
|
|
parsePermissionId,
|
|
type RegisteredModuleHttpRoute,
|
|
} from "@ekklesia/contracts";
|
|
import {
|
|
type AuthorizationEvaluator,
|
|
type AuthorizationStore,
|
|
createPermissionRegistry,
|
|
} from "@ekklesia/core/authorization";
|
|
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
|
import {
|
|
createModuleUiPublicationRegistry,
|
|
type DiscoveredModule,
|
|
EMPTY_MODULE_REALTIME_REGISTRY,
|
|
isReservedModuleId,
|
|
type LegacyModuleRuntimeServices,
|
|
type ModuleDatabase,
|
|
type ModuleLifecycleConfiguration,
|
|
RESERVED_MODULE_IDS,
|
|
RESERVED_SETTINGS_NAMES,
|
|
RESERVED_TOP_LEVEL_NAMES,
|
|
} from "@ekklesia/core/modules";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
|
|
// Every byte these routes serve must come from the injected startup snapshot. Any filesystem read
|
|
// while answering a request — the hallmark of a static directory — fails these tests.
|
|
const readFileMock = vi.hoisted(() => vi.fn());
|
|
vi.mock("node:fs/promises", () => ({ readFile: readFileMock }));
|
|
|
|
import { createApiApp } from "../src/app.js";
|
|
import {
|
|
createDashboardHostAssets,
|
|
type DashboardHostAssets,
|
|
} from "../src/dashboard-host-assets.js";
|
|
import {
|
|
DASHBOARD_HOST_PAGE_PATH,
|
|
DASHBOARD_HOST_APPEARANCE_SCRIPT_PATH,
|
|
DASHBOARD_HOST_SCRIPT_PATH,
|
|
DashboardHostConfigurationError,
|
|
createDashboardHostRoutes,
|
|
} from "../src/dashboard-host-routes.js";
|
|
import { MODULE_NAVIGATION_ICONS } from "@ekklesia/contracts";
|
|
import {
|
|
DASHBOARD_PLATFORM_PAGES,
|
|
HOST_NAMESPACES,
|
|
SETTINGS_SECTIONS,
|
|
} from "@ekklesia/shell/dashboard-routes";
|
|
import { DASHBOARD_MODULE_ICON_NAMES } from "@ekklesia/shell/module-icons";
|
|
import {
|
|
MODULE_DISPLAY_DESCRIPTOR_PREFIX,
|
|
MODULE_DISPLAY_PAGE_PREFIX,
|
|
} from "../src/module-display-path.js";
|
|
import {
|
|
DISPLAY_HOST_SCRIPT_PATH,
|
|
DISPLAY_SERVICE_WORKER_PATH,
|
|
} from "../src/module-display-routes.js";
|
|
import { createInMemoryAccountStore } from "./support/in-memory-account-store.js";
|
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.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";
|
|
|
|
const trustedOrigin = "https://dashboard.example.test";
|
|
const modulePermission = parsePermissionId("dashboard.page.read");
|
|
|
|
const PAGE_HTML =
|
|
'<!doctype html><html lang="en"><body><div id="ekklesia-host"></div><script src="/assets/ekklesia-dashboard/app.js" type="module"></script></body></html>\n';
|
|
const SCRIPT = "export const dashboard = 'built shell';\n";
|
|
|
|
function sha256Etag(text: string): string {
|
|
return `"${createHash("sha256").update(text).digest("base64url")}"`;
|
|
}
|
|
|
|
const PAGE_ETAG = sha256Etag(PAGE_HTML);
|
|
const SCRIPT_ETAG = sha256Etag(SCRIPT);
|
|
const APPEARANCE_SCRIPT = "(() => { /* pre-paint appearance */ })();\n";
|
|
const APPEARANCE_SCRIPT_ETAG = sha256Etag(APPEARANCE_SCRIPT);
|
|
|
|
function fakeRuntime(): EkklesiaIdentityRuntime {
|
|
return {
|
|
handleAuthRequest: vi.fn(),
|
|
resolvePrincipal: vi.fn().mockResolvedValue(ANONYMOUS_PRINCIPAL),
|
|
};
|
|
}
|
|
|
|
function fakeStore(): AuthorizationStore {
|
|
return {
|
|
createGrant: vi.fn(),
|
|
grantPlatformOwner: vi.fn(),
|
|
hasActiveGrant: vi.fn().mockResolvedValue(false),
|
|
isPlatformOwner: vi.fn().mockResolvedValue(false),
|
|
listGrantsForUser: vi.fn().mockResolvedValue({ grants: [] }),
|
|
revokeGrant: vi.fn(),
|
|
revokePlatformOwner: vi.fn(),
|
|
};
|
|
}
|
|
|
|
function fakeModuleServices(): LegacyModuleRuntimeServices {
|
|
return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase });
|
|
}
|
|
|
|
/** A discovered module as the boot snapshot records it, from a real validated manifest. Only its id
|
|
* and whether it declares a Dashboard UI matter to these routes; nothing is ever loaded. */
|
|
function discoveredModule(id: string, options: { readonly ui: boolean }): DiscoveredModule {
|
|
const manifest = parseModuleManifest({
|
|
capabilities: [],
|
|
compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" },
|
|
entrypoints: {
|
|
server: "./dist/server/index.js",
|
|
...(options.ui ? { ui: "./dist/ui/index.js" } : {}),
|
|
},
|
|
id,
|
|
manifestVersion: 8,
|
|
name: `${id} module`,
|
|
permissions: [],
|
|
version: "0.1.0",
|
|
});
|
|
return {
|
|
contributions: [],
|
|
directory: `/modules/${id}`,
|
|
entrypoints: {
|
|
server: `/modules/${id}/dist/server/index.js`,
|
|
...(options.ui ? { ui: `/modules/${id}/dist/ui/index.js` } : {}),
|
|
},
|
|
manifest,
|
|
manifestPath: `/modules/${id}/ekklesia.module.json`,
|
|
packageName: `@test/${id}`,
|
|
};
|
|
}
|
|
|
|
function buildApp(
|
|
options: {
|
|
readonly assets?: DashboardHostAssets;
|
|
readonly configuration?: ModuleLifecycleConfiguration;
|
|
readonly discovered?: readonly DiscoveredModule[];
|
|
readonly moduleRoutes?: readonly RegisteredModuleHttpRoute[];
|
|
} = {},
|
|
) {
|
|
const evaluator: AuthorizationEvaluator = {
|
|
authorize: vi.fn().mockResolvedValue({ allowed: true }),
|
|
};
|
|
const runtime = fakeRuntime();
|
|
const app = createApiApp({
|
|
authorization: {
|
|
evaluator,
|
|
registry: createPermissionRegistry([
|
|
{ baseline: "public", description: "Test permission.", id: modulePermission },
|
|
]),
|
|
store: fakeStore(),
|
|
},
|
|
moduleRoutes: options.moduleRoutes ?? Object.freeze([]),
|
|
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
|
moduleServices: fakeModuleServices(),
|
|
moduleUi: createModuleUiPublicationRegistry([]),
|
|
moduleLifecycle: {
|
|
snapshot: moduleLifecycleSnapshotFor(options.discovered ?? [], options.configuration),
|
|
store: createInMemoryModuleLifecycleStore(),
|
|
},
|
|
moduleConfiguration: emptyModuleConfiguration(),
|
|
moduleStorage: emptyModuleStorage(),
|
|
account: createInMemoryAccountStore(),
|
|
dashboard: createInMemoryDashboardStore(),
|
|
dashboardHostAssets:
|
|
options.assets ?? createDashboardHostAssets(PAGE_HTML, SCRIPT, APPEARANCE_SCRIPT),
|
|
health: readyHealth(),
|
|
runtime,
|
|
trustedOrigins: [trustedOrigin],
|
|
});
|
|
return { app, evaluator, runtime };
|
|
}
|
|
|
|
function expectPageSecurityHeaders(response: Response): void {
|
|
expect(response.headers.get("x-frame-options")).toBe("DENY");
|
|
expect(response.headers.get("content-security-policy")).toBe("frame-ancestors 'none'");
|
|
expect(response.headers.get("referrer-policy")).toBe("no-referrer");
|
|
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
|
|
}
|
|
|
|
async function expectSanitizedNotFound(response: Response, label: string): Promise<void> {
|
|
expect(response.status, label).toBe(404);
|
|
expect(response.headers.get("content-type"), label).toMatch(/^application\/json/u);
|
|
expect(await response.json(), label).toEqual({ code: "NOT_FOUND" });
|
|
}
|
|
|
|
describe("GET / (the Dashboard page)", () => {
|
|
it("serves the exact startup HTML bytes with revalidation, a strong ETag, and the page security headers", async () => {
|
|
const { app } = buildApp();
|
|
|
|
const response = await app.request(DASHBOARD_HOST_PAGE_PATH);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(await response.text()).toBe(PAGE_HTML);
|
|
expect(response.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
|
expect(response.headers.get("cache-control")).toBe("no-cache");
|
|
expect(response.headers.get("etag")).toBe(PAGE_ETAG);
|
|
expectPageSecurityHeaders(response);
|
|
// The one CSP directive is the only one: the full policy is a separate decision.
|
|
expect(response.headers.get("content-security-policy")).not.toContain(";");
|
|
});
|
|
|
|
it("serves the same page for an emailed-link landing, since the query never selects a file", async () => {
|
|
const { app } = buildApp();
|
|
|
|
const response = await app.request("/?ekklesia_auth=reset-password&token=abc123");
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(await response.text()).toBe(PAGE_HTML);
|
|
expect(response.headers.get("etag")).toBe(PAGE_ETAG);
|
|
});
|
|
|
|
it("answers a matching If-None-Match with an empty 304 that keeps the validator and every page security header", async () => {
|
|
const { app } = buildApp();
|
|
|
|
for (const ifNoneMatch of [PAGE_ETAG, `W/${PAGE_ETAG}`, `"other", ${PAGE_ETAG}`, "*"]) {
|
|
const response = await app.request(DASHBOARD_HOST_PAGE_PATH, {
|
|
headers: { "If-None-Match": ifNoneMatch },
|
|
});
|
|
|
|
expect(response.status, ifNoneMatch).toBe(304);
|
|
expect(await response.text(), ifNoneMatch).toBe("");
|
|
expect(response.headers.get("etag")).toBe(PAGE_ETAG);
|
|
expect(response.headers.get("cache-control")).toBe("no-cache");
|
|
expectPageSecurityHeaders(response);
|
|
}
|
|
});
|
|
|
|
it("serves the full page again when the browser holds a different build's validator", async () => {
|
|
const { app } = buildApp();
|
|
|
|
const response = await app.request(DASHBOARD_HOST_PAGE_PATH, {
|
|
headers: { "If-None-Match": sha256Etag("an older build") },
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(await response.text()).toBe(PAGE_HTML);
|
|
});
|
|
|
|
it("is unauthenticated and outside the API's CORS policy: it resolves no principal and reflects no origin", async () => {
|
|
const { app, evaluator, runtime } = buildApp();
|
|
|
|
const response = await app.request(DASHBOARD_HOST_PAGE_PATH, {
|
|
headers: { Cookie: "better-auth.session_token=anything", Origin: trustedOrigin },
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(runtime.resolvePrincipal).not.toHaveBeenCalled();
|
|
expect(evaluator.authorize).not.toHaveBeenCalled();
|
|
expect(response.headers.get("access-control-allow-origin")).toBeNull();
|
|
expect(response.headers.get("set-cookie")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("Dashboard deep links (ADR 0057)", () => {
|
|
const installed = () => [
|
|
discoveredModule("signage", { ui: true }),
|
|
discoveredModule("calendar", { ui: true }),
|
|
discoveredModule("retired", { ui: true }),
|
|
// A display-only module has no Dashboard page (ADR 0033).
|
|
discoveredModule("lobby-screen", { ui: false }),
|
|
];
|
|
// Installed but disabled: its address still opens the Dashboard, which says it is unavailable.
|
|
const configuration = { modules: [{ enabled: false, moduleId: "retired" }] };
|
|
|
|
const deepLinks = [
|
|
"/settings",
|
|
"/settings/",
|
|
"/settings/displays/add",
|
|
"/account",
|
|
"/account/appearance",
|
|
"/signage",
|
|
"/signage/",
|
|
// Reserved for the module's own pages, which the shell will interpret (ADR 0057).
|
|
"/signage/timelines",
|
|
"/signage/timelines/a1b2",
|
|
"/calendar",
|
|
"/retired",
|
|
];
|
|
|
|
it("serves the shell's own platform pages, from the shell's own list", () => {
|
|
expect(DASHBOARD_PLATFORM_PAGES).toEqual(["settings", "account"]);
|
|
});
|
|
|
|
it("serves the identical page, validator, and security headers at every Dashboard page, so a refresh stays put", async () => {
|
|
const { app } = buildApp({ configuration, discovered: installed() });
|
|
|
|
for (const path of deepLinks) {
|
|
const response = await app.request(path);
|
|
expect(response.status, path).toBe(200);
|
|
expect(await response.text(), path).toBe(PAGE_HTML);
|
|
expect(response.headers.get("content-type"), path).toBe("text/html; charset=utf-8");
|
|
expect(response.headers.get("cache-control"), path).toBe("no-cache");
|
|
expect(response.headers.get("etag"), path).toBe(PAGE_ETAG);
|
|
expectPageSecurityHeaders(response);
|
|
}
|
|
expect(readFileMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("revalidates a deep link with the page's one validator", async () => {
|
|
const { app } = buildApp({ discovered: installed() });
|
|
|
|
for (const path of ["/settings/displays", "/signage"]) {
|
|
const response = await app.request(path, { headers: { "If-None-Match": PAGE_ETAG } });
|
|
expect(response.status, path).toBe(304);
|
|
expect(await response.text(), path).toBe("");
|
|
expectPageSecurityHeaders(response);
|
|
}
|
|
});
|
|
|
|
it("never turns an arbitrary top-level path into a Dashboard page", async () => {
|
|
const { app } = buildApp({ configuration, discovered: installed() });
|
|
|
|
for (const path of [
|
|
"/definitely-not-a-real-module",
|
|
"/definitely-not-a-real-module/timelines",
|
|
// Installed, but with no Dashboard UI.
|
|
"/lobby-screen",
|
|
// Look-alikes of real pages.
|
|
"/signages",
|
|
"/signage-old",
|
|
"/Signage",
|
|
"/settings-old",
|
|
"/accounts",
|
|
// The former implementation prefix is not a namespace at all.
|
|
"/modules",
|
|
"/modules/signage",
|
|
]) {
|
|
await expectSanitizedNotFound(await app.request(path), path);
|
|
}
|
|
});
|
|
|
|
it("serves a module's page only while that module is installed", async () => {
|
|
const { app } = buildApp({ discovered: [] });
|
|
|
|
await expectSanitizedNotFound(await app.request("/signage"), "/signage");
|
|
expect((await app.request("/settings")).status).toBe(200);
|
|
});
|
|
|
|
it("serves the page and never the script, whatever a deep link's last segment looks like", async () => {
|
|
const { app } = buildApp({ discovered: installed() });
|
|
|
|
for (const path of [
|
|
"/signage/app.js",
|
|
"/settings/assets/ekklesia-dashboard/app.js",
|
|
"/account/index.html",
|
|
]) {
|
|
expect(await (await app.request(path)).text(), path).toBe(PAGE_HTML);
|
|
}
|
|
});
|
|
|
|
it("answers no other method at a Dashboard page", async () => {
|
|
const { app } = buildApp({ discovered: installed() });
|
|
|
|
for (const path of ["/settings/displays", "/signage"]) {
|
|
for (const method of ["POST", "PUT", "PATCH", "DELETE"]) {
|
|
await expectSanitizedNotFound(await app.request(path, { method }), `${method} ${path}`);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("refuses at composition a module page that would shadow a platform namespace", () => {
|
|
for (const modulePages of [["api"], ["settings"], ["display"], ["Not-An-Id"], ["a", "a"]]) {
|
|
expect(
|
|
() =>
|
|
createDashboardHostRoutes(
|
|
createDashboardHostAssets(PAGE_HTML, SCRIPT, APPEARANCE_SCRIPT),
|
|
{ modulePages },
|
|
),
|
|
JSON.stringify(modulePages),
|
|
).toThrow(DashboardHostConfigurationError);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("the platform's reserved top-level names (ADR 0057)", () => {
|
|
it("cover every top-level namespace the API registers, so a module page can never shadow one", () => {
|
|
const { app } = buildApp({ discovered: [discoveredModule("signage", { ui: true })] });
|
|
|
|
const topLevel = new Set(
|
|
app.routes
|
|
.map((route) => route.path.split("/")[1] ?? "")
|
|
.filter((segment) => segment !== "" && segment !== "*"),
|
|
);
|
|
topLevel.delete("signage");
|
|
|
|
// This composition publishes no display, so the display host's routes are absent here; they
|
|
// are checked through their own path constants below.
|
|
expect([...topLevel].sort()).toEqual(["account", "api", "assets", "settings"]);
|
|
for (const segment of topLevel) {
|
|
expect(isReservedModuleId(segment), segment).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("cover the display host's pages, service worker, and script, and the Dashboard's script", () => {
|
|
for (const path of [
|
|
MODULE_DISPLAY_PAGE_PREFIX,
|
|
DISPLAY_SERVICE_WORKER_PATH,
|
|
DISPLAY_HOST_SCRIPT_PATH,
|
|
DASHBOARD_HOST_SCRIPT_PATH,
|
|
MODULE_DISPLAY_DESCRIPTOR_PREFIX,
|
|
]) {
|
|
expect(isReservedModuleId(path.split("/")[1] ?? ""), path).toBe(true);
|
|
}
|
|
expect([...RESERVED_TOP_LEVEL_NAMES].sort()).toEqual([
|
|
"account",
|
|
"api",
|
|
"assets",
|
|
"display",
|
|
"settings",
|
|
]);
|
|
});
|
|
|
|
it("include every Dashboard platform page the shell interprets", () => {
|
|
for (const page of DASHBOARD_PLATFORM_PAGES) {
|
|
expect(RESERVED_TOP_LEVEL_NAMES, page).toContain(page);
|
|
}
|
|
});
|
|
|
|
it("are exactly the Dashboard's platform pages plus the host namespaces it never treats as pages (ADR 0063)", () => {
|
|
// With module routes nested beneath `/<module-id>/`, the shell must know every other top-level
|
|
// name: `/api/v1/principal` must never read as the route `v1/principal` of a module called `api`.
|
|
expect([...DASHBOARD_PLATFORM_PAGES, ...HOST_NAMESPACES].sort()).toEqual([
|
|
...RESERVED_TOP_LEVEL_NAMES,
|
|
]);
|
|
});
|
|
|
|
it("include exactly the Settings sections the shell interprets, so /settings/<module-id> never collides (ADR 0061)", () => {
|
|
expect([...SETTINGS_SECTIONS].sort()).toEqual([...RESERVED_SETTINGS_NAMES]);
|
|
for (const section of SETTINGS_SECTIONS) {
|
|
expect(isReservedModuleId(section), section).toBe(true);
|
|
}
|
|
expect(RESERVED_MODULE_IDS).toEqual(
|
|
[...RESERVED_TOP_LEVEL_NAMES, ...RESERVED_SETTINGS_NAMES].sort(),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("GET /assets/ekklesia-dashboard/app.js (the Dashboard script)", () => {
|
|
it("serves the exact startup script bytes as JavaScript with revalidation and a strong ETag", async () => {
|
|
const { app } = buildApp();
|
|
|
|
const response = await app.request(DASHBOARD_HOST_SCRIPT_PATH);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(await response.text()).toBe(SCRIPT);
|
|
expect(response.headers.get("content-type")).toBe("text/javascript; charset=utf-8");
|
|
expect(response.headers.get("cache-control")).toBe("no-cache");
|
|
expect(response.headers.get("etag")).toBe(SCRIPT_ETAG);
|
|
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
|
|
});
|
|
|
|
it("answers a matching If-None-Match with an empty 304 carrying the script's own validator", async () => {
|
|
const { app } = buildApp();
|
|
|
|
const response = await app.request(DASHBOARD_HOST_SCRIPT_PATH, {
|
|
headers: { "If-None-Match": SCRIPT_ETAG },
|
|
});
|
|
|
|
expect(response.status).toBe(304);
|
|
expect(await response.text()).toBe("");
|
|
expect(response.headers.get("etag")).toBe(SCRIPT_ETAG);
|
|
expect(response.headers.get("cache-control")).toBe("no-cache");
|
|
});
|
|
|
|
it("does not accept the page's validator as the script's", async () => {
|
|
const { app } = buildApp();
|
|
|
|
const response = await app.request(DASHBOARD_HOST_SCRIPT_PATH, {
|
|
headers: { "If-None-Match": PAGE_ETAG },
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
});
|
|
});
|
|
|
|
describe("GET /assets/ekklesia-dashboard/appearance.js (the pre-paint appearance script)", () => {
|
|
it("serves the exact pre-paint script as JavaScript with revalidation and its own strong ETag", async () => {
|
|
const { app } = buildApp();
|
|
|
|
const response = await app.request(DASHBOARD_HOST_APPEARANCE_SCRIPT_PATH);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(await response.text()).toBe(APPEARANCE_SCRIPT);
|
|
expect(response.headers.get("content-type")).toBe("text/javascript; charset=utf-8");
|
|
expect(response.headers.get("cache-control")).toBe("no-cache");
|
|
expect(response.headers.get("etag")).toBe(APPEARANCE_SCRIPT_ETAG);
|
|
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
|
|
// Code only: nothing about any viewer, and nothing that varies by request.
|
|
expect(response.headers.get("set-cookie")).toBeNull();
|
|
expect(response.headers.get("vary")).toBeNull();
|
|
});
|
|
|
|
it("answers a matching If-None-Match with a 304, and does not accept the app script's validator", async () => {
|
|
const { app } = buildApp();
|
|
|
|
const notModified = await app.request(DASHBOARD_HOST_APPEARANCE_SCRIPT_PATH, {
|
|
headers: { "If-None-Match": APPEARANCE_SCRIPT_ETAG },
|
|
});
|
|
expect(notModified.status).toBe(304);
|
|
expect(notModified.headers.get("etag")).toBe(APPEARANCE_SCRIPT_ETAG);
|
|
|
|
const other = await app.request(DASHBOARD_HOST_APPEARANCE_SCRIPT_PATH, {
|
|
headers: { "If-None-Match": SCRIPT_ETAG },
|
|
});
|
|
expect(other.status).toBe(200);
|
|
});
|
|
|
|
it("lives beside the app script, in the Dashboard's own asset namespace", () => {
|
|
expect(DASHBOARD_HOST_APPEARANCE_SCRIPT_PATH).toBe("/assets/ekklesia-dashboard/appearance.js");
|
|
});
|
|
});
|
|
|
|
describe("Dashboard host boundaries", () => {
|
|
it("serves nothing but its exact paths, answering every other spelling with the sanitized 404", async () => {
|
|
const { app } = buildApp();
|
|
|
|
for (const path of [
|
|
"/index.html",
|
|
"/app.js",
|
|
"/app.js.map",
|
|
"/assets/ekklesia-dashboard/app.js.map",
|
|
"/assets/ekklesia-dashboard/",
|
|
"/assets/ekklesia-dashboard",
|
|
"/assets/ekklesia-dashboard/app.js/",
|
|
"/assets/ekklesia-dashboard/index.html",
|
|
"/assets/",
|
|
"/assets/ekklesia-dashboard/APP.JS",
|
|
"/assets/ekklesia-dashboard/appearance.js.map",
|
|
"/assets/ekklesia-dashboard/appearance.js/",
|
|
"/assets/ekklesia-dashboard/APPEARANCE.JS",
|
|
"/appearance.js",
|
|
"/dashboard",
|
|
"/login",
|
|
]) {
|
|
await expectSanitizedNotFound(await app.request(path), path);
|
|
}
|
|
});
|
|
|
|
it("cannot be walked out of: encoded traversal, separators, and double slashes reach no file", async () => {
|
|
const { app } = buildApp();
|
|
|
|
for (const path of [
|
|
"/assets/ekklesia-dashboard/%2e%2e/%2e%2e/package.json",
|
|
"/assets/ekklesia-dashboard/..%2fapp.js.map",
|
|
"/assets/ekklesia-dashboard/%2e%2e%2fapp.js.map",
|
|
"/assets/ekklesia-dashboard/app.js%2f..%2f..%2fpackage.json",
|
|
"/assets/ekklesia-dashboard/..%5c..%5cpackage.json",
|
|
"/%2e%2e/%2e%2e/etc/passwd",
|
|
"/../../etc/passwd",
|
|
"//assets/ekklesia-dashboard/app.js",
|
|
"/assets//ekklesia-dashboard/app.js",
|
|
"/assets/ekklesia-dashboard/app.js%00.map",
|
|
]) {
|
|
const response = await app.request(path);
|
|
expect(await response.text(), path).not.toContain(SCRIPT);
|
|
expect(response.status, path).toBe(404);
|
|
}
|
|
expect(readFileMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("never touches the filesystem while answering, even across every served path", async () => {
|
|
const { app } = buildApp();
|
|
|
|
await app.request(DASHBOARD_HOST_PAGE_PATH);
|
|
await app.request(DASHBOARD_HOST_SCRIPT_PATH);
|
|
await app.request("/assets/ekklesia-dashboard/app.js.map");
|
|
|
|
expect(readFileMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("serves GET (and Hono's implicit HEAD) only; every other method is the ordinary 404", async () => {
|
|
const { app } = buildApp();
|
|
|
|
const head = await app.request(DASHBOARD_HOST_PAGE_PATH, { method: "HEAD" });
|
|
expect(head.status).toBe(200);
|
|
expect(head.headers.get("etag")).toBe(PAGE_ETAG);
|
|
expect(await head.text()).toBe("");
|
|
|
|
for (const path of [DASHBOARD_HOST_PAGE_PATH, DASHBOARD_HOST_SCRIPT_PATH]) {
|
|
for (const method of ["POST", "PUT", "PATCH", "DELETE"]) {
|
|
await expectSanitizedNotFound(await app.request(path, { method }), `${method} ${path}`);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("leaves the API, the display host's namespace, and module routes exactly where they were", async () => {
|
|
const moduleHandler = vi.fn<ModuleRouteHandler>(
|
|
async () => new Response("module", { status: 200 }),
|
|
);
|
|
const { app } = buildApp({
|
|
moduleRoutes: [
|
|
Object.freeze({
|
|
bodyMode: "buffered",
|
|
handler: moduleHandler,
|
|
maxBodyBytes: 1024,
|
|
method: "GET",
|
|
moduleId: "dashboard",
|
|
path: "/",
|
|
permission: modulePermission,
|
|
}) as RegisteredModuleHttpRoute,
|
|
],
|
|
});
|
|
|
|
expect(await (await app.request("/api/v1/principal")).json()).toEqual({ kind: "anonymous" });
|
|
expect(await (await app.request("/api/v1/health/live")).json()).toEqual({ status: "live" });
|
|
expect(await (await app.request("/api/v1/modules/dashboard")).text()).toBe("module");
|
|
await expectSanitizedNotFound(await app.request("/api/v1/unknown"), "/api/v1/unknown");
|
|
// No display is published here, so the display host's script is absent rather than answered
|
|
// by the Dashboard's.
|
|
await expectSanitizedNotFound(
|
|
await app.request(DISPLAY_HOST_SCRIPT_PATH),
|
|
DISPLAY_HOST_SCRIPT_PATH,
|
|
);
|
|
expect(DASHBOARD_HOST_SCRIPT_PATH.startsWith("/assets/ekklesia-display/")).toBe(false);
|
|
expect(await (await app.request(DASHBOARD_HOST_PAGE_PATH)).text()).toBe(PAGE_HTML);
|
|
expect(moduleHandler).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("refuses at composition a snapshot whose validator does not match its bytes, or empty bytes", () => {
|
|
const consistent = createDashboardHostAssets(PAGE_HTML, SCRIPT, APPEARANCE_SCRIPT);
|
|
|
|
for (const assets of [
|
|
{ ...consistent, html: `${PAGE_HTML}<!-- tampered -->` },
|
|
{ ...consistent, scriptEtag: PAGE_ETAG },
|
|
{ ...consistent, appearanceScriptEtag: SCRIPT_ETAG },
|
|
{ ...consistent, appearanceScript: `${APPEARANCE_SCRIPT}// tampered` },
|
|
{
|
|
appearanceScript: APPEARANCE_SCRIPT,
|
|
appearanceScriptEtag: APPEARANCE_SCRIPT_ETAG,
|
|
html: "",
|
|
htmlEtag: sha256Etag(""),
|
|
script: SCRIPT,
|
|
scriptEtag: SCRIPT_ETAG,
|
|
},
|
|
]) {
|
|
expect(() => buildApp({ assets })).toThrow(DashboardHostConfigurationError);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("module navigation icons (ADR 0063)", () => {
|
|
it("are the same set in the manifest contract and in the Dashboard that draws them", () => {
|
|
// The platform cannot accept an icon the Dashboard cannot draw, nor draw one it would refuse.
|
|
expect([...DASHBOARD_MODULE_ICON_NAMES]).toEqual([...MODULE_NAVIGATION_ICONS]);
|
|
});
|
|
});
|