Serve surfaces/shell's built Dashboard from apps/api on the same origin as /api (ADR 0044), exactly as the display host is served: a closed set of files read once at startup, never a static directory, never a path built from request input. - The shell exports @ekklesia/shell/dashboard-assets (the exact built index.html and script URLs), emits the script at assets/ekklesia-dashboard/app.js, and index.html names that absolute path so dev and production serve the same URLs. - GET / and GET /assets/ekklesia-dashboard/app.js answer from a frozen snapshot: no-cache with a strong ETag and 304 on match. Source maps are never served; every other path is the ordinary sanitized 404. - The page sets nosniff, X-Frame-Options: DENY, Content-Security-Policy: frame-ancestors 'none' (that directive only), and Referrer-Policy: no-referrer, on 200 and 304 alike. - A missing or empty shell build fails startup with a fixed DashboardHostAssetError before any module code is imported. - The If-None-Match comparison moves to conditional-request.ts, shared with the display host routes. - The emailed-link token stripping moves from browser/main.tsx into consumeAuthLanding (location and history injected) so it is tested; behavior is unchanged. Co-Authored-By: Claude Opus 5.5 <[email protected]>
569 lines
22 KiB
TypeScript
569 lines
22 KiB
TypeScript
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import {
|
|
ANONYMOUS_PRINCIPAL,
|
|
createAuthenticatedPrincipal,
|
|
type EkklesiaPrincipal,
|
|
type ModuleDatabase,
|
|
type ModuleRuntimeServices,
|
|
parseEkklesiaUserId,
|
|
} from "@ekklesia/contracts";
|
|
import {
|
|
type AuthorizationEvaluator,
|
|
type AuthorizationStore,
|
|
createAuthorizationEvaluator,
|
|
} from "@ekklesia/core/authorization";
|
|
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
|
import {
|
|
composePlatformPermissionRegistry,
|
|
createModuleUiPublicationRegistry,
|
|
type DiscoveredModule,
|
|
discoverModules,
|
|
EMPTY_MODULE_REALTIME_REGISTRY,
|
|
} from "@ekklesia/core/modules";
|
|
import type { Hono } from "hono";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { createApiApp } from "../src/app.js";
|
|
import { createDisplayHostAssets, type DisplayHostAssets } from "../src/display-host-assets.js";
|
|
import {
|
|
DISPLAY_HOST_SCRIPT_PATH,
|
|
DISPLAY_SERVICE_WORKER_PATH,
|
|
ModuleDisplayConfigurationError,
|
|
moduleDisplayDescriptorUrl,
|
|
moduleDisplayPageUrl,
|
|
} from "../src/module-display-routes.js";
|
|
import { moduleApiBaseUrl } from "../src/module-http-routes.js";
|
|
import { moduleRealtimeBaseUrl } from "../src/module-realtime-routes.js";
|
|
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
|
|
import { moduleDisplayUiArtifactUrl } from "../src/module-ui-routes.js";
|
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
|
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
|
import { readyHealth } from "./support/ready-health.js";
|
|
import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js";
|
|
import {
|
|
createInMemoryModuleLifecycleStore,
|
|
moduleLifecycleSnapshotFor,
|
|
} from "./support/in-memory-module-lifecycle.js";
|
|
import { moduleStorageOptionFor } from "./support/in-memory-module-storage.js";
|
|
|
|
const viewerId = parseEkklesiaUserId("11111111-1111-4111-8111-111111111111");
|
|
const trustedOrigin = "https://dashboard.example.test";
|
|
const displayPermission = "announcements.display.view";
|
|
|
|
const displayHostAssets: DisplayHostAssets = createDisplayHostAssets(
|
|
`<!doctype html><html><body><div id="ekklesia-display"></div><script type="module" src="${DISPLAY_HOST_SCRIPT_PATH}"></script></body></html>`,
|
|
"globalThis.__ekklesiaDisplayHostLoaded = true;\n",
|
|
"globalThis.__ekklesiaDisplayWorkerLoaded = true;\n",
|
|
);
|
|
|
|
const temporaryDirectories: string[] = [];
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(
|
|
temporaryDirectories
|
|
.splice(0)
|
|
.map((directory) => rm(directory, { force: true, recursive: true })),
|
|
);
|
|
});
|
|
|
|
interface ModuleFixture {
|
|
readonly display?: boolean;
|
|
readonly id?: string;
|
|
readonly ui?: boolean;
|
|
readonly version?: string;
|
|
}
|
|
|
|
async function createModule(modulesDirectory: string, fixture: ModuleFixture = {}): Promise<void> {
|
|
const id = fixture.id ?? "announcements";
|
|
const version = fixture.version ?? "1.4.2";
|
|
const moduleDirectory = path.join(modulesDirectory, id);
|
|
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
|
|
await writeFile(
|
|
path.join(moduleDirectory, "package.json"),
|
|
JSON.stringify({
|
|
ekklesia: { layer: "module" },
|
|
name: `@test/module-${id}`,
|
|
type: "module",
|
|
version,
|
|
}),
|
|
);
|
|
await writeFile(
|
|
path.join(moduleDirectory, "ekklesia.module.json"),
|
|
JSON.stringify({
|
|
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
|
entrypoints: {
|
|
server: "./dist/server/index.js",
|
|
...(fixture.ui ? { ui: "./dist/ui/index.js" } : {}),
|
|
...(fixture.display
|
|
? {
|
|
display: {
|
|
artifact: "./dist/display/index.js",
|
|
permission: `${id}.display.view`,
|
|
},
|
|
}
|
|
: {}),
|
|
},
|
|
id,
|
|
manifestVersion: 7,
|
|
name: `${id} module`,
|
|
permissions: fixture.display
|
|
? [
|
|
{
|
|
baseline: "restricted",
|
|
description: `View the ${id} display.`,
|
|
id: `${id}.display.view`,
|
|
},
|
|
]
|
|
: [],
|
|
version,
|
|
}),
|
|
);
|
|
await writeFile(
|
|
path.join(moduleDirectory, "dist/server/index.js"),
|
|
`export default { id: ${JSON.stringify(id)}, version: ${JSON.stringify(version)} };\n`,
|
|
);
|
|
if (fixture.ui) {
|
|
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
|
|
await writeFile(
|
|
path.join(moduleDirectory, "dist/ui/index.js"),
|
|
`export default { id: ${JSON.stringify(id)}, version: ${JSON.stringify(version)}, mount() {} };\n`,
|
|
);
|
|
}
|
|
if (fixture.display) {
|
|
await mkdir(path.join(moduleDirectory, "dist/display"), { recursive: true });
|
|
await writeFile(
|
|
path.join(moduleDirectory, "dist/display/index.js"),
|
|
`export default { id: ${JSON.stringify(id)}, version: ${JSON.stringify(version)}, mount(container, context) { container.textContent = context.moduleId + ":slideshow"; } };\n`,
|
|
);
|
|
}
|
|
}
|
|
|
|
interface InstallOptions extends ModuleFixture {
|
|
readonly assets?: DisplayHostAssets;
|
|
readonly evaluator?: AuthorizationEvaluator;
|
|
readonly granted?: readonly string[];
|
|
readonly principal?: EkklesiaPrincipal;
|
|
/** The API builds publication from the eligible/running set. `false` models a module that was
|
|
* discovered but disabled or dependency-blocked before runtime startup. */
|
|
readonly published?: boolean;
|
|
readonly withAssets?: boolean;
|
|
}
|
|
|
|
interface Installation {
|
|
readonly app: Hono;
|
|
readonly modules: readonly DiscoveredModule[];
|
|
}
|
|
|
|
function fakeServices(): ModuleRuntimeServices {
|
|
return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase });
|
|
}
|
|
|
|
function fakeAuthorizationStore(): 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(),
|
|
} as unknown as AuthorizationStore;
|
|
}
|
|
|
|
async function install(options: InstallOptions): Promise<Installation> {
|
|
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-api-display-"));
|
|
temporaryDirectories.push(modulesDirectory);
|
|
await createModule(modulesDirectory, options);
|
|
const modules = await discoverModules({
|
|
moduleSdkVersion: "0.1.0",
|
|
modulesDirectory,
|
|
platformVersion: "0.1.0",
|
|
});
|
|
const granted = new Set(options.granted ?? []);
|
|
const evaluator =
|
|
options.evaluator ??
|
|
createAuthorizationEvaluator({
|
|
grants: { hasActiveGrant: async (query) => granted.has(query.permission) },
|
|
platformOwners: { isPlatformOwner: async () => false },
|
|
});
|
|
const runtime: EkklesiaIdentityRuntime = {
|
|
handleAuthRequest: vi.fn().mockResolvedValue(new Response(null, { status: 200 })),
|
|
resolvePrincipal: vi.fn().mockResolvedValue(options.principal ?? ANONYMOUS_PRINCIPAL),
|
|
};
|
|
const publishedModules = options.published === false ? [] : modules;
|
|
const moduleUi = createModuleUiPublicationRegistry(publishedModules);
|
|
const app = createApiApp({
|
|
authorization: {
|
|
evaluator,
|
|
registry: composePlatformPermissionRegistry(modules),
|
|
store: fakeAuthorizationStore(),
|
|
},
|
|
dashboard: createInMemoryDashboardStore(),
|
|
dashboardHostAssets: testDashboardHostAssets(),
|
|
health: readyHealth(),
|
|
...(moduleUi.listDisplays().length > 0 && options.withAssets !== false
|
|
? { displayHostAssets: options.assets ?? displayHostAssets }
|
|
: {}),
|
|
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
|
moduleLifecycle: {
|
|
snapshot: moduleLifecycleSnapshotFor(modules),
|
|
store: createInMemoryModuleLifecycleStore(),
|
|
},
|
|
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
|
moduleRoutes: Object.freeze([]),
|
|
moduleServices: fakeServices(),
|
|
moduleStorage: moduleStorageOptionFor(modules),
|
|
moduleUi,
|
|
runtime,
|
|
trustedOrigins: [trustedOrigin],
|
|
});
|
|
return { app, modules };
|
|
}
|
|
|
|
function signedIn(): EkklesiaPrincipal {
|
|
return createAuthenticatedPrincipal(viewerId);
|
|
}
|
|
|
|
describe("module display routes — authorization and descriptor", () => {
|
|
it("gives an unpaired viewer the inert shell but never the descriptor", async () => {
|
|
const { app } = await install({ display: true });
|
|
|
|
const [page, descriptor] = await Promise.all([
|
|
app.request(moduleDisplayPageUrl("announcements")),
|
|
app.request(moduleDisplayDescriptorUrl("announcements")),
|
|
]);
|
|
|
|
// The page is deliberately open: a television nobody has paired has no identity to present, and
|
|
// a 403 here would leave it nowhere to show a pairing code (ADR 0036). It carries nothing —
|
|
// the same module-agnostic bytes every display gets, with no module id and no data.
|
|
expect(page.status).toBe(200);
|
|
expect(await page.text()).toBe(displayHostAssets.html);
|
|
|
|
// Authorization is entirely in the descriptor, and it has not moved.
|
|
expect(descriptor.status).toBe(403);
|
|
expect(descriptor.headers.get("cache-control")).toBe("no-store");
|
|
expect(await descriptor.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
|
});
|
|
|
|
it("requires the declared display permission, not merely an authenticated principal", async () => {
|
|
const { app } = await install({ display: true, principal: signedIn() });
|
|
|
|
// Being signed in is not being permitted, and the descriptor is where that is decided.
|
|
expect((await app.request(moduleDisplayDescriptorUrl("announcements"))).status).toBe(403);
|
|
});
|
|
|
|
it("serves the stable shell and current versioned descriptor to a permitted principal", async () => {
|
|
const { app } = await install({
|
|
display: true,
|
|
granted: [displayPermission],
|
|
principal: signedIn(),
|
|
version: "1.4.2",
|
|
});
|
|
|
|
const page = await app.request(moduleDisplayPageUrl("announcements"));
|
|
const descriptor = await app.request(moduleDisplayDescriptorUrl("announcements"));
|
|
|
|
expect(page.status).toBe(200);
|
|
expect(page.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
|
expect(page.headers.get("cache-control")).toBe("no-store");
|
|
expect(page.headers.get("x-content-type-options")).toBe("nosniff");
|
|
expect(await page.text()).toBe(displayHostAssets.html);
|
|
expect(await descriptor.json()).toEqual({
|
|
viewer: { kind: "authenticated", userId: viewerId },
|
|
module: {
|
|
contributions: [],
|
|
api: { baseUrl: moduleApiBaseUrl("announcements") },
|
|
display: {
|
|
url: moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "1.4.2" }),
|
|
},
|
|
id: "announcements",
|
|
page: { url: moduleDisplayPageUrl("announcements") },
|
|
permissions: [displayPermission],
|
|
realtime: { baseUrl: moduleRealtimeBaseUrl("announcements") },
|
|
version: "1.4.2",
|
|
},
|
|
});
|
|
expect(descriptor.headers.get("cache-control")).toBe("no-store");
|
|
expect(descriptor.headers.get("content-type")).toContain("application/json");
|
|
expect(descriptor.headers.get("vary")).toContain("Origin");
|
|
expect(descriptor.headers.get("vary")).toContain("Cookie");
|
|
});
|
|
|
|
it("sanitizes a permission-hint evaluation failure after route authorization succeeds", async () => {
|
|
const authorize = vi
|
|
.fn()
|
|
.mockResolvedValueOnce({ allowed: true })
|
|
.mockRejectedValueOnce(new Error("postgresql://[email protected] /srv/modules/announcements"));
|
|
const evaluator: AuthorizationEvaluator = { authorize };
|
|
const { app } = await install({ display: true, evaluator, principal: signedIn() });
|
|
|
|
const response = await app.request(moduleDisplayDescriptorUrl("announcements"));
|
|
const body = await response.text();
|
|
|
|
expect(authorize).toHaveBeenCalledTimes(2);
|
|
expect(response.status).toBe(503);
|
|
expect(JSON.parse(body)).toEqual({ code: "AUTHORIZATION_EVALUATION_FAILED" });
|
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
|
expect(response.headers.get("vary")).toContain("Origin");
|
|
expect(response.headers.get("vary")).toContain("Cookie");
|
|
for (const secret of ["postgresql", "127.0.0.1", "/srv/", "announcements"]) {
|
|
expect(body).not.toContain(secret);
|
|
}
|
|
});
|
|
|
|
it("keeps the outer page stable when the internal artifact version changes", () => {
|
|
expect(moduleDisplayPageUrl("announcements")).toBe("/display/announcements");
|
|
expect(moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "1.0.0" })).not.toBe(
|
|
moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "2.0.0" }),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("module display routes — role and lifecycle separation", () => {
|
|
it("keeps a display-only module out of the primary UI inventory", async () => {
|
|
const { app } = await install({
|
|
display: true,
|
|
granted: [displayPermission],
|
|
principal: signedIn(),
|
|
});
|
|
|
|
expect((await app.request(moduleDisplayPageUrl("announcements"))).status).toBe(200);
|
|
const inventory = await app.request(MODULE_UI_INVENTORY_PATH);
|
|
expect(inventory.status).toBe(200);
|
|
expect(await inventory.json()).toEqual({ modules: [] });
|
|
});
|
|
|
|
it("does not give a dashboard-only module a display route", async () => {
|
|
const { app } = await install({ principal: signedIn(), ui: true });
|
|
|
|
expect((await app.request(moduleDisplayPageUrl("announcements"))).status).toBe(404);
|
|
const inventory = await app.request(MODULE_UI_INVENTORY_PATH);
|
|
expect(inventory.status).toBe(200);
|
|
expect((await inventory.json()) as { modules: unknown[] }).toMatchObject({
|
|
modules: [{ id: "announcements" }],
|
|
});
|
|
});
|
|
|
|
it("publishes both roles independently when a module declares both", async () => {
|
|
const { app } = await install({
|
|
display: true,
|
|
granted: [displayPermission],
|
|
principal: signedIn(),
|
|
ui: true,
|
|
});
|
|
|
|
expect((await app.request(moduleDisplayPageUrl("announcements"))).status).toBe(200);
|
|
expect((await app.request(MODULE_UI_INVENTORY_PATH)).status).toBe(200);
|
|
expect(
|
|
(
|
|
(await (await app.request(MODULE_UI_INVENTORY_PATH)).json()) as {
|
|
modules: Array<{ display: { url: string } | null }>;
|
|
}
|
|
).modules,
|
|
).toEqual([
|
|
expect.objectContaining({ display: { url: moduleDisplayPageUrl("announcements") } }),
|
|
]);
|
|
});
|
|
|
|
it("has no display page, descriptor, or artifact when the module is outside the eligible set", async () => {
|
|
const { app } = await install({
|
|
display: true,
|
|
granted: [displayPermission],
|
|
principal: signedIn(),
|
|
published: false,
|
|
});
|
|
|
|
expect((await app.request(moduleDisplayPageUrl("announcements"))).status).toBe(404);
|
|
expect((await app.request(moduleDisplayDescriptorUrl("announcements"))).status).toBe(404);
|
|
expect(
|
|
(
|
|
await app.request(
|
|
moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "1.4.2" }),
|
|
)
|
|
).status,
|
|
).toBe(404);
|
|
expect((await app.request(DISPLAY_HOST_SCRIPT_PATH)).status).toBe(404);
|
|
});
|
|
|
|
it("registers only the exact stable page and descriptor paths", async () => {
|
|
const { app } = await install({
|
|
display: true,
|
|
granted: [displayPermission],
|
|
principal: signedIn(),
|
|
});
|
|
|
|
for (const route of [
|
|
"/display",
|
|
"/display/announcements/",
|
|
"/display/announcements/settings",
|
|
"/api/v1/module-displays",
|
|
"/api/v1/module-displays/announcements/",
|
|
"/api/v1/module-displays/announcements/settings",
|
|
]) {
|
|
expect((await app.request(route)).status, route).toBe(404);
|
|
}
|
|
});
|
|
|
|
it("fails composition when a display exists but its host assets were not supplied", async () => {
|
|
await expect(install({ display: true, withAssets: false })).rejects.toBeInstanceOf(
|
|
ModuleDisplayConfigurationError,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("module display host asset publication", () => {
|
|
it("serves only the fixed script with JavaScript and revalidation headers", async () => {
|
|
const { app } = await install({ display: true });
|
|
|
|
const response = await app.request(DISPLAY_HOST_SCRIPT_PATH);
|
|
expect(response.status).toBe(200);
|
|
expect(await response.text()).toBe(displayHostAssets.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("x-content-type-options")).toBe("nosniff");
|
|
|
|
const notModified = await app.request(DISPLAY_HOST_SCRIPT_PATH, {
|
|
headers: { "if-none-match": displayHostAssets.scriptEtag },
|
|
});
|
|
expect(notModified.status).toBe(304);
|
|
expect(await notModified.text()).toBe("");
|
|
expect(notModified.headers.get("etag")).toBe(displayHostAssets.scriptEtag);
|
|
expect(notModified.headers.get("cache-control")).toBe("no-cache");
|
|
|
|
for (const header of [
|
|
`W/${displayHostAssets.scriptEtag}`,
|
|
"*",
|
|
`"stale", ${displayHostAssets.scriptEtag}`,
|
|
]) {
|
|
expect(
|
|
(
|
|
await app.request(DISPLAY_HOST_SCRIPT_PATH, {
|
|
headers: { "if-none-match": header },
|
|
})
|
|
).status,
|
|
header,
|
|
).toBe(304);
|
|
}
|
|
expect(
|
|
(
|
|
await app.request(DISPLAY_HOST_SCRIPT_PATH, {
|
|
headers: { "if-none-match": '"stale"' },
|
|
})
|
|
).status,
|
|
).toBe(200);
|
|
});
|
|
|
|
it("exposes no source map, neighboring file, arbitrary role, or request-selected path", async () => {
|
|
const { app } = await install({ display: true });
|
|
|
|
for (const candidate of [
|
|
`${DISPLAY_HOST_SCRIPT_PATH}.map`,
|
|
"/assets/ekklesia-display/index.html",
|
|
"/assets/ekklesia-display/secret.txt",
|
|
"/assets/ekklesia-display/../secret.txt",
|
|
"/api/v1/module-ui/announcements/1.4.2/management",
|
|
]) {
|
|
expect((await app.request(candidate)).status, candidate).toBe(404);
|
|
}
|
|
});
|
|
|
|
it("uses identical non-interpolated HTML for every eligible module", async () => {
|
|
const first = await install({
|
|
display: true,
|
|
granted: [displayPermission],
|
|
principal: signedIn(),
|
|
});
|
|
const second = await install({
|
|
display: true,
|
|
granted: ["signage.display.view"],
|
|
id: "signage",
|
|
principal: signedIn(),
|
|
});
|
|
|
|
const firstHtml = await (await first.app.request(moduleDisplayPageUrl("announcements"))).text();
|
|
const secondHtml = await (await second.app.request(moduleDisplayPageUrl("signage"))).text();
|
|
expect(firstHtml).toBe(secondHtml);
|
|
expect(firstHtml).not.toContain("announcements");
|
|
expect(secondHtml).not.toContain("signage");
|
|
});
|
|
|
|
it("snapshots injected assets and rejects a validator that does not describe its script", async () => {
|
|
const source = createDisplayHostAssets(
|
|
"<html>display host</html>",
|
|
"export const host = 1;\n",
|
|
"export const worker = 1;\n",
|
|
);
|
|
const mutable = { ...source };
|
|
const { app } = await install({ assets: mutable, display: true });
|
|
mutable.html = "<html>mutated secret</html>";
|
|
mutable.script = "throw new Error('mutated secret');";
|
|
|
|
expect(await (await app.request(DISPLAY_HOST_SCRIPT_PATH)).text()).toBe(source.script);
|
|
|
|
await expect(
|
|
install({
|
|
assets: { ...source, scriptEtag: '"stale-validator"' },
|
|
display: true,
|
|
}),
|
|
).rejects.toBeInstanceOf(ModuleDisplayConfigurationError);
|
|
});
|
|
|
|
it("does not publish the generic host script when no eligible display uses it", async () => {
|
|
const { app } = await install({ ui: true, withAssets: false });
|
|
|
|
expect((await app.request(DISPLAY_HOST_SCRIPT_PATH)).status).toBe(404);
|
|
});
|
|
|
|
it("serves the offline service worker from the path whose directory is its scope", async () => {
|
|
const { app } = await install({ display: true });
|
|
|
|
const response = await app.request(DISPLAY_SERVICE_WORKER_PATH);
|
|
|
|
// `/display/sw.js`, so the worker's default scope is `/display/` — display pages and nothing
|
|
// else. The Dashboard is never controlled by it (ADR 0043).
|
|
expect(DISPLAY_SERVICE_WORKER_PATH).toBe("/display/sw.js");
|
|
expect(response.status).toBe(200);
|
|
expect(await response.text()).toBe(displayHostAssets.serviceWorker);
|
|
expect(response.headers.get("content-type")).toBe("text/javascript; charset=utf-8");
|
|
// `no-cache` with a strong validator, exactly like the host script: a browser re-checks the
|
|
// worker on its own schedule, which is how a screen avoids being trapped on an obsolete one.
|
|
expect(response.headers.get("cache-control")).toBe("no-cache");
|
|
expect(response.headers.get("etag")).toBe(displayHostAssets.serviceWorkerEtag);
|
|
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
|
|
});
|
|
|
|
it("revalidates the service worker with its own validator, never the host script's", async () => {
|
|
const { app } = await install({ display: true });
|
|
|
|
const notModified = await app.request(DISPLAY_SERVICE_WORKER_PATH, {
|
|
headers: { "if-none-match": displayHostAssets.serviceWorkerEtag },
|
|
});
|
|
expect(notModified.status).toBe(304);
|
|
|
|
const wrongValidator = await app.request(DISPLAY_SERVICE_WORKER_PATH, {
|
|
headers: { "if-none-match": displayHostAssets.scriptEtag },
|
|
});
|
|
expect(wrongValidator.status).toBe(200);
|
|
});
|
|
|
|
it("does not publish the service worker when no eligible display uses it", async () => {
|
|
const { app } = await install({ ui: true, withAssets: false });
|
|
|
|
expect((await app.request(DISPLAY_SERVICE_WORKER_PATH)).status).toBe(404);
|
|
});
|
|
|
|
it("cannot be reached through a neighbouring or request-selected path", async () => {
|
|
const { app } = await install({ display: true });
|
|
|
|
for (const candidate of [
|
|
`${DISPLAY_SERVICE_WORKER_PATH}.map`,
|
|
"/display/sw.js/",
|
|
"/sw.js",
|
|
"/assets/ekklesia-display/sw.js",
|
|
]) {
|
|
expect((await app.request(candidate)).status, candidate).toBe(404);
|
|
}
|
|
});
|
|
});
|