From 20742c9d7eb056cc15971641778d6667c151b8f6 Mon Sep 17 00:00:00 2001 From: Mariusz Banach Date: Wed, 18 Feb 2026 05:34:42 +0100 Subject: [PATCH] MAESTRO: verify rate limit flow e2e --- ...Kit-web-header-analyzer-Phase-09-Polish.md | 2 +- frontend/e2e/rate-limiting.spec.ts | 104 ++++++++++++++++++ 2 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 frontend/e2e/rate-limiting.spec.ts diff --git a/Auto Run Docs/SpecKit-web-header-analyzer-Phase-09-Polish.md b/Auto Run Docs/SpecKit-web-header-analyzer-Phase-09-Polish.md index df6ae90..b5a7be0 100644 --- a/Auto Run Docs/SpecKit-web-header-analyzer-Phase-09-Polish.md +++ b/Auto Run Docs/SpecKit-web-header-analyzer-Phase-09-Polish.md @@ -32,7 +32,7 @@ This phase performs final integration, accessibility audit, responsive testing, - [x] Complete flow works end-to-end: paste headers → configure tests → analyse → view report → export. Notes: replaced Playwright example spec with end-to-end flow test (paste + configure + analyse + report + export), adjusted Playwright webServer ports/CORS for local 3100 runs, ran `npx playwright test e2e/example.spec.ts --project=chromium`. - [x] File drop flow works: drop EML → auto-populate → analyse → report. Notes: extract header block from dropped EML before populating input; updated FileDropZone tests and ran `npx vitest run src/__tests__/FileDropZone.test.tsx`. - [x] Cache flow works: analyse → reload → see cached results → clear cache. Notes: added Playwright cache flow coverage (reload + clear) in e2e/example.spec.ts and ran `npx playwright test e2e/example.spec.ts --project=chromium`. -- [ ] Rate limiting flow works: exceed limit → CAPTCHA modal → solve → retry succeeds +- [x] Rate limiting flow works: exceed limit → CAPTCHA modal → solve → retry succeeds. Notes: added Playwright rate limiting flow spec that mocks 429 response + CAPTCHA verify, asserts retry includes bypass token and succeeds; ran `npx playwright test e2e/rate-limiting.spec.ts --project=chromium`. - [ ] `pytest backend/tests/` passes with ≥80% coverage on new modules - [ ] `npx vitest run --coverage` passes with ≥80% coverage on new components - [ ] `ruff check backend/` — zero errors diff --git a/frontend/e2e/rate-limiting.spec.ts b/frontend/e2e/rate-limiting.spec.ts new file mode 100644 index 0000000..88293ed --- /dev/null +++ b/frontend/e2e/rate-limiting.spec.ts @@ -0,0 +1,104 @@ +import { test, expect } from "@playwright/test"; +import fs from "fs/promises"; +import path from "path"; + +const headersPath = path.resolve(__dirname, "../../backend/tests/fixtures/sample_headers.txt"); +const rateLimit = 3; +const bypassToken = "playwright-bypass-token"; +const captchaChallenge = { + challengeToken: "playwright-challenge-token", + imageBase64: + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFgwJ/l0pNqQAAAABJRU5ErkJggg==", +}; + +test("rate limiting shows captcha and retries successfully", async ({ page }) => { + const headers = await fs.readFile(headersPath, "utf8"); + let analyseCount = 0; + + await page.route("**/api/analyse", async (route) => { + analyseCount += 1; + if (analyseCount === rateLimit + 1) { + await route.fulfill({ + status: 429, + contentType: "application/json", + headers: { + "access-control-allow-origin": "http://localhost:3100", + "access-control-allow-credentials": "true", + "retry-after": "60", + }, + body: JSON.stringify({ + error: "Too many requests", + retryAfter: 60, + captchaChallenge, + }), + }); + return; + } + await route.continue(); + }); + + await page.route("**/api/captcha/verify", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + headers: { + "access-control-allow-origin": "http://localhost:3100", + "access-control-allow-credentials": "true", + }, + body: JSON.stringify({ success: true, bypassToken }), + }); + }); + + await page.goto("http://localhost:3100"); + + const headerInput = page.getByRole("textbox", { name: "Header Input" }); + await headerInput.fill(headers); + + const analyseButton = page.getByRole("button", { name: "Analyse Headers" }); + + const runAnalysis = async () => { + const responsePromise = page.waitForResponse( + (response) => response.url().includes("/api/analyse") && response.status() === 200, + ); + await expect(analyseButton).toBeEnabled({ timeout: 30000 }); + await analyseButton.click(); + await responsePromise; + await expect(analyseButton).toBeEnabled({ timeout: 30000 }); + }; + + for (let attempt = 0; attempt < rateLimit; attempt += 1) { + await runAnalysis(); + } + + const [rateLimitResponse] = await Promise.all([ + page.waitForResponse( + (response) => response.url().includes("/api/analyse") && response.status() === 429, + ), + analyseButton.click(), + ]); + + expect(rateLimitResponse.status()).toBe(429); + + const captchaModal = page.getByTestId("captcha-challenge"); + await expect(captchaModal).toBeVisible(); + + const retryResponsePromise = page.waitForResponse((response) => { + if (!response.url().includes("/api/analyse")) { + return false; + } + if (response.status() !== 200) { + return false; + } + const bypassHeader = response.request().headers()["x-captcha-bypass-token"]; + return bypassHeader === bypassToken; + }); + + await page.getByTestId("captcha-input").fill("12345"); + await page.getByTestId("captcha-submit").click(); + + const retryResponse = await retryResponsePromise; + expect(retryResponse.status()).toBe(200); + + await expect(captchaModal).toBeHidden({ timeout: 30000 }); + await expect(analyseButton).toBeEnabled({ timeout: 30000 }); +});