import { request as httpRequest } from "node:http"; import { afterEach, describe, expect, it, vi } from "vitest"; import { startReviewServer } from "../server.js"; import type { AskRequest, Draft, Review, ReviewServer, ServerOptions, } from "../types.js"; function makeReview(id = "snapshot-1"): Review { return { snapshot: { id, repoRoot: "/safe/project", source: { kind: "working" }, base: "base-id", head: "head-id", hunks: [ { id: "hunk-a", path: "src/a.ts", header: "@@ -1,2 +1,2 @@", lines: [ { kind: "delete", text: "old", oldLine: 1 }, { kind: "add", text: "new", newLine: 1 }, { kind: "context", text: "same", oldLine: 2, newLine: 2 }, ], }, ], skipped: [{ path: "media.bin", reason: "binary" }], }, plan: { summary: "One focused change", cohorts: [ { title: "Core", layers: [ { id: "layer-a", title: "Behavior", summary: "Changes behavior", hunks: [{ id: "hunk-a", summary: "Replace old with new" }], flow: ["Input", "Output"], }, ], }, ], }, draft: { reviewed: [], findings: [], notes: "" }, }; } function options(overrides: Partial = {}): ServerOptions { return { review: makeReview(), isCurrent: vi.fn(async () => true), refresh: vi.fn(async () => makeReview("snapshot-2")), ask: vi.fn(async (request) => `Answer: ${request.question}`), save: vi.fn(), submit: vi.fn(async () => {}), ...overrides, }; } type Session = { server: ReviewServer; origin: string; cookie: string; revision: number; snapshotId: string; }; const servers = new Set(); afterEach(async () => { await Promise.all([...servers].map((server) => server.close())); servers.clear(); }); async function openSession(serverOptions: ServerOptions): Promise { const server = await startReviewServer(serverOptions); servers.add(server); return openExisting(server); } async function api( session: Session, path: string, body?: unknown, extraHeaders: Record = {}, ): Promise { const response = await fetch(`${session.origin}${path}`, { method: body === undefined ? "GET" : "POST", headers: { Cookie: session.cookie, ...(body === undefined ? {} : { Origin: session.origin, "Content-Type": "application/json", ...(path === "/api/close" ? {} : { "If-Match": String(session.revision), "X-Strata-Snapshot": session.snapshotId, }), }), ...extraHeaders, }, body: body === undefined ? undefined : JSON.stringify(body), }); const payload = await response .clone() .json() .catch(() => undefined); if (payload && Number.isSafeInteger(payload.revision)) { session.revision = payload.revision; session.snapshotId = payload.snapshotId; } return response; } describe("review server HTTP boundary", () => { it("exchanges its one-time URL token and authenticates pages and assets", async () => { const server = await startReviewServer(options()); servers.add(server); const origin = new URL(server.url).origin; const unauthenticated = await fetch(origin); expect(unauthenticated.status).toBe(401); expect(unauthenticated.headers.get("cache-control")).toBe("no-store"); const session = await openExisting(server); const page = await api(session, "/"); expect(page.status).toBe(200); expect(page.headers.get("content-security-policy")).toContain( "script-src 'self'", ); expect(await page.text()).toContain("STRATA"); const script = await api(session, "/assets/app.js"); expect(script.status).toBe(200); expect(script.headers.get("content-type")).toContain("text/javascript"); const unauthenticatedAsset = await fetch(`${origin}/assets/style.css`); expect(unauthenticatedAsset.status).toBe(401); }); it("rejects forged hosts, origins, and oversized requests", async () => { const session = await openSession(options()); const forgedHost = await requestStatus(session, "/api/state", { Host: "localhost.invalid", Cookie: session.cookie, }); expect(forgedHost).toBe(400); const forgedOrigin = await api( session, "/api/draft", { reviewed: [], findings: [], notes: "" }, { Origin: "http://localhost.invalid" }, ); expect(forgedOrigin.status).toBe(403); const oversized = await fetch(`${session.origin}/api/draft`, { method: "POST", headers: { Cookie: session.cookie, Origin: session.origin, "Content-Type": "application/json", }, body: JSON.stringify({ notes: "x".repeat(70_000) }), }); expect(oversized.status).toBe(413); }); it("serves state and saves only valid complete drafts", async () => { const save = vi.fn(); const session = await openSession(options({ save })); const state = await api(session, "/api/state"); expect(await state.json()).toMatchObject({ revision: 0, snapshotId: "snapshot-1", review: { snapshot: { id: "snapshot-1" } }, threads: [], }); const draft: Draft = { reviewed: ["hunk-a"], findings: [ { id: "finding-a", hunkId: "hunk-a", side: "new", line: 1, severity: "major", text: "Please verify this.", }, ], notes: "Review note", }; const saved = await api(session, "/api/draft", draft); expect(saved.status).toBe(200); expect(await saved.json()).toMatchObject({ revision: 1, snapshotId: "snapshot-1", draft, }); expect(save).toHaveBeenCalledWith(draft); const invalid = await api(session, "/api/draft", { ...draft, findings: [{ ...draft.findings[0], line: 99 }], }); expect(invalid.status).toBe(400); expect(save).toHaveBeenCalledTimes(1); const extra = await api(session, "/api/draft", { ...draft, extra: true }); expect(extra.status).toBe(400); }); it("rejects missing or stale optimistic bindings without overwriting another tab", async () => { const save = vi.fn(); const session = await openSession(options({ save })); const secondTab = await openExisting(session.server); const missing = await fetch(`${session.origin}/api/draft`, { method: "POST", headers: { Cookie: session.cookie, Origin: session.origin, "Content-Type": "application/json", }, body: JSON.stringify({ reviewed: [], findings: [], notes: "missing" }), }); expect(missing.status).toBe(409); expect((await missing.json()).code).toBe("stale_client"); const first = await api(session, "/api/draft", { reviewed: [], findings: [], notes: "first tab", }); expect(first.status).toBe(200); expect(await first.json()).toMatchObject({ revision: 1, snapshotId: "snapshot-1", }); const stale = await api(secondTab, "/api/draft", { reviewed: [], findings: [], notes: "second tab", }); expect(stale.status).toBe(409); expect((await stale.json()).code).toBe("stale_client"); expect(save).toHaveBeenCalledTimes(1); }); it("asks only known layers and returns plain bounded answers", async () => { const ask = vi.fn( async (request: AskRequest) => `${request.question}`, ); const session = await openSession(options({ ask })); const response = await api(session, "/api/ask", { layerId: "layer-a", hunkId: "hunk-a", question: "Why?", }); expect(response.status).toBe(200); expect(await response.json()).toMatchObject({ answer: "Why?", revision: 1, snapshotId: "snapshot-1", threads: [ { layerId: "layer-a", messages: [ { question: "Why?", answer: "Why?", hunkId: "hunk-a", }, ], }, ], }); expect(ask).toHaveBeenCalledWith( { layerId: "layer-a", hunkId: "hunk-a", question: "Why?", history: [], }, expect.any(AbortSignal), ); const unknown = await api(session, "/api/ask", { layerId: "missing", hunkId: null, question: "Why?", }); expect(unknown.status).toBe(400); const browserHistory = await api(session, "/api/ask", { layerId: "layer-a", hunkId: null, question: "Again?", history: [], }); expect(browserHistory.status).toBe(400); const whitespace = await api(session, "/api/ask", { layerId: null, hunkId: null, question: " \n\t ", }); expect(whitespace.status).toBe(400); const oversizedUtf8 = await api(session, "/api/ask", { layerId: null, hunkId: null, question: "é".repeat(3_000), }); expect(oversizedUtf8.status).toBe(400); }); it("owns separate layer and All changes histories and rejects invalid focus", async () => { const review = makeReview(); review.snapshot.hunks.push({ id: "hunk-b", path: "src/b.ts", header: "@@ -1 +1 @@", lines: [{ kind: "add", text: "b", newLine: 1 }], }); review.plan.cohorts[0]?.layers.push({ id: "layer-b", title: "Other", summary: "Other behavior", hunks: [{ id: "hunk-b", summary: "Adds b" }], }); const requests: AskRequest[] = []; const session = await openSession( options({ review, ask: async (request) => { requests.push(request); return `Answer ${requests.length}`; }, }), ); expect( ( await api(session, "/api/ask", { layerId: "layer-a", hunkId: "hunk-b", question: "Wrong focus", }) ).status, ).toBe(400); await api(session, "/api/ask", { layerId: null, hunkId: "hunk-b", question: "Whole review", }); await api(session, "/api/ask", { layerId: "layer-a", hunkId: "hunk-a", question: "Layer question", }); await api(session, "/api/ask", { layerId: "layer-a", hunkId: null, question: "Follow-up", }); expect(requests[0]).toMatchObject({ layerId: null, history: [] }); expect(requests[1]).toMatchObject({ layerId: "layer-a", history: [] }); expect(requests[2]?.history).toEqual([ { question: "Layer question", answer: "Answer 2", hunkId: "hunk-a", }, ]); const state = await (await api(session, "/api/state")).json(); expect(state.threads).toHaveLength(2); expect(state.threads[0].layerId).toBeNull(); }); it("checks freshness before Ask and again before storing the answer", async () => { const ask = vi.fn(async () => "Answer"); const staleBefore = await openSession( options({ isCurrent: vi.fn(async () => false), ask }), ); const refused = await api(staleBefore, "/api/ask", { layerId: null, hunkId: null, question: "Question", }); expect(refused.status).toBe(409); expect(ask).not.toHaveBeenCalled(); const freshness = vi .fn<() => Promise>() .mockResolvedValueOnce(true) .mockResolvedValueOnce(false); const staleAfter = await openSession( options({ isCurrent: freshness, ask }), ); const discarded = await api(staleAfter, "/api/ask", { layerId: null, hunkId: null, question: "Question", }); expect(discarded.status).toBe(409); expect(ask).toHaveBeenCalledOnce(); const state = await (await api(staleAfter, "/api/state")).json(); expect(state).toMatchObject({ revision: 0, threads: [] }); }); it("rejects Ask history growth at 128 completed exchanges without truncation", async () => { const session = await openSession(options({ ask: async () => "Answer" })); for (let index = 0; index < 128; index++) { const response = await api(session, "/api/ask", { layerId: null, hunkId: null, question: `Question ${index}`, }); expect(response.status).toBe(200); } const refused = await api(session, "/api/ask", { layerId: "layer-a", hunkId: null, question: "One too many", }); expect(refused.status).toBe(409); expect((await refused.json()).code).toBe("history_limit"); const state = await (await api(session, "/api/state")).json(); expect(state.revision).toBe(128); expect(state.threads[0].messages).toHaveLength(128); }); it("busy-rejects overlapping callbacks and aborts outstanding work on close", async () => { let started!: () => void; const didStart = new Promise((resolve) => { started = resolve; }); let aborted = false; const ask = vi.fn( async (_request: AskRequest, signal: AbortSignal) => new Promise((_resolve, reject) => { started(); signal.addEventListener("abort", () => { aborted = true; reject(new Error("aborted")); }); }), ); const session = await openSession(options({ ask })); const asking = api(session, "/api/ask", { layerId: "layer-a", hunkId: null, question: "Wait", }).catch(() => undefined); await didStart; const busy = await api(session, "/api/draft", { reviewed: [], findings: [], notes: "queued", }); expect(busy.status).toBe(409); await session.server.close(); await asking; expect(aborted).toBe(true); }); it("awaits request cleanup before close settles", async () => { const started = Promise.withResolvers(); const release = Promise.withResolvers(); let signal: AbortSignal | undefined; let cleaned = false; const session = await openSession( options({ ask: async (_request, requestSignal) => { signal = requestSignal; started.resolve(); try { return await release.promise; } finally { cleaned = true; } }, }), ); const asking = api(session, "/api/ask", { layerId: "layer-a", hunkId: null, question: "Wait", }).catch(() => undefined); try { await started.promise; let settled = false; const closing = session.server.close(); void closing.then(() => { settled = true; }); expect(session.server.close()).toBe(closing); expect(signal?.aborted).toBe(true); await Promise.resolve(); expect(settled).toBe(false); expect(cleaned).toBe(false); release.resolve("late answer"); await closing; expect(cleaned).toBe(true); } finally { release.resolve("cleanup"); await session.server.close(); await asking; } }); it("refuses stale submission, preserves its draft, then clears after success", async () => { const save = vi.fn(); const submit = vi.fn(async () => {}); const isCurrent = vi .fn<() => Promise>() .mockResolvedValueOnce(false) .mockResolvedValueOnce(true); const session = await openSession(options({ isCurrent, save, submit })); const draft: Draft = { reviewed: ["hunk-a"], findings: [], notes: "Keep me", }; const stale = await api(session, "/api/submit", { draft }); expect(stale.status).toBe(409); expect((await stale.json()).code).toBe("stale"); expect(submit).not.toHaveBeenCalled(); const state = await api(session, "/api/state"); expect((await state.json()).review.draft).toEqual(draft); const sent = await api(session, "/api/submit", { draft }); expect(sent.status).toBe(200); expect(submit).toHaveBeenCalledWith({ ...draft, snapshotId: "snapshot-1" }); expect(save).toHaveBeenLastCalledWith({ reviewed: [], findings: [], notes: "", }); }); it("requires draft confirmation, checks freshness, and safely replaces review", async () => { const isCurrent = vi.fn(async () => false); const refresh = vi.fn(async () => makeReview("snapshot-2")); const session = await openSession( options({ review: { ...makeReview(), draft: { reviewed: [], findings: [], notes: "draft" }, }, isCurrent, refresh, }), ); const refused = await api(session, "/api/refresh", { confirm: false }); expect(refused.status).toBe(409); expect((await refused.json()).code).toBe("confirm_required"); expect(isCurrent).toHaveBeenCalledTimes(1); expect(refresh).not.toHaveBeenCalled(); const accepted = await api(session, "/api/refresh", { confirm: true }); expect(accepted.status).toBe(200); const payload = await accepted.json(); expect(payload.wasCurrent).toBe(false); expect(payload.review.snapshot.id).toBe("snapshot-2"); expect(refresh).toHaveBeenCalledWith(expect.any(AbortSignal)); }); it("clears server-owned threads on refresh", async () => { const session = await openSession(options()); await api(session, "/api/ask", { layerId: null, hunkId: "hunk-a", question: "Before refresh", }); const refreshed = await api(session, "/api/refresh", { confirm: true }); expect(refreshed.status).toBe(200); expect((await refreshed.json()).threads).toEqual([]); expect((await (await api(session, "/api/state")).json()).threads).toEqual( [], ); }); it("closes explicitly through the authenticated endpoint", async () => { const session = await openSession(options()); const response = await api(session, "/api/close", {}); expect(response.status).toBe(200); expect(await response.json()).toMatchObject({ closed: true, revision: 0, snapshotId: "snapshot-1", }); }); }); function requestStatus( session: Session, path: string, headers: Record, ): Promise { return new Promise((resolve, reject) => { const request = httpRequest( `${session.origin}${path}`, { headers }, (response) => { response.resume(); response.on("end", () => resolve(response.statusCode)); }, ); request.on("error", reject); request.end(); }); } async function openExisting(server: ReviewServer): Promise { const exchange = await fetch(server.url, { redirect: "manual" }); const cookie = exchange.headers.get("set-cookie")?.split(";", 1)[0]; if (!cookie) throw new Error("server did not set an authentication cookie"); const origin = new URL(server.url).origin; const state = await fetch(`${origin}/api/state`, { headers: { Cookie: cookie }, }).then((response) => response.json()); return { server, origin, cookie, revision: state.revision, snapshotId: state.snapshotId, }; }