CI / verify (pull_request) Successful in 1m10s
CI / infrastructure-smoke (pull_request) Successful in 43s
CI / package-release (pull_request) Successful in 49s
CI / migration-smoke (pull_request) Successful in 2m12s
CI / image-build (pull_request) Successful in 1m0s
CI / deploy-smoke (same-origin) (pull_request) Successful in 56s
CI / package-publication (pull_request) Successful in 56s
CI / deploy-smoke (split) (pull_request) Successful in 2m37s
Module pages move from `/modules/<id>` to `/<module-id>` (`/signage`), beside `/settings` and `/account`, so the implementation concept "module" never appears in a user-facing address. ADR 0057 is rewritten to match. - Core reserves the top-level names the platform actually serves today (`account`, `api`, `assets`, `display`, `settings`), and discovery refuses a module that declares one with `MODULE_ID_RESERVED`. That covers startup, `modules:validate`, and package verification. Tests in `apps/api` hold the set to every top-level route the API registers and every page the shell interprets. - The API serves the Dashboard page at `/<module-id>` and below only for modules discovered with a Dashboard UI, enabled or not: a disabled module's bookmark still opens the Dashboard, which says it is unavailable. An arbitrary root path such as `/definitely-not-a-real-module` stays the sanitized 404. - Both reference proxies forward every top-level path shaped like a module id and let the API decide; the split Dashboard hostname still refuses `/api`, `/display`, and `/assets`. Installing a module needs no proxy change. - `/<module-id>/<page>` is already served and forwarded, so a later module sub-page contract needs no routing or deployment change; the shell shows "Page not found" there until it exists. - The deploy smoke checks a module's address before install, while installed, while disabled, after removal, and after reinstall, plus unknown names and the old `/modules/…` form through the real proxy. Co-Authored-By: Claude Opus 5.5 <[email protected]>
562 lines
21 KiB
TypeScript
562 lines
21 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,
|
|
} 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_SCRIPT_PATH,
|
|
DashboardHostConfigurationError,
|
|
createDashboardHostRoutes,
|
|
} from "../src/dashboard-host-routes.js";
|
|
import { DASHBOARD_PLATFORM_PAGES } from "@ekklesia/shell/dashboard-routes";
|
|
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 { 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);
|
|
|
|
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(),
|
|
dashboard: createInMemoryDashboardStore(),
|
|
dashboardHostAssets: options.assets ?? createDashboardHostAssets(PAGE_HTML, 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), { 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_MODULE_IDS].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_MODULE_IDS, page).toContain(page);
|
|
}
|
|
});
|
|
});
|
|
|
|
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("Dashboard host boundaries", () => {
|
|
it("serves nothing but its two 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",
|
|
"/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);
|
|
|
|
for (const assets of [
|
|
{ ...consistent, html: `${PAGE_HTML}<!-- tampered -->` },
|
|
{ ...consistent, scriptEtag: PAGE_ETAG },
|
|
{ html: "", htmlEtag: sha256Etag(""), script: SCRIPT, scriptEtag: SCRIPT_ETAG },
|
|
]) {
|
|
expect(() => buildApp({ assets })).toThrow(DashboardHostConfigurationError);
|
|
}
|
|
});
|
|
});
|