Files
buzz/admin-web/tests/csp.spec.ts
DuncanandWill Pfleger 5505e608b9 feat(relay): OPERATOR/MODERATOR role model for relay admin API with NIP-98 auth
Gate the relay admin moderation API (/api/admin/v1) behind explicit
authentication configuration selected by BUZZ_ADMIN_AUTH: token (default),
disabled, or nip98. In nip98 mode every request carries a signed kind-27235
NIP-98 event; the authenticated pubkey resolves to an OPERATOR or MODERATOR
principal from RELAY_OPERATOR_PUBKEYS, the RELAY_OWNER_PUBKEY fallback, or the
relay_operators table. Replaces the BUZZ_ADMIN_INSECURE_NO_AUTH bypass with a
role model that is revocable without rotating a shared secret and fails closed
at every boundary.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
2026-08-12 23:20:29 -04:00

110 lines
3.6 KiB
TypeScript

import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { expect, test } from "@playwright/test";
const ROUTER_RS = fileURLToPath(
new URL("../../crates/buzz-relay/src/router.rs", import.meta.url),
);
/// The exact policy the relay serves on admin SPA documents. Read from the
/// relay source rather than copied, so this test can never pass against a
/// policy operators do not actually get. The preview server used here serves
/// no CSP of its own, so it is injected below.
function adminCsp() {
const source = readFileSync(ROUTER_RS, "utf8");
const match = source.match(/const ADMIN_CSP: &str = "([^"]+)";/);
if (!match) throw new Error(`ADMIN_CSP not found in ${ROUTER_RS}`);
return match[1];
}
const TOKEN =
"5f0e1d2c3b4a59687786958493a2b1c0decadebeefcafe0123456789abcdef01";
test("the relay admin csp does not break the built dashboard", async ({
page,
}) => {
const csp = adminCsp();
expect(csp).toContain("frame-ancestors 'none'");
expect(csp).not.toContain("unsafe-inline");
await page.addInitScript((token) => {
sessionStorage.setItem("buzz-admin-token", token);
}, TOKEN);
await page.route("**/api/admin/v1/**", (route) =>
route.fulfill({ contentType: "application/json", body: "[]" }),
);
// Only the document request: the API call to /reports carries a query string.
await page.route(
(url) => url.pathname === "/reports" && url.search === "",
async (route) => {
const response = await route.fetch();
await route.fulfill({
response,
headers: { ...response.headers(), "content-security-policy": csp },
});
},
);
const violations: string[] = [];
page.on("console", (message) => {
if (message.text().includes("Content Security Policy"))
violations.push(message.text());
});
await page.goto("/reports");
// Rendering at all proves the bundle's script and stylesheet loaded, and the
// empty state proves the fetch to /api/admin/v1 survived `connect-src 'self'`.
await expect(
page.getByRole("heading", { name: "Open reports" }),
).toBeVisible();
await expect(page.getByText("No records.")).toBeVisible();
expect(violations).toEqual([]);
});
test("the linked favicon loads under the admin csp", async ({ page }) => {
const csp = adminCsp();
await page.route(
(url) => url.pathname === "/" && url.search === "",
async (route) => {
const response = await route.fetch();
await route.fulfill({
response,
headers: { ...response.headers(), "content-security-policy": csp },
});
},
);
const violations: string[] = [];
page.on("console", (message) => {
if (message.text().includes("Content Security Policy"))
violations.push(message.text());
});
await page.goto("/");
const href = await page.locator("link[rel=icon]").getAttribute("href");
expect(href).toBe("/favicon.svg");
// Headless Chromium never issues the `<link rel=icon>` request itself, so
// load the same file the same way the policy sees it: an image fetch under
// `img-src 'self'`. A blocked fetch rejects `decode()`; a successful one
// proves the icon renders, and that the SVG's own inline <style> is not
// subject to the embedding document's `style-src`.
const decoded = await page.evaluate(async (src) => {
const image = new Image();
image.src = src;
try {
await image.decode();
return { ok: true, width: image.naturalWidth };
} catch (error) {
return { ok: false, error: String(error) };
}
}, href as string);
expect(decoded.ok).toBe(true);
expect(decoded.width).toBeGreaterThan(0);
expect(violations).toEqual([]);
});