admin-web/tests/feedback.spec.ts
Rama main · réplica de lectura del repositorio original.
import { expect, type Page, test } from "@playwright/test";
/// Injects a minimal window.nostr stub so a test can drive nip98 mode without
/// a real NIP-07 extension. It echoes the tags it signed (including the
/// `payload` tag added for body-bearing requests) so a test can assert the
/// credential committed to the exact body bytes.
async function seedNip98(page: Page) {
await page.addInitScript(() => {
(window as Window & { nostr?: unknown }).nostr = {
signEvent: async (event: {
kind: number;
created_at: number;
tags: string[][];
content: string;
}) => {
const serialized = JSON.stringify([
event.kind,
event.created_at,
event.tags,
event.content,
]);
let h = 0;
for (let i = 0; i < serialized.length; i++) {
h = (Math.imul(31, h) + serialized.charCodeAt(i)) | 0;
}
const id = (h >>> 0).toString(16).padStart(8, "0").repeat(8);
return {
...event,
id,
pubkey: "b".repeat(64),
sig: "c".repeat(128),
};
},
};
});
}
function decodeNostrHeader(header: string): { tags: string[][] } {
return JSON.parse(atob(header.replace(/^Nostr /, "")));
}
async function sha256Hex(text: string): Promise<string> {
const digest = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(text),
);
return Array.from(new Uint8Array(digest), (b) =>
b.toString(16).padStart(2, "0"),
).join("");
}
const FEEDBACK_ONE = "11111111-1111-1111-1111-111111111111";
function summary(overrides: Record<string, unknown>) {
return {
id: FEEDBACK_ONE,
communityId: "6d474feb-c50a-44e4-a0b5-f30532df49bc",
communityHost: "design.buzz.xyz",
submitterPubkey: "21".repeat(32),
category: "bug",
bodySummary: "Composer froze on paste.",
status: "new",
receivedAt: "2026-07-17T17:30:00Z",
...overrides,
};
}
test("nip98 mode: changing status PATCHes the relay with a payload-bound credential", async ({
page,
}) => {
await seedNip98(page);
const patches: { method: string; body: string; authorization?: string }[] =
[];
await page.route("**/api/admin/v1/feedback", async (route) => {
// The list load is authenticated (probe already resolved nip98 mode).
await route.fulfill({
contentType: "application/json",
body: JSON.stringify([summary({ status: "new" })]),
});
});
await page.route("**/api/admin/v1/reports**", (route) => {
// Probe: reject unauthenticated so the SPA resolves nip98 mode.
if (!route.request().headers().authorization) {
route.fulfill({
status: 401,
headers: { "www-authenticate": "Nostr" },
contentType: "application/json",
body: JSON.stringify({ error: { code: "unauthorized", message: "x" } }),
});
return;
}
route.fulfill({ contentType: "application/json", body: "[]" });
});
await page.route(
`**/api/admin/v1/feedback/${FEEDBACK_ONE}`,
async (route) => {
const request = route.request();
patches.push({
method: request.method(),
body: request.postData() ?? "",
authorization: request.headers().authorization,
});
await route.fulfill({
contentType: "application/json",
body: JSON.stringify({ status: "reviewed" }),
});
},
);
await page.goto("/feedback");
await expect(page.getByRole("heading", { name: "Feedback" })).toBeVisible();
await page.getByRole("combobox").last().selectOption("reviewed");
await expect.poll(() => patches.length).toBeGreaterThan(0);
const patch = patches[0];
expect(patch.method).toBe("PATCH");
expect(JSON.parse(patch.body)).toEqual({ status: "reviewed" });
// The credential is a NIP-98 event whose payload tag is the SHA-256 of the
// exact body bytes the request sent.
expect(patch.authorization).toMatch(/^Nostr /);
const { tags } = decodeNostrHeader(patch.authorization as string);
const payload = tags.find((t) => t[0] === "payload")?.[1];
expect(payload).toBe(await sha256Hex(patch.body));
// The row adopts the status from the PATCH response.
await expect(page.getByRole("combobox").last()).toHaveValue("reviewed");
});
test("nip98 mode: a failed PATCH surfaces an error and does not lie about the status", async ({
page,
}) => {
await seedNip98(page);
await page.route("**/api/admin/v1/feedback", (route) =>
route.fulfill({
contentType: "application/json",
body: JSON.stringify([summary({ status: "new" })]),
}),
);
await page.route("**/api/admin/v1/reports**", (route) => {
if (!route.request().headers().authorization) {
route.fulfill({
status: 401,
headers: { "www-authenticate": "Nostr" },
contentType: "application/json",
body: JSON.stringify({ error: { code: "unauthorized", message: "x" } }),
});
return;
}
route.fulfill({ contentType: "application/json", body: "[]" });
});
// Gate the failing PATCH so the test can observe the in-flight (pending)
// state before the 500 lands.
let releasePatch: () => void = () => {};
const patchInFlight = new Promise<void>((resolve) => {
releasePatch = resolve;
});
await page.route(
`**/api/admin/v1/feedback/${FEEDBACK_ONE}`,
async (route) => {
await patchInFlight;
await route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({ error: { code: "internal", message: "boom" } }),
});
},
);
await page.goto("/feedback");
const control = page.getByRole("combobox").last();
await control.selectOption("archived");
// While the PATCH is in flight the control is disabled, shows no error, and
// still reads the original server status — an optimistic set (even one that
// rolls back on failure) would surface the unpersisted value here.
await expect(control).toBeDisabled();
await expect(control).toHaveValue("new");
await expect(page.getByRole("alert")).toHaveCount(0);
releasePatch();
// The failed write surfaces an error and leaves the control at the original
// server status — it never optimistically adopts the requested value.
await expect(page.getByRole("alert")).toHaveText("Update failed");
await expect(control).toHaveValue("new");
await expect(control).toBeEnabled();
});
test("disabled mode: the status control is a read-only badge with no write affordance", async ({
page,
}) => {
// Probe returns 200 → disabled mode. No mutation must be offered.
await page.route("**/api/admin/v1/reports**", (route) =>
route.fulfill({ contentType: "application/json", body: "[]" }),
);
await page.route("**/api/admin/v1/feedback", (route) =>
route.fulfill({
contentType: "application/json",
body: JSON.stringify([summary({ status: "reviewed" })]),
}),
);
let patched = false;
await page.route(`**/api/admin/v1/feedback/${FEEDBACK_ONE}`, (route) => {
patched = true;
route.fulfill({ contentType: "application/json", body: "{}" });
});
await page.goto("/feedback");
await expect(page.getByRole("heading", { name: "Feedback" })).toBeVisible();
// Authoritative status is shown as a badge; the only combobox on the page is
// the status filter, not a per-row write control.
await expect(page.getByText("Reviewed").last()).toBeVisible();
await expect(page.getByRole("combobox")).toHaveCount(3);
await page.waitForTimeout(200);
expect(patched).toBe(false);
});
test("a purged feedback row (null provenance) renders without crashing search or attachments", async ({
page,
}) => {
// Disabled mode keeps the test focused on nullable-host handling.
await page.route("**/api/admin/v1/reports**", (route) =>
route.fulfill({ contentType: "application/json", body: "[]" }),
);
await page.route("**/api/admin/v1/feedback", (route) =>
route.fulfill({
contentType: "application/json",
body: JSON.stringify([
summary({ communityId: null, communityHost: null, status: "new" }),
]),
}),
);
await page.goto("/feedback");
await expect(page.getByText("Community unavailable").first()).toBeVisible();
// Search a term that only matches the submitter pubkey (index 3) — the
// filter must evaluate the null host (index 1) on the way there without
// throwing. "2121" is present in the pubkey but not the body summary.
await page.getByPlaceholder("Search feedback").fill("2121");
await expect(page.getByText("Composer froze on paste.")).toBeVisible();
await expect(page.getByText("1 of 1 submissions")).toBeVisible();
});
test("a purged feedback detail derives no attachments from an absent host", async ({
page,
}) => {
const imageHash = "a".repeat(64);
await page.route("**/api/admin/v1/reports**", (route) =>
route.fulfill({ contentType: "application/json", body: "[]" }),
);
await page.route("**/api/admin/v1/feedback", (route) =>
route.fulfill({ contentType: "application/json", body: "[]" }),
);
let attachmentRequested = false;
await page.route(
`**/api/admin/v1/feedback/${FEEDBACK_ONE}/attachments/**`,
(route) => {
attachmentRequested = true;
route.fulfill({ contentType: "application/octet-stream", body: "x" });
},
);
await page.route(`**/api/admin/v1/feedback/${FEEDBACK_ONE}`, (route) =>
route.fulfill({
contentType: "application/json",
body: JSON.stringify({
id: FEEDBACK_ONE,
communityId: null,
communityHost: null,
eventId: "31".repeat(32),
submitterPubkey: "21".repeat(32),
category: "bug",
body: "Broken.\n\n",
tags: [
[
"imeta",
"url https://design.buzz.xyz/media/x.png",
"m image/png",
`x ${imageHash}`,
"filename shot.png",
],
],
status: "new",
eventCreatedAt: "2026-07-17T17:25:00Z",
receivedAt: "2026-07-17T17:30:00Z",
}),
}),
);
await page.goto(`/feedback/${FEEDBACK_ONE}`);
await expect(page.getByText("Community unavailable").first()).toBeVisible();
await expect(page.getByText("Attachments")).toHaveCount(0);
await page.waitForTimeout(200);
// The authoritative host is absent, so no attachment fetch is derived.
expect(attachmentRequested).toBe(false);
});
test("a hostile attachment served as octet-stream is download-only, never a navigable typed document", async ({
page,
}) => {
// The reporter's imeta claims image/png, but the relay sniffs the stored
// bytes and forces application/octet-stream for anything that is not a
// verified passive raster image (e.g. an HTML/SVG payload). The client must
// trust the served type, so this attachment renders as a download link — no
// <img>, no target="_blank" navigation to a typed document on the admin
// origin.
const hash = "a".repeat(64);
await page.route("**/api/admin/v1/reports**", (route) =>
route.fulfill({ contentType: "application/json", body: "[]" }),
);
await page.route("**/api/admin/v1/feedback", (route) =>
route.fulfill({ contentType: "application/json", body: "[]" }),
);
await page.route(
`**/api/admin/v1/feedback/${FEEDBACK_ONE}/attachments/**`,
(route) =>
route.fulfill({
contentType: "application/octet-stream",
body: "<script>alert(1)</script>",
}),
);
await page.route(`**/api/admin/v1/feedback/${FEEDBACK_ONE}`, (route) =>
route.fulfill({
contentType: "application/json",
body: JSON.stringify({
id: FEEDBACK_ONE,
communityId: "6d474feb-c50a-44e4-a0b5-f30532df49bc",
communityHost: "design.buzz.xyz",
eventId: "31".repeat(32),
submitterPubkey: "21".repeat(32),
category: "bug",
body: "Broken.\n\n",
tags: [
[
"imeta",
"url https://design.buzz.xyz/media/x.png",
"m image/png",
`x ${hash}`,
"filename shot.png",
],
],
status: "new",
eventCreatedAt: "2026-07-17T17:25:00Z",
receivedAt: "2026-07-17T17:30:00Z",
}),
}),
);
await page.goto(`/feedback/${FEEDBACK_ONE}`);
const link = page.getByRole("link", { name: /shot\.png/ });
await expect(link).toBeVisible();
await expect(link).toHaveAttribute("download", "shot.png");
await expect(link).not.toHaveAttribute("target", "_blank");
// The hostile payload is never rendered as an inline image.
await expect(page.locator("figure.image-attachment img")).toHaveCount(0);
});