diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 63f47de..8c0d56e 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -59,7 +59,7 @@ jobs: # The Go counterpart to the `complexity` rule the page scripts are linted # with. Pinned so a later gocyclo release cannot change the verdict on a # tree that did not change. - - run: go run github.com/fzipp/gocyclo/cmd/gocyclo@v0.6.0 -over 5 ./src + - run: go run github.com/fzipp/gocyclo/cmd/gocyclo@v0.6.0 -over 4 ./src timeout-minutes: 2 # Coverage is measured and reported, not gated. A threshold set before the # number is known is a guess; this prints it so a later one can be set at diff --git a/AGENTS.md b/AGENTS.md index 9fda128..b503843 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,10 @@ the fixtures shared by every test that drives a real request. drive real requests through it without a listening socket. Anything that pulls configuration out of the environment belongs in `main`, not in a handler. +The live feed is the exception. A WebSocket upgrade needs a real connection +underneath it, so `sockets_test.go` starts an application of its own on a +loopback port and dials it. Everything else stays on the in-memory transport. + ## The Playwright suite covers screens and page scripts separately `e2e/tests` holds one spec per subject. A `*-screen.spec.ts` drives a screen @@ -44,6 +48,12 @@ helpers do. Fixtures used by more than one spec live in `tests/support/harness.ts`, which is also the one place a type is asserted rather than proven, at the `JSON.parse` and `response.json()` boundaries. +Screens are reached through `getByTestId`. `testIdAttribute` in +`playwright.config.ts` points it at the `data-test` attribute the templates +carry, so a test names a hook and never spells an attribute selector. An element +a test reaches for gets a `data-test`; anything a reader can identify by its +role or its text is reached that way instead. + ## Comments Comments describe what the code does now and warn about non-obvious constraints @@ -141,7 +151,7 @@ not change the verdict on a tree that did not change. asserting the value beside it, and a `t.Fatal` on every teardown call reads worse than it protects. -The complexity ceiling stays separate. `gocyclo -over 5` is a ratchet rather +The complexity ceiling stays separate. `gocyclo -over 4` is a ratchet rather than a correctness check, and it is documented alongside the ESLint one. ## Database standards diff --git a/docs/scripts.md b/docs/scripts.md index 127cc74..ea5f9f7 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -61,12 +61,12 @@ ships a stray Go package once the npm tooling is installed. Check cyclomatic complexity: ```bash -go run github.com/fzipp/gocyclo/cmd/gocyclo@v0.6.0 -over 5 ./src +go run github.com/fzipp/gocyclo/cmd/gocyclo@v0.6.0 -over 4 ./src go run github.com/fzipp/gocyclo/cmd/gocyclo@v0.6.0 -top 10 ./src ``` Both sides carry a ceiling, and both are set at the worst score the tree -currently holds: `-over 5` for Go, and `complexity` at 6 in `eslint.config.mjs` +currently holds: `-over 4` for Go, and `complexity` at 5 in `eslint.config.mjs` for the page scripts and the Playwright suite. The JavaScript side carries two more ratchets set the same way, `max-params` at 3 and `max-depth` at 2. `-top` takes no position and is the one to run when deciding what to simplify next. diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 6f1af52..35b738f 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -16,6 +16,11 @@ export default defineConfig({ baseURL: BASE_URL, trace: "on-first-retry", permissions: ["clipboard-read", "clipboard-write"], + // The templates mark their test hooks with data-test, so getByTestId reads + // them directly. Spelling the attribute here rather than writing the + // selector at each call site keeps a hook's name the only thing a test + // states about it. + testIdAttribute: "data-test", }, projects: [ { diff --git a/e2e/tests/contact-screen.spec.ts b/e2e/tests/contact-screen.spec.ts index a21196b..0efbce6 100644 --- a/e2e/tests/contact-screen.spec.ts +++ b/e2e/tests/contact-screen.spec.ts @@ -20,7 +20,7 @@ test.describe("Contact screen", () => { await page.locator('input[name="name"]').fill("John Doe"); await page.locator('input[name="email"]').fill("john@doe.test"); await page.locator('textarea[name="message"]').fill("Hello, World!"); - await expect(page.locator('button[data-test="send-form"]')).toBeEnabled(); + await expect(page.getByTestId("send-form")).toBeEnabled(); }); test("every field is labelled and required", async ({ page }) => { @@ -32,7 +32,7 @@ test.describe("Contact screen", () => { }); test("an empty form does not submit", async ({ page }) => { - await page.locator('button[data-test="send-form"]').click(); + await page.getByTestId("send-form").click(); await expect(page).toHaveURL(/\/contact$/); }); diff --git a/e2e/tests/endpoint-screen.spec.ts b/e2e/tests/endpoint-screen.spec.ts index ebc40eb..ad56b81 100644 --- a/e2e/tests/endpoint-screen.spec.ts +++ b/e2e/tests/endpoint-screen.spec.ts @@ -24,12 +24,11 @@ const HYDRATION_TIMEOUT_MS = 15_000; * spelled once rather than at every call site, so a change to the markup lands * in one place. */ -const stream = (page: Page) => page.locator('[data-test="requests"]'); -const resultCount = (page: Page) => - page.locator('[data-test="search-results"]'); -const searchBox = (page: Page) => page.locator('[data-test="search-input"]'); -const newestCard = (page: Page) => - page.locator('[data-test="request"]').first(); +const stream = (page: Page) => page.getByTestId("requests"); +const resultCount = (page: Page) => page.getByTestId("search-results"); +const searchBox = (page: Page) => page.getByTestId("search-input"); +const requestCards = (page: Page) => page.getByTestId("request"); +const newestCard = (page: Page) => requestCards(page).first(); /** * The capture a send produced, found by the UUID the server echoes back. Going @@ -60,7 +59,7 @@ test.describe("Endpoint screen", () => { // Given longer than an ordinary assertion because it is waiting on that // script to arrive from a CDN, which is slower and less predictable than // anything the app itself does. - await expect(page.locator('[data-test="empty-waiting"]')).toBeVisible({ + await expect(page.getByTestId("empty-waiting")).toBeVisible({ timeout: HYDRATION_TIMEOUT_MS, }); }); @@ -71,20 +70,16 @@ test.describe("Endpoint screen", () => { }); test("the endpoint URL is shown with a copy button", async ({ page }) => { - await expect(page.locator('[data-test="endpoint-url"]')).toContainText( - endpointUrl, - ); - await expect(page.locator('[data-test="copy-url"]')).toBeVisible(); + await expect(page.getByTestId("endpoint-url")).toContainText(endpointUrl); + await expect(page.getByTestId("copy-url")).toBeVisible(); }); test("the copy-url button writes the URL to the clipboard", async ({ page, }) => { - await page.locator('[data-test="copy-url"]').click(); + await page.getByTestId("copy-url").click(); expect(await readClipboard(page)).toBe(endpointUrl); - await expect(page.locator('[data-test="copy-url-label"]')).toContainText( - "Copied!", - ); + await expect(page.getByTestId("copy-url-label")).toContainText("Copied!"); }); test("the retention and visibility terms are stated", async ({ page }) => { @@ -94,9 +89,7 @@ test.describe("Endpoint screen", () => { }); test("the connection indicator settles on live", async ({ page }) => { - await expect( - page.locator('[data-test="connection-status"]'), - ).toContainText("Live"); + await expect(page.getByTestId("connection-status")).toContainText("Live"); }); }); @@ -130,15 +123,15 @@ test.describe("Endpoint screen", () => { headers: { "Content-Type": "text/plain" }, }); const card = newestCard(page); - const details = card.locator('[data-test="request-details"]'); + const details = card.getByTestId("request-details"); await expect(details).toContainText(/now|seconds? ago/); await expect(details).toContainText("127.0.0.1"); await expect(details).toContainText(endpointPath); await expect(card).toContainText("POST"); - await expect(card.locator('[data-test="request-headers"]')).toContainText( + await expect(card.getByTestId("request-headers")).toContainText( "Content-Type", ); - await expect(card.locator('[data-test="request-body"]')).toContainText( + await expect(card.getByTestId("request-body")).toContainText( "Hello, World!", ); }); @@ -150,16 +143,16 @@ test.describe("Endpoint screen", () => { await send(request, `${endpointUrl}?event=charge.succeeded`, { data: "x", }); - const withQuery = page.locator('[data-test="query-string"]').first(); + const withQuery = page.getByTestId("query-string").first(); await expect(withQuery).toContainText("event=charge.succeeded"); - await expect( - page.locator('[data-test="request-path"]').first(), - ).toContainText("?event=charge.succeeded"); + await expect(page.getByTestId("request-path").first()).toContainText( + "?event=charge.succeeded", + ); await send(request, endpointUrl, { data: "y" }); - await expect( - page.locator('[data-test="query-string"]').first(), - ).toContainText("None"); + await expect(page.getByTestId("query-string").first()).toContainText( + "None", + ); }); test("a bodyless request reports no body rather than an empty panel", async ({ @@ -178,7 +171,7 @@ test.describe("Endpoint screen", () => { data: "x", headers: { "X-One": "1", "X-Two": "2" }, }); - const headers = page.locator('[data-test="request-headers"]').first(); + const headers = page.getByTestId("request-headers").first(); await expect(headers).toContainText("X-One"); await expect(headers).toContainText("X-Two"); await expect(headers).toContainText(/Headers\s*\(\d+\)/); @@ -210,6 +203,16 @@ test.describe("Endpoint screen", () => { await expect(body).toContainText("4.0 KB"); }); + // Everything on this page changes without a navigation, so an arrival that + // is not spoken here is one a screen reader user is never told about. + test("an arriving capture is announced", async ({ page, request }) => { + await send(request, endpointUrl, { method: "PUT", data: "spoken" }); + + await expect(page.getByTestId("announcer")).toHaveText( + "PUT request received", + ); + }); + test("delete-request removes a single card", async ({ page, request }) => { await send(request, endpointUrl, { data: "first" }); const response = await send(request, endpointUrl, { @@ -218,7 +221,7 @@ test.describe("Endpoint screen", () => { const uuid = capturedUuid(response); const card = cardFor(page, uuid); await expect(card).toBeAttached(); - await card.locator('[data-test="delete-request"]').click(); + await card.getByTestId("delete-request").click(); await expect(card).not.toBeAttached(); }); @@ -229,17 +232,17 @@ test.describe("Endpoint screen", () => { await send(request, endpointUrl, { data: "x" }); await send(request, endpointUrl, { data: "y" }); - await page.locator('[data-test="delete-requests"]').click(); + await page.getByTestId("delete-requests").click(); // Destructive and unrecoverable, so it confirms first; the requests are // still there until the confirmation is accepted. - await expect(page.locator('[data-test="delete-confirm"]')).toBeVisible(); - await expect(page.locator('[data-test="request"]')).toHaveCount(2); + await expect(page.getByTestId("delete-confirm")).toBeVisible(); + await expect(requestCards(page)).toHaveCount(2); - await page.locator('[data-test="delete-cancel"]').click(); - await expect(page.locator('[data-test="request"]')).toHaveCount(2); + await page.getByTestId("delete-cancel").click(); + await expect(requestCards(page)).toHaveCount(2); - await page.locator('[data-test="delete-requests"]').click(); - await page.locator('[data-test="delete-confirm-button"]').click(); + await page.getByTestId("delete-requests").click(); + await page.getByTestId("delete-confirm-button").click(); await expect(stream(page)).toContainText("Waiting for requests"); }); @@ -277,14 +280,12 @@ test.describe("Endpoint screen", () => { } await expect(resultCount(page)).toContainText(`${overOnePage} results`); - await expect(page.locator('[data-test="request"]')).toHaveCount(25); - const showMore = page.locator('[data-test="show-more"]'); + await expect(requestCards(page)).toHaveCount(25); + const showMore = page.getByTestId("show-more"); await expect(showMore).toContainText("Show 1 more"); await showMore.click(); - await expect(page.locator('[data-test="request"]')).toHaveCount( - overOnePage, - ); + await expect(requestCards(page)).toHaveCount(overOnePage); await expect(showMore).toBeHidden(); }); }); @@ -333,6 +334,25 @@ test.describe("Endpoint screen", () => { await expect(body.locator("img")).toHaveCount(0); }); + // Highlighting emits roughly an element per token, so colouring a large + // payload costs a visible stall and tens of thousands of nodes to decorate + // text the reader scrolls past. Above the renderer's ceiling the colour is + // dropped and nothing else is: the body is still whole and still formatted. + test("a body past the highlight ceiling keeps its text and loses its colour", async ({ + page, + request, + }) => { + await send(request, endpointUrl, { + data: { note: "x".repeat(60_000) }, + headers: { "Content-Type": "application/json" }, + }); + const body = newestBody(page); + + await expect(body).toContainText('"note"'); + expect(await body.locator("pre span.hljs-string").count()).toBe(0); + expect(await newestBodyText(page)).toContain("x".repeat(1_000)); + }); + test("a multipart body shows its fields as a parsed list", async ({ page, request, @@ -509,11 +529,11 @@ test.describe("Endpoint screen", () => { await expect(resultCount(page)).toContainText("3 results"); - await page.locator('[data-test="method-filter"]').selectOption("POST"); + await page.getByTestId("method-filter").selectOption("POST"); await expect(resultCount(page)).toContainText("1 result"); - await expect(page.locator('[data-test="request"]')).toHaveCount(1); + await expect(requestCards(page)).toHaveCount(1); - await page.locator('[data-test="method-filter"]').selectOption(""); + await page.getByTestId("method-filter").selectOption(""); await expect(resultCount(page)).toContainText("3 results"); }); @@ -522,16 +542,14 @@ test.describe("Endpoint screen", () => { request, }) => { await send(request, endpointUrl, { data: "x" }); - await expect(page.locator('[data-test="request"]')).toHaveCount(1); + await expect(requestCards(page)).toHaveCount(1); - await page - .locator('[data-test="search-input"]') - .fill("nothing-matches-this-term"); - await expect(page.locator('[data-test="empty-filtered"]')).toBeVisible(); - await expect(page.locator('[data-test="empty-waiting"]')).toHaveCount(0); + await searchBox(page).fill("nothing-matches-this-term"); + await expect(page.getByTestId("empty-filtered")).toBeVisible(); + await expect(page.getByTestId("empty-waiting")).toHaveCount(0); - await page.locator('[data-test="clear-filters"]').click(); - await expect(page.locator('[data-test="request"]')).toHaveCount(1); + await page.getByTestId("clear-filters").click(); + await expect(requestCards(page)).toHaveCount(1); }); // Delete-all clears the endpoint, not the filtered view its neighbour @@ -542,16 +560,16 @@ test.describe("Endpoint screen", () => { }) => { await send(request, endpointUrl, { data: "p" }); await send(request, endpointUrl, { method: "PUT", data: "u" }); - await expect(page.locator('[data-test="request"]')).toHaveCount(2); + await expect(requestCards(page)).toHaveCount(2); - await page.locator('[data-test="method-filter"]').selectOption("PUT"); + await page.getByTestId("method-filter").selectOption("PUT"); - await expect( - page.locator('[data-test="copy-all-har-label"]'), - ).toContainText("Copy shown (1)"); - await expect( - page.locator('[data-test="delete-requests-label"]'), - ).toContainText("Delete all (2)"); + await expect(page.getByTestId("copy-all-har-label")).toContainText( + "Copy shown (1)", + ); + await expect(page.getByTestId("delete-requests-label")).toContainText( + "Delete all (2)", + ); }); // The search runs on the server, so the list it returns is not the @@ -562,16 +580,16 @@ test.describe("Endpoint screen", () => { }) => { await send(request, endpointUrl, { data: "alpha" }); await send(request, endpointUrl, { data: "beta" }); - await expect(page.locator('[data-test="request"]')).toHaveCount(2); + await expect(requestCards(page)).toHaveCount(2); await searchBox(page).fill("alpha"); await expect(resultCount(page)).toContainText("1 result"); - await expect( - page.locator('[data-test="delete-requests-label"]'), - ).toContainText("Delete all (2)"); + await expect(page.getByTestId("delete-requests-label")).toContainText( + "Delete all (2)", + ); - await page.locator('[data-test="delete-requests"]').click(); - await expect(page.locator('[data-test="delete-confirm"]')).toContainText( + await page.getByTestId("delete-requests").click(); + await expect(page.getByTestId("delete-confirm")).toContainText( "Delete all 2 captured requests?", ); }); @@ -581,13 +599,11 @@ test.describe("Endpoint screen", () => { request, }) => { await send(request, endpointUrl, { data: "alpha" }); - await expect(page.locator('[data-test="request"]')).toHaveCount(1); + await expect(requestCards(page)).toHaveCount(1); - await page - .locator('[data-test="search-input"]') - .fill("nothing-matches-this-term"); + await searchBox(page).fill("nothing-matches-this-term"); - await expect(page.locator('[data-test="empty-filtered"]')).toContainText( + await expect(page.getByTestId("empty-filtered")).toContainText( "1 captured on this endpoint", ); }); @@ -604,7 +620,7 @@ test.describe("Endpoint screen", () => { headers: { "Content-Type": "application/json" }, }); const card = newestCard(page); - await card.locator('[data-test="copy-body"]').click(); + await card.getByTestId("copy-body").click(); expect(await readClipboard(page)).toBe(raw); }); @@ -617,7 +633,7 @@ test.describe("Endpoint screen", () => { headers: { "X-Sample": "value" }, }); const card = newestCard(page); - await card.locator('[data-test="copy-headers"]').click(); + await card.getByTestId("copy-headers").click(); const parsed = await readClipboardJson>(page); expect(parsed["X-Sample"]).toBe("value"); }); @@ -628,7 +644,7 @@ test.describe("Endpoint screen", () => { }) => { await send(request, `${endpointUrl}?a=1&b=2`, { data: "x" }); const card = newestCard(page); - await card.locator('[data-test="copy-query"]').click(); + await card.getByTestId("copy-query").click(); expect(await readClipboard(page)).toBe("a=1&b=2"); }); @@ -645,7 +661,7 @@ test.describe("Endpoint screen", () => { const card = cardFor(page, uuid); await expect(card).toBeAttached(); - await card.locator('[data-test="copy-request-har"]').click(); + await card.getByTestId("copy-request-har").click(); const har = await readClipboardJson(page); expect(har.creator.name).toBe("httphq"); @@ -678,7 +694,7 @@ test.describe("Endpoint screen", () => { }) => { await send(request, endpointUrl, { data: "x" }); const card = newestCard(page); - await card.locator('[data-test="copy-request-har"]').click(); + await card.getByTestId("copy-request-har").click(); const har = await readClipboardJson(page); expect(har.entries[0]).not.toHaveProperty("response"); @@ -689,7 +705,7 @@ test.describe("Endpoint screen", () => { test("a bodyless request omits postData", async ({ page, request }) => { await send(request, endpointUrl, { method: "GET" }); const card = newestCard(page); - await card.locator('[data-test="copy-request-har"]').click(); + await card.getByTestId("copy-request-har").click(); const har = await readClipboardJson(page); expect(har.entries[0].request).not.toHaveProperty("postData"); @@ -702,10 +718,10 @@ test.describe("Endpoint screen", () => { }) => { await send(request, endpointUrl, { data: "x" }); const card = newestCard(page); - await card.locator('[data-test="copy-request-har"]').click(); - await expect( - card.locator('[data-test="copy-request-har-label"]'), - ).toContainText("Copied!"); + await card.getByTestId("copy-request-har").click(); + await expect(card.getByTestId("copy-request-har-label")).toContainText( + "Copied!", + ); }); test("copy-all writes every visible request, newest first", async ({ @@ -713,11 +729,11 @@ test.describe("Endpoint screen", () => { request, }) => { await send(request, endpointUrl, { data: "first" }); - await expect(page.locator('[data-test="request"]')).toHaveCount(1); + await expect(requestCards(page)).toHaveCount(1); await send(request, endpointUrl, { data: "second" }); - await expect(page.locator('[data-test="request"]')).toHaveCount(2); + await expect(requestCards(page)).toHaveCount(2); - await page.locator('[data-test="copy-all-har"]').click(); + await page.getByTestId("copy-all-har").click(); const har = await readClipboardJson(page); expect(har.entries).toHaveLength(2); @@ -735,12 +751,12 @@ test.describe("Endpoint screen", () => { await send(request, endpointUrl, { method: "PUT", data: "u" }); await expect(resultCount(page)).toContainText("2 results"); - await page.locator('[data-test="method-filter"]').selectOption("PUT"); - await expect( - page.locator('[data-test="copy-all-har-label"]'), - ).toContainText("Copy shown (1)"); + await page.getByTestId("method-filter").selectOption("PUT"); + await expect(page.getByTestId("copy-all-har-label")).toContainText( + "Copy shown (1)", + ); - await page.locator('[data-test="copy-all-har"]').click(); + await page.getByTestId("copy-all-har").click(); const har = await readClipboardJson(page); expect(har.entries).toHaveLength(1); @@ -752,34 +768,34 @@ test.describe("Endpoint screen", () => { test("copy-all is disabled while nothing has been captured", async ({ page, }) => { - await expect(page.locator('[data-test="copy-all-har"]')).toBeDisabled(); + await expect(page.getByTestId("copy-all-har")).toBeDisabled(); }); }); test.describe("Connecting an agent", () => { test("the panel is collapsed until it is opened", async ({ page }) => { - await expect(page.locator('[data-test="agent-prompt"]')).toBeHidden(); + await expect(page.getByTestId("agent-prompt")).toBeHidden(); - await page.locator('[data-test="agent-toggle"]').click(); + await page.getByTestId("agent-toggle").click(); - await expect(page.locator('[data-test="agent-prompt"]')).toBeVisible(); + await expect(page.getByTestId("agent-prompt")).toBeVisible(); }); // Both panels sit above the stream. Opening one to read a prompt must not // push the other open on top of it. test("opening it leaves the send panel closed", async ({ page }) => { - await page.locator('[data-test="agent-toggle"]').click(); + await page.getByTestId("agent-toggle").click(); - await expect(page.locator('[data-test="agent-prompt"]')).toBeVisible(); - await expect(page.locator('[data-test="send-submit"]')).toBeHidden(); + await expect(page.getByTestId("agent-prompt")).toBeVisible(); + await expect(page.getByTestId("send-submit")).toBeHidden(); }); // The prompt is built from the request, so it has to name the host the // page was actually served from rather than a hardcoded one. test("the prompt carries this endpoint's own URLs", async ({ page }) => { - await page.locator('[data-test="agent-toggle"]').click(); + await page.getByTestId("agent-toggle").click(); - const prompt = page.locator('[data-test="agent-prompt"]'); + const prompt = page.getByTestId("agent-prompt"); await expect(prompt).toContainText(endpointUrl); await expect(prompt).toContainText( `/api/endpoints/${endpointId}/requests`, @@ -789,9 +805,9 @@ test.describe("Endpoint screen", () => { test("the prompt states the cursor loop and the poll interval", async ({ page, }) => { - await page.locator('[data-test="agent-toggle"]').click(); + await page.getByTestId("agent-toggle").click(); - const prompt = page.locator('[data-test="agent-prompt"]'); + const prompt = page.getByTestId("agent-prompt"); await expect(prompt).toContainText("?since="); await expect(prompt).toContainText("hasMore"); await expect(prompt).toContainText("2 seconds"); @@ -802,12 +818,10 @@ test.describe("Endpoint screen", () => { test("the copy button writes the prompt with no stray whitespace", async ({ page, }) => { - await page.locator('[data-test="agent-toggle"]').click(); - const shown = await page - .locator('[data-test="agent-prompt"]') - .textContent(); + await page.getByTestId("agent-toggle").click(); + const shown = await page.getByTestId("agent-prompt").textContent(); - await page.locator('[data-test="copy-agent-prompt"]').click(); + await page.getByTestId("copy-agent-prompt").click(); const copied = await readClipboard(page); expect(copied).toBe(shown); @@ -816,73 +830,85 @@ test.describe("Endpoint screen", () => { }); test("the copy button label flips to Copied!", async ({ page }) => { - await page.locator('[data-test="agent-toggle"]').click(); - await page.locator('[data-test="copy-agent-prompt"]').click(); + await page.getByTestId("agent-toggle").click(); + await page.getByTestId("copy-agent-prompt").click(); - await expect( - page.locator('[data-test="copy-agent-prompt-label"]'), - ).toContainText("Copied!"); + await expect(page.getByTestId("copy-agent-prompt-label")).toContainText( + "Copied!", + ); }); }); test.describe("Sending a test request", () => { test("the panel is collapsed until it is opened", async ({ page }) => { - await expect(page.locator('[data-test="send-submit"]')).toBeHidden(); + await expect(page.getByTestId("send-submit")).toBeHidden(); - await page.locator('[data-test="send-toggle"]').click(); + await page.getByTestId("send-toggle").click(); - await expect(page.locator('[data-test="send-submit"]')).toBeVisible(); + await expect(page.getByTestId("send-submit")).toBeVisible(); }); test("submitting the panel produces a captured request", async ({ page, }) => { - await page.locator('[data-test="send-toggle"]').click(); - await page.locator('[data-test="send-method"]').selectOption("PUT"); + await page.getByTestId("send-toggle").click(); + await page.getByTestId("send-method").selectOption("PUT"); await page - .locator('[data-test="send-headers"]') + .getByTestId("send-headers") .fill("X-Source: panel\nContent-Type: application/json"); - await page.locator('[data-test="send-body"]').fill('{"hello":"panel"}'); - await page.locator('[data-test="send-submit"]').click(); + await page.getByTestId("send-body").fill('{"hello":"panel"}'); + await page.getByTestId("send-submit").click(); const card = newestCard(page); await expect(card).toContainText("PUT"); - await expect(card.locator('[data-test="request-headers"]')).toContainText( + await expect(card.getByTestId("request-headers")).toContainText( "X-Source", ); - await expect(card.locator('[data-test="request-body"]')).toContainText( - "panel", - ); + await expect(card.getByTestId("request-body")).toContainText("panel"); }); test("the path and query field reaches the capture", async ({ page }) => { - await page.locator('[data-test="send-toggle"]').click(); + await page.getByTestId("send-toggle").click(); await page - .locator('[data-test="send-path"]') + .getByTestId("send-path") .fill("/orders/8821?event=charge.succeeded"); - await page.locator('[data-test="send-submit"]').click(); + await page.getByTestId("send-submit").click(); const card = newestCard(page); - await expect(card.locator('[data-test="request-path"]')).toContainText( + await expect(card.getByTestId("request-path")).toContainText( `${endpointPath}/orders/8821?event=charge.succeeded`, ); }); + // "orders/8821" and "/orders/8821" address the same capture path, so a user + // who leaves the slash off does not quietly send somewhere else. + test("a sub-path with no leading slash reaches the same place", async ({ + page, + }) => { + await page.getByTestId("send-toggle").click(); + await page.getByTestId("send-path").fill("orders/8821"); + await page.getByTestId("send-submit").click(); + + await expect(newestCard(page).getByTestId("request-path")).toContainText( + `${endpointPath}/orders/8821`, + ); + }); + // Silently discarding a line that is one typo away from an Authorization // header, and then reporting success, sends the user chasing an auth bug // that does not exist. test("a malformed header line is reported instead of dropped", async ({ page, }) => { - await page.locator('[data-test="send-toggle"]').click(); + await page.getByTestId("send-toggle").click(); await page - .locator('[data-test="send-headers"]') + .getByTestId("send-headers") .fill("Authorization Bearer sk_test_123"); - await page.locator('[data-test="send-submit"]').click(); - await expect(page.locator('[data-test="send-status"]')).toContainText( + await page.getByTestId("send-submit").click(); + await expect(page.getByTestId("send-status")).toContainText( "is not a header", ); - await expect(page.locator('[data-test="request"]')).toHaveCount(0); + await expect(requestCards(page)).toHaveCount(0); }); }); diff --git a/e2e/tests/home-screen.spec.ts b/e2e/tests/home-screen.spec.ts index fbad809..9563c92 100644 --- a/e2e/tests/home-screen.spec.ts +++ b/e2e/tests/home-screen.spec.ts @@ -27,17 +27,15 @@ test.describe("Home screen", () => { test.describe("Creating an endpoint", () => { test("the create button is visible", async ({ page }) => { - await expect( - page.locator('button[data-test="create-endpoint"]'), - ).toBeVisible(); + await expect(page.getByTestId("create-endpoint")).toBeVisible(); }); test("the create button lands on a working endpoint screen", async ({ page, }) => { - await page.locator('button[data-test="create-endpoint"]').click(); + await page.getByTestId("create-endpoint").click(); await expect(page).toHaveURL(/\/[a-z0-9-]+$/); - await expect(page.locator('[data-test="endpoint-url"]')).toBeVisible(); + await expect(page.getByTestId("endpoint-url")).toBeVisible(); }); // The facts a visitor needs before pointing live traffic at a public URL, @@ -56,7 +54,7 @@ test.describe("Home screen", () => { test.describe("Supporting sections", () => { test("the use cases section is visible", async ({ page }) => { - const section = page.locator('[data-test="use-cases"]'); + const section = page.getByTestId("use-cases"); await expect(section).toBeVisible(); await expect(section).toContainText("Test webhooks"); await expect(section).toContainText("Inspect payloads"); @@ -64,7 +62,7 @@ test.describe("Home screen", () => { }); test("the example capture shows a rendered request", async ({ page }) => { - const example = page.locator('[data-test="example-capture"]'); + const example = page.getByTestId("example-capture"); await expect(example).toBeVisible(); await expect(example).toContainText("POST"); await expect(example).toContainText("content-type"); diff --git a/e2e/tests/page-helpers.spec.ts b/e2e/tests/page-helpers.spec.ts index f24a288..bc412cb 100644 --- a/e2e/tests/page-helpers.spec.ts +++ b/e2e/tests/page-helpers.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from "@playwright/test"; +import { test, expect, type Page } from "@playwright/test"; import { loadPageScripts } from "./support/harness"; /** @@ -54,7 +54,7 @@ test.describe("Page helpers", () => { // measures against the moment it is called. Every offset is a whole number // of its unit: the few milliseconds that pass inside the call would round // a value sitting exactly on .5 to the neighbouring unit instead. - const agoFor = (page: Parameters[0], ms: number) => + const agoFor = (page: Page, ms: number) => page.evaluate( (offset) => window.formatTimeAgo(new Date(Date.now() + offset)), ms, @@ -105,7 +105,7 @@ test.describe("Page helpers", () => { */ test.describe("Header lookup", () => { const lookup = ( - page: Parameters[0], + page: Page, headers: Record | null, name: string, ) => @@ -174,7 +174,7 @@ test.describe("Page helpers", () => { * auth failure that was never in their request. */ test.describe("Header lines", () => { - const parse = (page: Parameters[0], text: string) => + const parse = (page: Page, text: string) => page.evaluate((source) => window.parseHeaderLines(source), text); test("splits each line on its first colon", async ({ page }) => { diff --git a/e2e/tests/render-body.spec.ts b/e2e/tests/render-body.spec.ts index 73067cb..4f4eb15 100644 --- a/e2e/tests/render-body.spec.ts +++ b/e2e/tests/render-body.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from "@playwright/test"; +import { test, expect, type Page } from "@playwright/test"; import { loadPageScripts } from "./support/harness"; /** @@ -42,7 +42,7 @@ test.describe("Body renderer", () => { /** Renders a body and returns what it put on the page, unescaped. */ const render = async ( - page: Parameters[0], + page: Page, body: string | null, headers: Record | null = null, ) => diff --git a/e2e/tests/support/harness.ts b/e2e/tests/support/harness.ts index 59a0f0d..25424d0 100644 --- a/e2e/tests/support/harness.ts +++ b/e2e/tests/support/harness.ts @@ -144,7 +144,7 @@ export const listRequests = async ( * site, and a change to the markup lands in one place. */ export const newestBody = (page: Page) => - page.locator('[data-test="request-body"]').first(); + page.getByTestId("request-body").first(); /** The newest capture's body as displayed text, highlighting flattened away. */ export const newestBodyText = (page: Page) => diff --git a/eslint.config.mjs b/eslint.config.mjs index 491ff85..cc1930b 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -31,7 +31,7 @@ export default tseslint.config( // score the tree currently carries, so it holds the line rather than leaving // room to grow into, and a function that gains a branch has to lose one // somewhere. Lower them as hotspots are simplified; raising one is how a - // ratchet stops working. `gocyclo -over 5` in CI is the Go counterpart to the + // ratchet stops working. `gocyclo -over 4` in CI is the Go counterpart to the // first of these. // // The modified variant scores a whole switch as one decision rather than one @@ -40,7 +40,7 @@ export default tseslint.config( // repositories rather than differing in a way that looks deliberate. { rules: { - complexity: ["error", { max: 6, variant: "modified" }], + complexity: ["error", { max: 5, variant: "modified" }], "max-params": ["error", 3], "max-depth": ["error", 2], }, diff --git a/go.mod b/go.mod index d146790..c78d75d 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ go 1.26.6 require ( github.com/atrox/haikunatorgo/v2 v2.0.1 + github.com/fasthttp/websocket v1.5.12 github.com/glebarez/sqlite v1.11.0 github.com/gofiber/contrib/v3/socketio v1.1.4 github.com/gofiber/contrib/v3/websocket v1.1.4 @@ -30,7 +31,6 @@ require ( github.com/andybalholm/brotli v1.2.1 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/fasthttp/websocket v1.5.12 // indirect github.com/glebarez/go-sqlite v1.21.2 // indirect github.com/go-sql-driver/mysql v1.8.1 // indirect github.com/gofiber/schema v1.7.1 // indirect diff --git a/go.sum b/go.sum index a717589..5ce7c21 100644 --- a/go.sum +++ b/go.sum @@ -31,8 +31,6 @@ github.com/gofiber/template v1.8.3 h1:hzHdvMwMo/T2kouz2pPCA0zGiLCeMnoGsQZBTSYgZx github.com/gofiber/template v1.8.3/go.mod h1:bs/2n0pSNPOkRa5VJ8zTIvedcI/lEYxzV3+YPXdBvq8= github.com/gofiber/template/html/v2 v2.1.3 h1:n1LYBtmr9C0V/k/3qBblXyMxV5B0o/gpb6dFLp8ea+o= github.com/gofiber/template/html/v2 v2.1.3/go.mod h1:U5Fxgc5KpyujU9OqKzy6Kn6Qup6Tm7zdsISR+VpnHRE= -github.com/gofiber/utils v1.1.0 h1:vdEBpn7AzIUJRhe+CiTOJdUcTg4Q9RK+pEa0KPbLdrM= -github.com/gofiber/utils v1.1.0/go.mod h1:poZpsnhBykfnY1Mc0KeEa6mSHrS3dV0+oBWyeQmb2e0= github.com/gofiber/utils v1.2.0 h1:NCaqd+Efg3khhN++eeUUTyBz+byIxAsmIjpl8kKOMIc= github.com/gofiber/utils v1.2.0/go.mod h1:poZpsnhBykfnY1Mc0KeEa6mSHrS3dV0+oBWyeQmb2e0= github.com/gofiber/utils/v2 v2.0.4 h1:WwAxUA7L4MW2DjdEHF234lfqvBqd2vYYuBtA9TJq2ec= @@ -100,23 +98,14 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= -golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= -golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= -golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= -golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= -golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/public/endpoint.js b/public/endpoint.js index 78ac05e..d21870f 100644 --- a/public/endpoint.js +++ b/public/endpoint.js @@ -31,6 +31,9 @@ // How long a copy button reads "Copied!" before returning to its label. const COPIED_MS = 1_500; + // How long the status line holds a successful send before it clears. + const SENT_MS = 2_000; + function transformRequest(r) { return { ...r, createdAt: new Date(r.createdAt) }; } @@ -348,6 +351,14 @@ return this._copyAndFlash(text, key, "Copied to clipboard"); }, + // The label a copy button carries: its own while idle, the confirmation + // while it is the one that last wrote to the clipboard. Several buttons + // share this component instance, so the key is what tells them apart, and + // it has to be the same key the click handler passes to copy(). + copyLabel(key, idle) { + return this.copiedKey === key ? "Copied!" : idle; + }, + copyHar(requests, key) { return this._copyAndFlash( window.buildHarExport(requests), @@ -382,26 +393,39 @@ return `/to/${this.store.endpointId}${path ? "/" + path : ""}`; }, + // The header field, parsed. A malformed line is reported and nothing is + // returned, because sending a request that is missing a header the user + // wrote would send them chasing a failure that was never in it. + _sendHeaders() { + const parsed = window.parseHeaderLines(this.sendForm.headers); + const [first] = parsed.invalid; + if (!first) return parsed.headers; + this._reportSend( + true, + `Line ${first.line} is not a header: "${first.text}". Use Key: Value.`, + ); + return null; + }, + + // The compose form as a request. An empty body field sends no body at + // all: fetch refuses a body on GET or HEAD, and an empty string is still + // a body. + _sendRequest(headers) { + return fetch(this._sendTarget(), { + method: this.sendForm.method, + headers, + body: this.sendForm.body || undefined, + }); + }, + async sendCustom() { if (!this.store.endpointId) return; - const parsed = window.parseHeaderLines(this.sendForm.headers); - if (parsed.invalid.length) { - const [first] = parsed.invalid; - this._reportSend( - true, - `Line ${first.line} is not a header: "${first.text}". Use Key: Value.`, - ); - return; - } - const target = this._sendTarget(); + const headers = this._sendHeaders(); + if (!headers) return; try { this.sendFailed = false; this.sendStatus = "Sending…"; - const res = await fetch(target, { - method: this.sendForm.method, - headers: parsed.headers, - body: this.sendForm.body || undefined, - }); + const res = await this._sendRequest(headers); if (!res.ok) { this._reportSend( true, @@ -410,7 +434,7 @@ return; } this._reportSend(false, `Sent ${this.sendForm.method}`); - setTimeout(() => (this.sendStatus = ""), 2000); + setTimeout(() => (this.sendStatus = ""), SENT_MS); } catch (err) { // The browser refuses some combinations outright, e.g. a body on GET. // Report its reason rather than swallowing it, and keep it on screen. diff --git a/src/api.go b/src/api.go index 9143d50..063ac2b 100644 --- a/src/api.go +++ b/src/api.go @@ -45,6 +45,24 @@ func newestCreatedAt(requests []database.Request) time.Time { return newest } +// nextCursor is what a caller echoes back on its next call. +// +// It is the newest capture in the page, or the moment the query ran when the +// page came back empty, so a poller that starts before any traffic still has +// something to advance from rather than re-asking for the same empty window +// forever. It never moves backwards: a cursor already ahead of both survives +// the round trip, or the caller is handed captures it has already processed. +func nextCursor(requests []database.Request, queryStart, since time.Time) time.Time { + cursor := queryStart + if len(requests) > 0 { + cursor = newestCreatedAt(requests) + } + if cursor.Before(since) { + return since + } + return cursor +} + // handleListRequests returns a window of an endpoint's captures, narrowed by // the search and by an optional `since` cursor. `total` is what the endpoint // holds regardless of search, cursor or window, so the page can say what a @@ -78,21 +96,10 @@ func handleListRequests(c fiber.Ctx) error { requests := database.GetRequestsForEndpointID( c.Context(), endpointID, c.Query("search"), since, requestPageSize) - // An empty page still advances the cursor to the start of this query, so a - // poller that begins before any traffic arrives does not re-ask for the - // same empty window forever. - cursor := queryStart - if len(requests) > 0 { - cursor = newestCreatedAt(requests) - } - if cursor.Before(since) { - cursor = since - } - return c.JSON(fiber.Map{ "requests": requests, "total": database.CountRequestsForEndpointID(c.Context(), endpointID), - "cursor": cursor.Format(time.RFC3339Nano), + "cursor": nextCursor(requests, queryStart, since).Format(time.RFC3339Nano), // A full page means a burst is still draining, so the caller should // come straight back rather than sleeping through the backlog. "hasMore": len(requests) == requestPageSize, diff --git a/src/capture.go b/src/capture.go index c35312c..919a8c0 100644 --- a/src/capture.go +++ b/src/capture.go @@ -57,15 +57,22 @@ func curlSpoofRequested(headers map[string][]string) bool { return len(spoof) > 0 && spoof[0] == "true" } +// spoofCurl rewrites a browser's headers so the capture reads as a command-line +// client's: the additions only a browser makes are dropped, and the values curl +// would have sent replace the ones it shares. +func spoofCurl(headers map[string][]string) { + for _, name := range browserOnlyHeaders { + delete(headers, name) + } + maps.Copy(headers, spoofedCurlHeaders) +} + // captureHeaders reduces the request's headers to what the user should see: the // browser's own additions removed when curl is spoofed, then every // infrastructure header stripped. func captureHeaders(headers map[string][]string) map[string][]string { if curlSpoofRequested(headers) { - for _, name := range browserOnlyHeaders { - delete(headers, name) - } - maps.Copy(headers, spoofedCurlHeaders) + spoofCurl(headers) } for name := range headers { if omitHeader(name) { diff --git a/src/capture_test.go b/src/capture_test.go index 58d05bd..f9168e2 100644 --- a/src/capture_test.go +++ b/src/capture_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "gorm.io/datatypes" "httphq/src/database" ) @@ -166,7 +167,6 @@ func TestCaptureRequest(t *testing.T) { require.Error(t, err) assert.Empty(t, capturedRequests(t, id, "")) }) - } func TestCaptureHeaders(t *testing.T) { @@ -314,6 +314,26 @@ func TestBroadcastCapture(t *testing.T) { assert.Equal(t, captured.Body, payload.Body) }) + // Fan-out is the one place a capture is re-serialised after it was stored, + // so a row whose headers are not valid JSON fails here and nowhere else. + // Nothing above this reports it, and half a payload would reach the page as + // a capture it cannot parse, so the dispatch is dropped and logged. + t.Run("a capture that will not marshal is logged rather than dispatched", func(t *testing.T) { + emits := recordSocketEmits(t) + logs := captureLogs(t) + registry := newSocketRegistry() + registry.add("watched", "socket-1") + + broadcastCapture(t.Context(), registry, database.Request{ + UUID: "capture-uuid", + EndpointID: "watched", + Headers: datatypes.JSON("not json"), + }) + + assert.Empty(t, emits) + assert.Contains(t, logs.String(), "websocket payload marshal failed") + }) + // The endpoint ID is the only isolation between two users of one instance, // so a page open on another endpoint must never be pushed this traffic. t.Run("a socket watching another endpoint is left alone", func(t *testing.T) { diff --git a/src/database/database_test.go b/src/database/database_test.go index 67bd2a0..9ba3753 100644 --- a/src/database/database_test.go +++ b/src/database/database_test.go @@ -272,6 +272,27 @@ func uuidsOf(requests []database.Request) []string { return uuids } +// drainWithCursor reads an endpoint's whole stream the way a poller does: a page +// at a time, advancing the cursor to the last capture it was handed, until a +// page comes back empty. Returns the UUIDs it was given, in order, and how many +// calls it took. +func drainWithCursor(t *testing.T, endpointID string, cursor time.Time, limit int) ([]string, int) { + t.Helper() + + var seen []string + calls := 0 + for { + page := database.GetRequestsForEndpointID(t.Context(), endpointID, "", cursor, limit) + if len(page) == 0 { + return seen, calls + } + calls++ + require.Less(t, calls, 10, "the drain is not converging") + seen = append(seen, uuidsOf(page)...) + cursor = page[len(page)-1].CreatedAt + } +} + func TestGetRequestsForEndpointIDSince(t *testing.T) { // A fixed base keeps the ordering assertions readable and keeps the rows // clear of time.Now(), so nothing depends on how fast the test runs. @@ -356,32 +377,17 @@ func TestGetRequestsForEndpointIDSince(t *testing.T) { burst = 25 limit = 10 ) + // The expectation is built from the same names that were stored, in the + // order they were stored, so the assertion cannot drift from the fixture. + want := make([]string, 0, burst) for i := range burst { - storeRequest(t.Context(), fmt.Sprintf("capture-%02d", i), - withCreatedAt(at(time.Duration(i)*time.Second))) + uuid := fmt.Sprintf("capture-%02d", i) + want = append(want, uuid) + storeRequest(t.Context(), uuid, withCreatedAt(at(time.Duration(i)*time.Second))) } - var ( - seen []string - cursor = base.Add(-time.Second) - calls int - ) - for { - page := database.GetRequestsForEndpointID(t.Context(), "test-id", "", cursor, limit) - if len(page) == 0 { - break - } - calls++ - require.Less(t, calls, 10, "the drain is not converging") - - seen = append(seen, uuidsOf(page)...) - cursor = page[len(page)-1].CreatedAt - } + seen, calls := drainWithCursor(t, "test-id", base.Add(-time.Second), limit) - want := make([]string, burst) - for i := range want { - want[i] = fmt.Sprintf("capture-%02d", i) - } assert.Equal(t, want, seen, "every capture exactly once, in order") assert.Equal(t, 3, calls, "25 captures at 10 a page is three pages") }) diff --git a/src/harness_test.go b/src/harness_test.go index 3d481a3..7ed50ad 100644 --- a/src/harness_test.go +++ b/src/harness_test.go @@ -1,8 +1,10 @@ package main import ( + "bytes" "encoding/json" "io" + "log/slog" "net" "net/http" "net/http/httptest" @@ -79,6 +81,24 @@ func withPlatform(t *testing.T, name string) { t.Cleanup(func() { currentPlatform = previous }) } +// captureLogs points the process logger at a buffer for one test and hands the +// buffer back. Several subjects report through the logs and nowhere else, so +// reading them is the only way to assert what happened. The logger is +// process-wide, so it is restored before the next test reads it. +// +// Debug is recorded as well as info, because a line demoted to debug is still a +// line a test may be asserting on. +func captureLogs(t *testing.T) *bytes.Buffer { + t.Helper() + var out bytes.Buffer + previous := slog.Default() + slog.SetDefault(slog.New(slog.NewJSONHandler(&out, &slog.HandlerOptions{ + Level: slog.LevelDebug, + }))) + t.Cleanup(func() { slog.SetDefault(previous) }) + return &out +} + // contextWith builds a request context from peerIP carrying the given headers, // so a test can exercise the header-reading helpers without a live server. func contextWith(t *testing.T, app *fiber.App, peerIP string, headers map[string]string) fiber.Ctx { @@ -110,6 +130,20 @@ type testRequest struct { repeated map[string][]string } +// applyHeaders writes a spec's headers onto a request. The two maps are walked +// separately because they say different things: one header per name, and one +// name carrying several. +func applyHeaders(request *http.Request, spec testRequest) { + for name, value := range spec.headers { + request.Header.Set(name, value) + } + for name, values := range spec.repeated { + for _, value := range values { + request.Header.Add(name, value) + } + } +} + func do(t *testing.T, spec testRequest) *http.Response { t.Helper() @@ -118,14 +152,7 @@ func do(t *testing.T, spec testRequest) *http.Response { body = strings.NewReader(spec.body) } req := httptest.NewRequest(spec.method, spec.path, body) - for name, value := range spec.headers { - req.Header.Set(name, value) - } - for name, values := range spec.repeated { - for _, value := range values { - req.Header.Add(name, value) - } - } + applyHeaders(req, spec) response, err := application(t).Test(req) require.NoError(t, err) diff --git a/src/platform_test.go b/src/platform_test.go index f0363b2..e7855e1 100644 --- a/src/platform_test.go +++ b/src/platform_test.go @@ -23,11 +23,13 @@ func schemeFor(t *testing.T, app *fiber.App, peerIP, forwardedProto string) stri // proxyTrustApp mirrors the production Fiber config for a request that arrives // with a PLATFORM configured (TrustProxy enabled): the app trusts the fronting -// proxy on the ranges from trustedProxyConfig. +// proxy on the ranges from trustedProxyConfig, and reads the peer it reports +// from the same forwarded header the application does. func proxyTrustApp() *fiber.App { return fiber.New(fiber.Config{ TrustProxy: true, TrustProxyConfig: trustedProxyConfig(), + ProxyHeader: fiber.HeaderXForwardedFor, }) } @@ -51,7 +53,11 @@ func TestResolvePlatform(t *testing.T) { t.Run("the name is trimmed and matched case-insensitively", func(t *testing.T) { assert.Equal(t, platforms["cloudflare"], resolvePlatform(" CloudFlare ")) }) +} +// The table itself, rather than the lookup into it. A platform entry that +// disagrees with the header it names is wrong however it was resolved. +func TestPlatforms(t *testing.T) { t.Run("list-valued platforms are the ones reading X-Forwarded-For", func(t *testing.T) { for name, config := range platforms { if config.ipHeader == fiber.HeaderXForwardedFor { @@ -112,6 +118,20 @@ func TestResolveClientIP(t *testing.T) { } }) + // The rate limiter buckets on whatever this returns, so a value that is not + // an address must not become a bucket of its own. Behind a trusted proxy + // there is no second opinion to fall back to: Fiber reads the peer from the + // same forwarded header, so a garbage entry is all either source has. + t.Run("an address none of the sources can parse yields nothing", func(t *testing.T) { + withPlatform(t, "proxy") + + c := contextWith(t, proxyTrustApp(), "10.0.0.1", map[string]string{ + fiber.HeaderXForwardedFor: "not-an-ip", + }) + + assert.Empty(t, resolveClientIP(c)) + }) + t.Run("IPv6 survives round-tripping", func(t *testing.T) { withPlatform(t, "fly") diff --git a/src/requestlog_test.go b/src/requestlog_test.go index 1e67153..1bcc544 100644 --- a/src/requestlog_test.go +++ b/src/requestlog_test.go @@ -1,7 +1,6 @@ package main import ( - "bytes" "encoding/json" "errors" "log/slog" @@ -14,22 +13,15 @@ import ( "github.com/stretchr/testify/require" ) -// accessLogFor drives one request with the process logger pointed at a buffer -// and returns the access-log line it produced. The logger is process-wide, so -// it is restored before the next test reads it. +// accessLogFor drives one request and returns the access-log line it produced. // -// The handler is a plain JSON handler rather than the one Init installs, so -// levels appear as slog spells them. Rendering them lower-case is the logging -// package's job and is covered there. +// The lines are written through a plain JSON handler rather than the one Init +// installs, so levels appear as slog spells them. Rendering them lower-case is +// the logging package's job and is covered there. func accessLogFor(t *testing.T, spec testRequest) map[string]any { t.Helper() - var out bytes.Buffer - previous := slog.Default() - slog.SetDefault(slog.New(slog.NewJSONHandler(&out, &slog.HandlerOptions{ - Level: slog.LevelDebug, - }))) - t.Cleanup(func() { slog.SetDefault(previous) }) + out := captureLogs(t) do(t, spec) diff --git a/src/security_test.go b/src/security_test.go index b3cfaa4..b03ceec 100644 --- a/src/security_test.go +++ b/src/security_test.go @@ -1,6 +1,7 @@ package main import ( + "slices" "strings" "testing" @@ -8,6 +9,32 @@ import ( "github.com/stretchr/testify/assert" ) +// policyDirectives reads a policy the way CSP itself does: each directive by +// name, carrying the sources it admits. A directive the policy never states is +// absent rather than empty, which the assertions below then report as a missing +// source rather than as a pass. +func policyDirectives(policy string) map[string][]string { + directives := map[string][]string{} + for _, candidate := range strings.Split(policy, "; ") { + name, value, _ := strings.Cut(candidate, " ") + directives[name] = strings.Fields(value) + } + return directives +} + +// directivesCarrying names every directive that admits a source, so a test can +// assert the whole of where one reaches. A directive that quietly gains it then +// fails rather than passing unmentioned. +func directivesCarrying(policy, source string) []string { + var carrying []string + for name, sources := range policyDirectives(policy) { + if slices.Contains(sources, source) { + carrying = append(carrying, name) + } + } + return carrying +} + // The headers are set by middleware rather than per route, so the assertion // that matters is that no surface can be reached without them. The paths below // are one of each response shape the app produces: a rendered page, a file from @@ -48,14 +75,12 @@ func TestContentSecurityPolicy(t *testing.T) { assert.Contains(t, contentSecurityPolicy(true), designToolingOrigin) }) + // The tooling injects a script and opens a socket back to itself, so those + // two directives are the whole of what it needs. A third would be a wider + // grant than the tooling asked for. t.Run("the tooling origin reaches only the directives that need it", func(t *testing.T) { - for _, directive := range strings.Split(contentSecurityPolicy(true), "; ") { - name, _, _ := strings.Cut(directive, " ") - if strings.Contains(directive, designToolingOrigin) { - assert.Containsf(t, []string{"script-src", "connect-src"}, name, - "%s should not carry the design tooling origin", name) - } - } + assert.ElementsMatch(t, []string{"script-src", "connect-src"}, + directivesCarrying(contentSecurityPolicy(true), designToolingOrigin)) }) t.Run("the baseline directives hold in both environments", func(t *testing.T) { @@ -74,7 +99,7 @@ func TestContentSecurityPolicy(t *testing.T) { // the internet, from a page that renders bodies a stranger sent. t.Run("the socket is admitted by self rather than by every websocket host", func(t *testing.T) { for _, policy := range []string{contentSecurityPolicy(false), contentSecurityPolicy(true)} { - sources := directiveSources(policy, "connect-src") + sources := policyDirectives(policy)["connect-src"] assert.Contains(t, sources, "'self'") assert.NotContains(t, sources, "ws:") assert.NotContains(t, sources, "wss:") @@ -82,16 +107,3 @@ func TestContentSecurityPolicy(t *testing.T) { } }) } - -// The sources of one directive, split the way CSP reads them. Returns nil when -// the policy has no such directive, which the assertions above then report as -// the missing source rather than as an empty pass. -func directiveSources(policy, directive string) []string { - for _, candidate := range strings.Split(policy, "; ") { - name, value, _ := strings.Cut(candidate, " ") - if name == directive { - return strings.Fields(value) - } - } - return nil -} diff --git a/src/sockets_test.go b/src/sockets_test.go index e6983e5..ed54ef5 100644 --- a/src/sockets_test.go +++ b/src/sockets_test.go @@ -1,12 +1,79 @@ package main import ( + "encoding/json" + "net" "net/http" "testing" + "time" + "github.com/fasthttp/websocket" + "github.com/gofiber/fiber/v3" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "httphq/src/database" ) +// settleTimeout bounds the wait for anything a connection does on a goroutine of +// its own: registration on connect, a pushed capture, the drop on close. +// Generous because it covers a scheduling delay rather than any work. +const settleTimeout = 2 * time.Second + +// servedApplication starts an application on a loopback port of its own and +// returns its registry and the host it answers on. The socket routes are the +// one surface Fiber's in-memory test transport cannot reach, because an upgrade +// needs a real connection underneath it. +// +// It builds its own application rather than sharing the package's. socketio +// dispatches lifecycle events through package-level state, so the shared +// application's handlers fire for these connections too; a registry ignores a +// UUID it does not hold, which is what keeps that harmless. +func servedApplication(t *testing.T) (*socketRegistry, string) { + t.Helper() + + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + registry := newSocketRegistry() + application := newApplication(applicationConfig{ + viewsDir: "./views", + publicDir: "../public", + registry: registry, + }) + go func() { + // The banner is Fiber's own stdout output, not a log line, so it would + // land in the middle of the test run's output rather than in a buffer. + _ = application.Listener(listener, fiber.ListenConfig{DisableStartupMessage: true}) + }() + t.Cleanup(func() { _ = application.Shutdown() }) + + return registry, listener.Addr().String() +} + +// openSocket connects to an endpoint's live feed and closes it at the end of the +// test, so a socket left open cannot be counted by the next one. +func openSocket(t *testing.T, host, endpointID string) *websocket.Conn { + t.Helper() + + connection, response, err := websocket.DefaultDialer.Dial( + "ws://"+host+"/ws/"+endpointID, nil) + require.NoError(t, err) + require.NoError(t, response.Body.Close()) + t.Cleanup(func() { _ = connection.Close() }) + return connection +} + +// awaitCount waits for the registry to hold the expected number of sockets. +// Connect and disconnect are both handled on the connection's own goroutine, so +// neither is observable the instant the client returns. +func awaitCount(t *testing.T, registry *socketRegistry, want int) { + t.Helper() + require.Eventually(t, func() bool { return registry.count() == want }, + settleTimeout, 10*time.Millisecond, + "registry should hold %d socket(s), holds %d", want, registry.count()) +} + func TestSocketRegistry(t *testing.T) { t.Run("a new registry holds nothing", func(t *testing.T) { registry := newSocketRegistry() @@ -86,3 +153,69 @@ func TestRegisterWebSockets(t *testing.T) { } }) } + +// The live feed, driven over a real connection. Everything below the upgrade is +// what the registry is for, and none of it can be reached through the in-memory +// transport the rest of the suite uses. +func TestLiveFeed(t *testing.T) { + t.Run("a connected socket is registered against its endpoint", func(t *testing.T) { + registry, host := servedApplication(t) + id := endpointID(t) + + openSocket(t, host, id) + + awaitCount(t, registry, 1) + assert.Len(t, registry.uuidsFor(id), 1) + }) + + // The endpoint ID is the only isolation between two users of one instance, + // so two pages on one process must not land in each other's stream. + t.Run("two endpoints keep separate subscriber lists", func(t *testing.T) { + registry, host := servedApplication(t) + first, second := endpointID(t), endpointID(t) + + openSocket(t, host, first) + openSocket(t, host, second) + + awaitCount(t, registry, 2) + assert.Len(t, registry.uuidsFor(first), 1) + assert.Len(t, registry.uuidsFor(second), 1) + }) + + // The whole point of the feed: a capture that arrives over the wire reaches + // a page watching that endpoint, with no poll and no reload. + t.Run("a capture arrives on the socket watching its endpoint", func(t *testing.T) { + registry, host := servedApplication(t) + id := endpointID(t) + connection := openSocket(t, host, id) + awaitCount(t, registry, 1) + + response, err := http.Post("http://"+host+"/to/"+id, + "text/plain", nil) + require.NoError(t, err) + require.NoError(t, response.Body.Close()) + + require.NoError(t, connection.SetReadDeadline(time.Now().Add(settleTimeout))) + _, payload, err := connection.ReadMessage() + require.NoError(t, err) + + var pushed database.Request + require.NoError(t, json.Unmarshal(payload, &pushed), + "a page parses the pushed payload as a capture") + assert.Equal(t, id, pushed.EndpointID) + assert.Equal(t, http.MethodPost, pushed.Method) + }) + + // A page left open for hours reconnects repeatedly. Without this the + // registry grows a dead entry per reconnect and every capture is marshalled + // for sockets that closed hours ago. + t.Run("a closed socket is dropped from the registry", func(t *testing.T) { + registry, host := servedApplication(t) + connection := openSocket(t, host, endpointID(t)) + awaitCount(t, registry, 1) + + require.NoError(t, connection.Close()) + + awaitCount(t, registry, 0) + }) +} diff --git a/src/views/endpoint.html b/src/views/endpoint.html index 8e53c56..37b73e1 100644 --- a/src/views/endpoint.html +++ b/src/views/endpoint.html @@ -12,7 +12,13 @@

Captured requests for {{.EndpointID}}

-

+

@@ -37,9 +43,7 @@

class="btn btn-secondary shrink-0" > {{template "partials/icons/copy" .}} - Copy @@ -196,7 +200,7 @@

Connect an agent

{{template "partials/icons/copy" .}} Copy prompt @@ -242,7 +246,7 @@

Connect an agent

bare icon, shorter than its neighbour and unreadable. --> Copy shown @@ -425,7 +429,7 @@

Connect an agent

{{template "partials/icons/clipboard" .}} @@ -540,7 +544,7 @@

Query string

@click="copy(request.queryString, request.uuid + ':query')" aria-label="Copy query string" class="btn-inline" - x-text="copiedKey === request.uuid + ':query' ? 'Copied!' : 'Copy'" + x-text="copyLabel(request.uuid + ':query', 'Copy')" > Copy @@ -570,7 +574,7 @@

@click="copy(request.body, request.uuid + ':body')" aria-label="Copy body" class="btn-inline" - x-text="copiedKey === request.uuid + ':body' ? 'Copied!' : 'Copy'" + x-text="copyLabel(request.uuid + ':body', 'Copy')" > Copy