1import { createExecutionContext, env, SELF, waitOnExecutionContext } from "cloudflare:test";2import { beforeEach, describe, expect, it } from "vitest";3import { getSiteByAddress } from "../src/db.ts";4import { handleFeedbackRequest, serveSiteFeedback } from "../src/feedback.ts";56const headers = { authorization: "Bearer runtime-test-token", "content-type": "application/json" };7const emptyHash = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";8const manifest = { v: 1, files: { "index.html": { h: emptyHash, s: 0, ct: "text/html" } } };9const PASSWORD = "review-password";1011function api(path: string, body?: unknown, method = "POST") {12 return SELF.fetch(`https://share.demo.dev/api/${path}`, {13 method,14 headers,15 ...(body === undefined ? {} : { body: JSON.stringify(body) }),16 });17}1819async function publish(policy: Record<string, unknown> = {}, target: unknown = { vault: "fb" }) {20 return await api("push/commit", { manifest, target, ...policy });21}2223async function protectedSite(): Promise<string> {24 const response = await publish({ password: PASSWORD, feedback: true });25 expect(response.status, await response.clone().text()).toBe(200);26 const site = await response.json<{ address: string; feedback: boolean; protected: boolean }>();27 expect(site).toMatchObject({ feedback: true, protected: true });28 return site.address;29}3031async function unlock(address: string): Promise<string> {32 const body = new URLSearchParams({ password: PASSWORD, return_to: "/" }).toString();33 const response = await SELF.fetch(`https://${address}.demo.dev/__unlock`, {34 method: "POST",35 redirect: "manual",36 headers: {37 "content-type": "application/x-www-form-urlencoded",38 "content-length": String(body.length),39 },40 body,41 });42 const cookie = response.headers.get("set-cookie")?.split(";", 1)[0];43 expect(cookie, `unlock status ${response.status}`).toMatch(/^__Host-nzip-unlock=/);44 return cookie!;45}4647function visitor(address: string, cookie: string, init: RequestInit = {}) {48 return SELF.fetch(`https://${address}.demo.dev/__feedback`, {49 ...init,50 headers: { cookie, ...(init.headers ?? {}) },51 });52}5354function save(address: string, cookie: string, body: unknown, extra: Record<string, string> = {}) {55 return visitor(address, cookie, {56 method: "POST",57 headers: {58 "content-type": "application/json",59 origin: `https://${address}.demo.dev`,60 ...extra,61 },62 body: JSON.stringify(body),63 });64}6566describe("Site feedback", () => {67 beforeEach(async () => {68 await env.DB.batch([69 env.DB.prepare("DELETE FROM sites WHERE vault_slot = 7"),70 env.DB.prepare("DELETE FROM vaults WHERE slot = 7"),71 ]);72 const vault = await api("vaults", { name: "fb", slot: 7 });73 expect(vault.status, await vault.clone().text()).toBe(201);74 await env.CONTENT.put(`blob/${emptyHash}`, new Uint8Array());75 });7677 it("only enables feedback behind a password", async () => {78 expect((await publish({ feedback: true })).status).toBe(400);79 expect((await publish({ feedback: "yes", password: PASSWORD })).status).toBe(400);8081 const open = await (await publish()).json<{ address: string; feedback: boolean }>();82 expect(open.feedback).toBe(false);83 expect((await api(`sites/${open.address}`, { feedback: true }, "PATCH")).status).toBe(400);8485 const address = await protectedSite();86 expect((await api(`sites/${address}`, { password: null }, "PATCH")).status).toBe(400);87 expect((await publish({ password: null }, { address: parseInt(address, 16) })).status)88 .toBe(400);89 // A revision without feedback flags keeps the setting.90 const revised = await publish({}, { address: parseInt(address, 16) });91 expect(await revised.json()).toMatchObject({ feedback: true, protected: true });92 const disabled = await api(`sites/${address}`, { feedback: false }, "PATCH");93 expect(await disabled.json()).toMatchObject({ feedback: false });94 expect((await api(`sites/${address}`, { password: null }, "PATCH")).status).toBe(200);95 });9697 it("keeps the visitor endpoint behind the unlock gate and off unless enabled", async () => {98 const address = await protectedSite();99 expect((await SELF.fetch(`https://${address}.demo.dev/__feedback`)).status).toBe(401);100 const cookie = await unlock(address);101 expect((await visitor(address, cookie)).status).toBe(200);102103 const script = await SELF.fetch(`https://${address}.demo.dev/__feedback.js`, {104 headers: { cookie },105 });106 expect(script.headers.get("content-type")).toContain("text/javascript");107 expect(script.headers.get("cache-control")).toBe("private, no-store");108 expect(await script.text()).toContain("data-nzip-feedback");109110 await api(`sites/${address}`, { feedback: false }, "PATCH");111 expect((await visitor(address, cookie)).status).toBe(404);112 expect((await save(address, cookie, { items: { a: "x" } })).status).toBe(404);113 });114115 it("saves, restores, deletes, and submits notes from same-origin JSON only", async () => {116 const address = await protectedSite();117 const cookie = await unlock(address);118119 expect(120 (await save(address, cookie, { items: { a: "x" } }, {121 origin: "https://evil.example",122 })).status,123 ).toBe(403);124 expect(125 (await save(address, cookie, { items: { a: "x" } }, {126 "sec-fetch-site": "cross-site",127 })).status,128 ).toBe(403);129 const form = await visitor(address, cookie, {130 method: "POST",131 headers: { "content-type": "text/plain", origin: `https://${address}.demo.dev` },132 body: JSON.stringify({ items: { a: "x" } }),133 });134 expect(form.status).toBe(403);135 expect((await save(address, cookie, { items: { "../bad": "x" } })).status).toBe(400);136 expect((await save(address, cookie, { items: { a: 1 } })).status).toBe(400);137 expect((await save(address, cookie, { items: { a: "x".repeat(4001) } })).status).toBe(400);138 expect((await save(address, cookie, { extra: true })).status).toBe(400);139140 const saved = await save(address, cookie, {141 items: { intro: "Tighten the opening", "option-2": "Prefer this one" },142 });143 expect(saved.status, await saved.clone().text()).toBe(200);144 const state = await saved.json<{ items: Record<string, { text: string; seq: number }> }>();145 expect(state.items.intro).toMatchObject({ text: "Tighten the opening", seq: 1 });146147 await save(address, cookie, { items: { intro: " " } });148 const restored = await (await visitor(address, cookie)).json<149 { items: Record<string, unknown>; submittedAt: number | null }150 >();151 expect(Object.keys(restored.items)).toEqual(["option-2"]);152 expect(restored.submittedAt).toBeNull();153154 const submitted = await save(address, cookie, { items: { outro: "Ship it" }, submit: true });155 expect(await submitted.json()).toMatchObject({ submittedAt: expect.any(Number) });156157 const owner = await (await api(`sites/${address}/feedback`, undefined, "GET")).json<158 { enabled: boolean; submittedAt: number; items: { item: string; text: string }[] }159 >();160 expect(owner.enabled).toBe(true);161 expect(owner.submittedAt).toEqual(expect.any(Number));162 expect(owner.items.map((item) => item.item).sort()).toEqual(["option-2", "outro"]);163 });164165 it("caps stored items per site", async () => {166 const address = await protectedSite();167 const cookie = await unlock(address);168 for (let batch = 0; batch < 2; batch++) {169 const items = Object.fromEntries(170 Array.from({ length: 100 }, (_, i) => [`item-${batch}-${i}`, "note"]),171 );172 expect((await save(address, cookie, { items })).status).toBe(200);173 }174 expect((await save(address, cookie, { items: { overflow: "note" } })).status).toBe(400);175 // Clearing one item makes room again.176 expect((await save(address, cookie, { items: { "item-0-0": "", overflow: "note" } })).status)177 .toBe(200);178 });179180 it("opens a review round and queues a feedback-request notification", async () => {181 const open = await (await publish({ password: PASSWORD })).json<{ address: string }>();182 expect((await api(`sites/${open.address}/feedback/request`, {})).status).toBe(409);183184 const address = await protectedSite();185 const cookie = await unlock(address);186 await save(address, cookie, { items: { intro: "first round" }, submit: true });187188 // Notifications are disabled in the test config: the round still opens.189 const response = await api(`sites/${address}/feedback/request`, {});190 expect(response.status, await response.clone().text()).toBe(200);191 const round = await response.json<192 { requestedAt: number; submittedAt: number | null; notification: unknown }193 >();194 expect(round).toMatchObject({ submittedAt: null, notification: null });195 expect(round.requestedAt).toEqual(expect.any(Number));196 expect((await api(`sites/${address}/feedback/request`, { message: "" })).status).toBe(400);197 expect((await api(`sites/${address}/feedback/request`, { other: 1 })).status).toBe(400);198199 // With notifications enabled, the request queues a lock-screen-safe event for the site.200 const site = await getSiteByAddress(env, parseInt(address, 16));201 const ctx = createExecutionContext();202 const enabled = await handleFeedbackRequest(203 new Request("https://share.demo.dev/", {204 method: "POST",205 body: JSON.stringify({ message: "Plan v2 is ready" }),206 }),207 { ...env, NOTIFY_ENABLED: "true" },208 site!,209 ctx,210 );211 await waitOnExecutionContext(ctx);212 const body = await enabled.json<{ notification: { eventId: string } }>();213 const event = await env.DB.prepare(214 "SELECT title, body, path, tag FROM notification_events WHERE id = ?",215 ).bind(body.notification.eventId).first();216 expect(event).toEqual({217 title: "Feedback requested",218 body: "Plan v2 is ready",219 path: `/${address}`,220 tag: `feedback-${address}`,221 });222 });223224 it("forgets notes when the audience changes but not on a same-password re-push", async () => {225 const address = await protectedSite();226 const target = { address: parseInt(address, 16) };227 const notes =228 async () => (await (await api(`sites/${address}/feedback`, undefined, "GET")).json<229 { items: unknown[]; requestedAt: number | null }230 >());231 let cookie = await unlock(address);232 await save(address, cookie, { items: { intro: "keep me" } });233 await api(`sites/${address}/feedback/request`, {});234235 // Wrappers that resend the same password on every push keep the round.236 expect((await publish({ password: PASSWORD }, target)).status).toBe(200);237 expect((await api(`sites/${address}`, { password: PASSWORD }, "PATCH")).status).toBe(200);238 expect((await notes()).items).toHaveLength(1);239240 // A different password is a different audience.241 expect((await api(`sites/${address}`, { password: "another-password" }, "PATCH")).status)242 .toBe(200);243 expect(await notes()).toMatchObject({ items: [], requestedAt: null });244245 // Turning feedback back on also starts clean.246 await api(`sites/${address}`, { password: PASSWORD }, "PATCH");247 cookie = await unlock(address);248 await save(address, cookie, { items: { intro: "second" } });249 await api(`sites/${address}`, { feedback: false }, "PATCH");250 expect((await publish({ feedback: true }, target)).status).toBe(200);251 expect((await notes()).items).toHaveLength(0);252 });253254 it("enforces the password rule and item cap in the database too", async () => {255 const open = await (await publish()).json<{ address: string }>();256 await expect(257 env.DB.prepare("UPDATE sites SET feedback = 1 WHERE address = ?")258 .bind(parseInt(open.address, 16)).run(),259 ).rejects.toThrow(/feedback requires password protection/);260261 const address = parseInt(await protectedSite(), 16);262 await env.DB.batch(263 Array.from({ length: 200 }, (_, i) =>264 env.DB.prepare(265 "INSERT INTO site_feedback (address, item, text, seq, updated_at) VALUES (?, ?, 'n', 1, 1)",266 ).bind(address, `item-${i}`)),267 );268 await expect(269 env.DB.prepare(270 "INSERT INTO site_feedback (address, item, text, seq, updated_at) VALUES (?, 'x', 'n', 1, 1)",271 ).bind(address).run(),272 ).rejects.toThrow(/feedback item limit reached/);273 // Updating an existing note at the cap still works.274 await env.DB.prepare(275 `INSERT INTO site_feedback (address, item, text, seq, updated_at) VALUES (?, 'item-1', 'u', 1, 2)276 ON CONFLICT(address, item) DO UPDATE SET text = excluded.text`,277 ).bind(address).run();278 });279280 it("fires a one-shot submit callback that never carries note text", async () => {281 const address = await protectedSite();282 const request = (body: unknown) => api(`sites/${address}/feedback/request`, body);283 expect((await request({ callbackUrl: "http://hooks.example.com/t/abc" })).status).toBe(400);284 expect((await request({ callbackUrl: "https://u:p@hooks.example.com/t" })).status).toBe(400);285 expect((await request({ callbackUrl: "not a url" })).status).toBe(400);286 const armed = await request({ callbackUrl: "https://hooks.example.com/t/abc" });287 const armedText = await armed.text();288 expect(JSON.parse(armedText)).toMatchObject({ callbackPending: true });289 expect(armedText).not.toContain("hooks.example.com");290291 const calls: { url: string; body: string }[] = [];292 const original = globalThis.fetch;293 globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {294 calls.push({ url: String(input), body: String(init?.body) });295 return Promise.resolve(new Response(null, { status: 204 }));296 }) as typeof fetch;297 try {298 const site = await getSiteByAddress(env, parseInt(address, 16));299 const submit = async () => {300 const ctx = createExecutionContext();301 const req = new Request(`https://${address}.demo.dev/__feedback`, {302 method: "POST",303 headers: { "content-type": "application/json", origin: `https://${address}.demo.dev` },304 body: JSON.stringify({ items: { intro: "private note text" }, submit: true }),305 });306 const response = await serveSiteFeedback(req, env, new URL(req.url), site!, ctx);307 await waitOnExecutionContext(ctx);308 return response!;309 };310 expect((await submit()).status).toBe(200);311 expect(calls).toHaveLength(1);312 expect(calls[0].url).toBe("https://hooks.example.com/t/abc");313 expect(JSON.parse(calls[0].body)).toMatchObject({314 event: "nzip.feedback.submitted",315 address,316 url: `https://${address}.demo.dev/`,317 items: 1,318 });319 expect(calls[0].body).not.toContain("private note text");320 await submit();321 expect(calls).toHaveLength(1);322 } finally {323 globalThis.fetch = original;324 }325 const after = await (await api(`sites/${address}/feedback`, undefined, "GET")).json();326 expect(after).toMatchObject({ callbackPending: false });327328 // A new audience disarms any pending callback.329 await request({ callbackUrl: "https://hooks.example.com/t/def" });330 await api(`sites/${address}`, { password: "another-password" }, "PATCH");331 const reset = await (await api(`sites/${address}/feedback`, undefined, "GET")).json();332 expect(reset).toMatchObject({ callbackPending: false });333 });334335 it("deletes notes with their site so a reused address starts clean", async () => {336 const address = await protectedSite();337 const cookie = await unlock(address);338 await save(address, cookie, { items: { intro: "note" } });339 expect((await api(`sites/${address}`, undefined, "DELETE")).status).toBe(200);340 const left = await env.DB.prepare("SELECT COUNT(*) AS n FROM site_feedback WHERE address = ?")341 .bind(parseInt(address, 16)).first<{ n: number }>();342 expect(left?.n).toBe(0);343 });344});