diff --git a/backend/src/docs/frontend-assets.json b/backend/src/docs/frontend-assets.json index 15cc750cf..07efdafd9 100644 --- a/backend/src/docs/frontend-assets.json +++ b/backend/src/docs/frontend-assets.json @@ -8,6 +8,7 @@ "src/core/card-kind.js", "src/core/collection-loader.js", "src/core/dates.js", + "src/core/guarded-fetch.js", "src/core/hovercard.js", "src/core/html.js", "src/core/icons.js", diff --git a/backend/src/docs/portal.ts b/backend/src/docs/portal.ts index 94cdecc0a..ed54358be 100644 --- a/backend/src/docs/portal.ts +++ b/backend/src/docs/portal.ts @@ -25,6 +25,7 @@ import { RUNTIME_ROOT } from '../runtimePaths'; import type { LambdaEvent, LambdaResponse } from '../types'; import { isAnonymousDocumentReviewAsset, isAnonymousDocumentReviewRequest } from '../documents/reviews'; import { isAnonymousSponsorDocAsset, isAnonymousSponsorDocRequest } from '../routes/sponsorDocs'; +import { isClientErrorReport } from '../routes/clientErrors'; import { isAnonymousSponsorContractAsset } from '../routes/sponsorContracts'; import { isAnonymousNewsletterPreviewAsset, @@ -312,7 +313,7 @@ export async function handlePortal(event: LambdaEvent, client: DynamoDBDocumentC const fe = serveFrontend(event, method, path); if (fe) return { response: fe, authorized: false }; } - if (isAnonymousDocumentReviewRequest(method, path) || isAnonymousSponsorDocRequest(method, path) || isAnonymousNewsletterPreviewRequest(method, path)) { + if (isAnonymousDocumentReviewRequest(method, path) || isAnonymousSponsorDocRequest(method, path) || isClientErrorReport(method, path) || isAnonymousNewsletterPreviewRequest(method, path)) { return { authorized: false }; } // Existing non-browser bearer clients continue through the router's own diff --git a/backend/src/documents/reviews.ts b/backend/src/documents/reviews.ts index 41e3edd09..27876be0a 100644 --- a/backend/src/documents/reviews.ts +++ b/backend/src/documents/reviews.ts @@ -16,7 +16,7 @@ export function isAnonymousDocumentReviewRequest(method: string, path: string): } export function isAnonymousDocumentReviewAsset(method: string, path: string): boolean { return method === 'GET' && (/^\/document-review\/[^/]+\/?$/.test(path) - || ['/src/document-review-page.js', '/src/surfaces/document-reviews.js'].includes(path)); + || ['/src/document-review-page.js', '/src/core/guarded-fetch.js', '/src/surfaces/document-reviews.js'].includes(path)); } // The document loader is provided by the run-document routes. All callers must // check existence here; a deleted document never remains accessible by a link. diff --git a/backend/src/newsletter/markdownPreview.ts b/backend/src/newsletter/markdownPreview.ts index bba630b0b..d66938fc1 100644 --- a/backend/src/newsletter/markdownPreview.ts +++ b/backend/src/newsletter/markdownPreview.ts @@ -80,6 +80,7 @@ export function isAnonymousNewsletterPreviewAsset(method: string, path: string): if (/^\/newsletter-issues\/[^/]+\/preview\/?$/.test(path)) return true; return ( path === '/src/newsletter-preview-page.js' + || path === '/src/core/guarded-fetch.js' || path === '/src/surfaces/operations/newsletter-preview.js' || path === '/src/styles/newsletter-preview.css' || path === '/src/styles/sponsor-docs.css' diff --git a/backend/src/router.ts b/backend/src/router.ts index 16cdc6aca..a60540627 100644 --- a/backend/src/router.ts +++ b/backend/src/router.ts @@ -62,6 +62,7 @@ import { handleKnowledgePublisher } from './auth/knowledgePublisher'; import { isScopedTokenRequestAllowed } from './auth/tokenScopes'; import { getApiToken, projectApiToken } from './db/apiTokens'; import { handleSponsorDocRoutes, isAnonymousSponsorDocRequest } from './routes/sponsorDocs'; +import { handleClientErrorRoute, isClientErrorReport } from './routes/clientErrors'; import { handleEmailTemplateRoutes } from './routes/emailTemplates'; import { handleApiTokenRoutes } from './routes/apiTokens'; import { handleSponsorContractRoutes, isAnonymousSponsorContractRequest } from './routes/sponsorContracts'; @@ -86,6 +87,7 @@ const AUTH_EXEMPT_PATHS = new Set([ function isAuthExempt(method: string, path: string): boolean { if (isAnonymousDocumentReviewRequest(method, path)) return true; if (isAnonymousSponsorDocRequest(method, path)) return true; + if (isClientErrorReport(method, path)) return true; if (isAnonymousSponsorContractRequest(method, path)) return true; if (isAnonymousNewsletterPreviewRequest(method, path)) return true; if (AUTH_EXEMPT_PATHS.has(path)) return true; @@ -434,6 +436,8 @@ async function route(event: LambdaEvent, client: DynamoDBDocumentClient): Promis if (result) return result; } + if (isClientErrorReport(method, reqPath)) return handleClientErrorRoute(event); + if (reqPath.startsWith('/api/sponsor-docs')) { const result = await handleSponsorDocRoutes(reqPath, method, event, client); if (result) return result; diff --git a/backend/src/routes/clientErrors.ts b/backend/src/routes/clientErrors.ts new file mode 100644 index 000000000..49f250971 --- /dev/null +++ b/backend/src/routes/clientErrors.ts @@ -0,0 +1,66 @@ +import { headerValue } from '../httpHeaders'; +import { noStoreJson } from '../http'; +import type { LambdaEvent, LambdaResponse } from '../types'; + +/** + * Browser-reported failures: a page's access check or API call failed or timed + * out and the visitor was shown an error. The page cannot be trusted to tell + * anyone itself, so it posts here and the failure lands in the server log as + * one structured ERROR line, where the same alerting as `api_request_failed` + * can pick it up. + * + * Anonymous (sponsor and guest pages have no session), so the payload is + * strictly bounded: fixed fields only, truncated, query strings stripped + * (they can carry access codes), and a per-IP budget per Lambda instance. + */ + +export const CLIENT_CHECK_FAILED_EVENT = 'client_check_failed'; + +const MAX_FIELD_LENGTH = 300; +const BUDGET_PER_WINDOW = 10; +const WINDOW_MS = 60_000; + +const budgets = new Map(); + +export function isClientErrorReport(method: string, path: string): boolean { + return method.toUpperCase() === 'POST' && /^\/api\/client-errors\/?$/.test(path); +} + +function field(value: unknown): string { + return typeof value === 'string' ? value.slice(0, MAX_FIELD_LENGTH) : ''; +} + +function withinBudget(ip: string, now: number): boolean { + const entry = budgets.get(ip); + if (!entry || now - entry.windowStart >= WINDOW_MS) { + if (budgets.size > 1000) budgets.clear(); + budgets.set(ip, { windowStart: now, count: 1 }); + return true; + } + entry.count += 1; + return entry.count <= BUDGET_PER_WINDOW; +} + +export function handleClientErrorRoute(event: LambdaEvent): LambdaResponse { + let body: Record = {}; + try { + const parsed = JSON.parse(event.body || '{}'); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) body = parsed as Record; + } catch { + return noStoreJson(400, { error: 'Invalid JSON body' }); + } + const ip = headerValue(event.headers, 'x-forwarded-for').split(',')[0].trim() || 'unknown'; + if (!withinBudget(ip, Date.now())) return noStoreJson(429, { error: 'Too many reports' }); + const status = Number(body.status); + console.error(JSON.stringify({ + event: CLIENT_CHECK_FAILED_EVENT, + level: 'ERROR', + surface: field(body.surface), + check: field(body.check), + status: Number.isInteger(status) ? status : null, + message: field(body.message), + page: field(body.page).split(/[?#]/)[0], + userAgent: headerValue(event.headers, 'user-agent').slice(0, MAX_FIELD_LENGTH), + })); + return noStoreJson(204, {}); +} diff --git a/backend/src/routes/sponsorContracts.ts b/backend/src/routes/sponsorContracts.ts index f03f39ead..192b6540b 100644 --- a/backend/src/routes/sponsorContracts.ts +++ b/backend/src/routes/sponsorContracts.ts @@ -495,5 +495,5 @@ async function handleSigningRequest( export function isAnonymousSponsorContractAsset(method: string, path: string): boolean { if (method.toUpperCase() !== 'GET') return false; if (/^\x2Fcontracts\x2F[^\x2F]+\x2F?$/.test(path)) return true; - return path === '/src/contract-sign-page.js' || path === '/src/surfaces/operations/contract-sign.js'; + return path === '/src/contract-sign-page.js' || path === '/src/core/guarded-fetch.js' || path === '/src/core/html.js' || path === '/src/surfaces/operations/contract-sign.js'; } diff --git a/backend/src/routes/sponsorDocs.ts b/backend/src/routes/sponsorDocs.ts index 25fe66b93..8c0e7fef4 100644 --- a/backend/src/routes/sponsorDocs.ts +++ b/backend/src/routes/sponsorDocs.ts @@ -199,12 +199,15 @@ export function isAnonymousSponsorDocAsset(method: string, path: string): boolea if (/^\/(sponsor-docs|podcast-docs|guest-docs|webinar-docs|workshop-docs)\/[^/]+\/?$/.test(path)) return true; return ( path === '/src/sponsor-doc-page.js' + || path === '/src/core/guarded-fetch.js' || path === '/src/surfaces/operations/sponsor-docs.js' || path === '/src/surfaces/operations/sponsor-doc-gate.js' || path === '/src/surfaces/operations/sponsor-doc-select.js' || path === '/src/surfaces/operations/sponsor-doc-radio.js' || path === '/src/surfaces/operations/sponsor-doc-bullets.js' || path === '/src/surfaces/operations/sponsor-doc-email-card.js' + || path === '/src/surfaces/operations/sponsor-doc-event-time.js' + || path === '/src/surfaces/operations/sponsor-doc-guest-schedule.js' || path === '/src/styles/sponsor-docs.css' || path.startsWith('/src/dakit/') ); diff --git a/backend/tests/anonymous-page-assets.test.ts b/backend/tests/anonymous-page-assets.test.ts new file mode 100644 index 000000000..5f04a8d4d --- /dev/null +++ b/backend/tests/anonymous-page-assets.test.ts @@ -0,0 +1,55 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert'; +import { readFileSync } from 'node:fs'; +import { dirname, join, relative, resolve } from 'node:path'; + +import { isAnonymousDocumentReviewAsset } from '../src/documents/reviews'; +import { isAnonymousNewsletterPreviewAsset } from '../src/newsletter/markdownPreview'; +import { isAnonymousSponsorContractAsset } from '../src/routes/sponsorContracts'; +import { isAnonymousSponsorDocAsset } from '../src/routes/sponsorDocs'; + +const FRONTEND_ROOT = resolve(__dirname, '../../frontend'); + +function importGraph(entry: string): Set { + const seen = new Set(); + const queue = [join(FRONTEND_ROOT, entry)]; + while (queue.length) { + const file = queue.pop() as string; + if (seen.has(file)) continue; + seen.add(file); + const source = readFileSync(file, 'utf8'); + for (const match of source.matchAll(/(?:import|export)\s[^'"]*?from\s*["'](\.[^"']+)["']|import\s*["'](\.[^"']+)["']/g)) { + queue.push(resolve(dirname(file), match[1] || match[2])); + } + } + return seen; +} + +const PAGES: Array<[string, (method: string, path: string) => boolean]> = [ + ['src/sponsor-doc-page.js', isAnonymousSponsorDocAsset], + ['src/contract-sign-page.js', isAnonymousSponsorContractAsset], + ['src/newsletter-preview-page.js', isAnonymousNewsletterPreviewAsset], + ['src/document-review-page.js', isAnonymousDocumentReviewAsset], +]; + +describe('anonymous page assets', () => { + for (const [entry, isAllowed] of PAGES) { + it(`lets an anonymous visitor load every module ${entry} imports`, () => { + const blocked = [...importGraph(entry)] + .map((file) => `/${relative(FRONTEND_ROOT, file)}`) + .filter((path) => !isAllowed('GET', path)); + assert.deepStrictEqual(blocked, []); + }); + } +}); + +describe('anonymous page scripts', () => { + it('parse as modules', () => { + for (const [entry] of PAGES) { + for (const file of importGraph(entry)) { + const result = require('node:child_process').spawnSync(process.execPath, ['--input-type=module', '--check'], { input: readFileSync(file, 'utf8') }); + assert.strictEqual(result.status, 0, `${relative(FRONTEND_ROOT, file)}: ${result.stderr}`); + } + } + }); +}); diff --git a/backend/tests/client-errors.test.ts b/backend/tests/client-errors.test.ts new file mode 100644 index 000000000..926dd0632 --- /dev/null +++ b/backend/tests/client-errors.test.ts @@ -0,0 +1,45 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert'; + +import { handleClientErrorRoute, isClientErrorReport } from '../src/routes/clientErrors'; +import type { LambdaEvent } from '../src/types'; + +function event(body: unknown, ip = '203.0.113.9'): LambdaEvent { + return { headers: { 'x-forwarded-for': ip, 'user-agent': 'test-agent' }, body: JSON.stringify(body) } as unknown as LambdaEvent; +} + +function captureErrors(run: () => void): string[] { + const lines: string[] = []; + const original = console.error; + console.error = (line: string) => { lines.push(line); }; + try { run(); } finally { console.error = original; } + return lines; +} + +describe('client error reports', () => { + it('matches only POST /api/client-errors', () => { + assert.ok(isClientErrorReport('POST', '/api/client-errors')); + assert.ok(!isClientErrorReport('GET', '/api/client-errors')); + }); + + it('logs one structured ERROR line without query strings', () => { + let status = 0; + const lines = captureErrors(() => { + status = handleClientErrorRoute(event({ surface: 'sponsor-doc', check: 'GET /api/sponsor-docs', status: 503, message: 'x', page: '/sponsor-docs/sdoc-1?code=SECRET' }, '198.51.100.1')).statusCode; + }); + assert.equal(status, 204); + assert.equal(lines.length, 1); + const entry = JSON.parse(lines[0]); + assert.equal(entry.event, 'client_check_failed'); + assert.equal(entry.level, 'ERROR'); + assert.equal(entry.page, '/sponsor-docs/sdoc-1'); + assert.ok(!lines[0].includes('SECRET')); + }); + + it('throttles one address and rejects invalid JSON', () => { + const codes: number[] = []; + captureErrors(() => { for (let i = 0; i < 12; i += 1) codes.push(handleClientErrorRoute(event({ surface: 's' }, '198.51.100.2')).statusCode); }); + assert.equal(codes.filter((c) => c === 429).length, 2); + assert.equal(handleClientErrorRoute({ headers: {}, body: '{' } as unknown as LambdaEvent).statusCode, 400); + }); +}); diff --git a/frontend/src/contract-sign-page.js b/frontend/src/contract-sign-page.js index 34eb01f08..b7a1a24cc 100644 --- a/frontend/src/contract-sign-page.js +++ b/frontend/src/contract-sign-page.js @@ -1,15 +1,19 @@ +import { guardedFetchJson } from './core/guarded-fetch.js'; import { createContractSignPage } from './surfaces/operations/contract-sign.js'; async function request(path, options = {}) { - const response = await fetch(/work, { - ...options, - cache: 'no-store', - headers: { - 'content-type': 'application/json', - ...(options.headers || {}), + const { response, data } = await guardedFetchJson( + path, + { + ...options, + cache: 'no-store', + headers: { + 'content-type': 'application/json', + ...(options.headers || {}), + }, }, - }); - const data = response.status === 204 ? {} : await response.json().catch(() => ({})); + { surface: 'contract-sign', check: `${options.method || 'GET'} /api/sponsor-contracts`, what: 'load the signing page' }, + ); if (!response.ok) { const error = new Error(data.error || 'Signing request failed'); error.status = response.status; @@ -25,4 +29,5 @@ const page = createContractSignPage({ request, locationRef: location, }); +document.documentElement.dataset.pageReady = 'true'; page.start(); diff --git a/frontend/src/contract-sign.html b/frontend/src/contract-sign.html index 73c69f23a..974309d75 100644 --- a/frontend/src/contract-sign.html +++ b/frontend/src/contract-sign.html @@ -22,5 +22,21 @@
+ diff --git a/frontend/src/core/guarded-fetch.js b/frontend/src/core/guarded-fetch.js new file mode 100644 index 000000000..8ba43fa23 --- /dev/null +++ b/frontend/src/core/guarded-fetch.js @@ -0,0 +1,54 @@ +// Shared rule: a failed or timed-out request must show the visitor what failed +// and what to do, and must be reported to the server so the team notices +// without anyone having to tell us. Expected outcomes (4xx) are not reported. + +export const REQUEST_TIMEOUT_MS = 20000; + +export function reportClientFailure(report, { fetchImpl = globalThis.fetch, locationRef = globalThis.location } = {}) { + try { + const result = fetchImpl("/api/client-errors", { + method: "POST", + headers: { "content-type": "application/json" }, + keepalive: true, + body: JSON.stringify({ ...report, page: locationRef?.pathname || "" }), + }); + result?.catch?.(() => {}); + } catch { + // Reporting is best effort; the visitor-facing error is already shown. + } +} + +export function failureMessage(what, status) { + const cause = status === "timeout" + ? "it took too long to respond" + : status === "network" + ? "the connection failed" + : `the server returned an error (${status})`; + return `Could not ${what}: ${cause}. Reload the page to try again. The DataTalks.Club team has been notified.`; +} + +/** + * fetch + JSON with a timeout. Resolves to the parsed body, or throws an Error + * whose `status` is the HTTP status, "timeout" or "network". Timeouts, network + * failures and 5xx are reported to the server and carry a ready-to-show message. + */ +export async function guardedFetchJson(path, options = {}, { surface, check, what, timeoutMs = REQUEST_TIMEOUT_MS, fetchImpl = globalThis.fetch, locationRef } = {}) { + let response; + try { + response = await fetchImpl(path, { ...options, signal: AbortSignal.timeout(timeoutMs) }); + } catch (cause) { + const status = cause?.name === "TimeoutError" || cause?.name === "AbortError" ? "timeout" : "network"; + const error = new Error(failureMessage(what, status)); + error.status = status; + reportClientFailure({ surface, check, status: null, message: status }, { fetchImpl, locationRef }); + throw error; + } + const data = response.status === 204 ? {} : await response.json().catch(() => ({})); + if (response.status >= 500) { + const error = new Error(failureMessage(what, response.status)); + error.status = response.status; + reportClientFailure({ surface, check, status: response.status, message: String(data.error || "") }, { fetchImpl, locationRef }); + throw error; + } + return { response, data }; +} diff --git a/frontend/src/document-review-page.js b/frontend/src/document-review-page.js index 16ad5fd2e..91eba0a9a 100644 --- a/frontend/src/document-review-page.js +++ b/frontend/src/document-review-page.js @@ -1,3 +1,4 @@ +import { guardedFetchJson } from "./core/guarded-fetch.js"; import { createDocumentReviewPanel } from "./surfaces/document-reviews.js"; const root = document.getElementById("document-review-root"); const id = decodeURIComponent( @@ -12,20 +13,24 @@ try { // Keep the bearer secret out of the address bar and subsequent referrers. history.replaceState(null, "", location.pathname); async function request(path, options = {}) { - const response = await fetch(path, { - ...options, - cache: "no-store", - headers: { - "content-type": "application/json", - "x-document-review-token": token, - ...options.headers, + const { response, data } = await guardedFetchJson( + path, + { + ...options, + cache: "no-store", + headers: { + "content-type": "application/json", + "x-document-review-token": token, + ...options.headers, + }, }, - }); - const data = await response.json(); + { surface: "document-review", check: `${options.method || "GET"} /api/documents/review`, what: "load the document" }, + ); if (!response.ok) throw new Error(data.error || "Document review request failed"); return data; } +document.documentElement.dataset.pageReady = "true"; try { if (!token) throw new Error( diff --git a/frontend/src/document-review.html b/frontend/src/document-review.html index 553ff06b8..1ac61c814 100644 --- a/frontend/src/document-review.html +++ b/frontend/src/document-review.html @@ -11,5 +11,21 @@
Loading document…
+ diff --git a/frontend/src/newsletter-preview-page.js b/frontend/src/newsletter-preview-page.js index 478671ac9..f4893f2f9 100644 --- a/frontend/src/newsletter-preview-page.js +++ b/frontend/src/newsletter-preview-page.js @@ -1,15 +1,19 @@ +import { guardedFetchJson } from "./core/guarded-fetch.js"; import { createNewsletterPreviewPage } from "./surfaces/operations/newsletter-preview.js"; async function request(path, options = {}) { - const response = await fetch(path, { - ...options, - cache: "no-store", - headers: { - "content-type": "application/json", - ...(options.headers || {}), + const { response, data } = await guardedFetchJson( + path, + { + ...options, + cache: "no-store", + headers: { + "content-type": "application/json", + ...(options.headers || {}), + }, }, - }); - const data = response.status === 204 ? {} : await response.json().catch(() => ({})); + { surface: "newsletter-preview", check: `${options.method || "GET"} /api/newsletter-issues`, what: "load the preview" }, + ); if (!response.ok) { const error = new Error(data.error || "Newsletter preview request failed"); error.status = response.status; @@ -26,4 +30,5 @@ const page = createNewsletterPreviewPage({ locationRef: location, historyRef: history, }); +document.documentElement.dataset.pageReady = "true"; page.start(); diff --git a/frontend/src/newsletter-preview.html b/frontend/src/newsletter-preview.html index 2a0011e63..ec79a8e6f 100644 --- a/frontend/src/newsletter-preview.html +++ b/frontend/src/newsletter-preview.html @@ -24,5 +24,21 @@
+ diff --git a/frontend/src/sponsor-doc-page.js b/frontend/src/sponsor-doc-page.js index 5d1999ae9..1404d6dfa 100644 --- a/frontend/src/sponsor-doc-page.js +++ b/frontend/src/sponsor-doc-page.js @@ -1,15 +1,19 @@ +import { guardedFetchJson } from "./core/guarded-fetch.js"; import { createSponsorDocPage } from "./surfaces/operations/sponsor-docs.js"; async function request(path, options = {}) { - const response = await fetch(path, { - ...options, - cache: "no-store", - headers: { - "content-type": "application/json", - ...(options.headers || {}), + const { response, data } = await guardedFetchJson( + path, + { + ...options, + cache: "no-store", + headers: { + "content-type": "application/json", + ...(options.headers || {}), + }, }, - }); - const data = response.status === 204 ? {} : await response.json().catch(() => ({})); + { surface: "sponsor-doc", check: `${options.method || "GET"} /api/sponsor-docs`, what: "load the document" }, + ); if (!response.ok) { const error = new Error(data.error || "Sponsor document request failed"); error.status = response.status; @@ -26,4 +30,5 @@ const page = createSponsorDocPage({ locationRef: location, historyRef: history, }); +document.documentElement.dataset.pageReady = "true"; page.start(); diff --git a/frontend/src/sponsor-doc.html b/frontend/src/sponsor-doc.html index 1df25d709..9d6875711 100644 --- a/frontend/src/sponsor-doc.html +++ b/frontend/src/sponsor-doc.html @@ -23,5 +23,21 @@
+ diff --git a/frontend/test/guarded-fetch.test.mjs b/frontend/test/guarded-fetch.test.mjs new file mode 100644 index 000000000..165dcf2c2 --- /dev/null +++ b/frontend/test/guarded-fetch.test.mjs @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { guardedFetchJson } from "../src/core/guarded-fetch.js"; + +const opts = (fetchImpl, extra = {}) => ({ surface: "s", check: "c", what: "load the thing", fetchImpl, locationRef: { pathname: "/p" }, ...extra }); + +function recorder(handler) { + const calls = []; + const fetchImpl = (path, init) => { + calls.push({ path, init }); + return path === "/api/client-errors" ? Promise.resolve({ status: 204 }) : handler(path, init); + }; + return { calls, fetchImpl }; +} + +test("reports and explains a timeout", async () => { + const { calls, fetchImpl } = recorder(() => Promise.reject(Object.assign(new Error("t"), { name: "TimeoutError" }))); + await assert.rejects(guardedFetchJson("/api/x", {}, opts(fetchImpl)), (error) => { + assert.equal(error.status, "timeout"); + assert.match(error.message, /Could not load the thing.*too long.*Reload.*notified/); + return true; + }); + const report = JSON.parse(calls.find((c) => c.path === "/api/client-errors").init.body); + assert.deepEqual({ surface: report.surface, status: report.status, message: report.message, page: report.page }, { surface: "s", status: null, message: "timeout", page: "/p" }); +}); + +test("reports a 5xx but not an expected 4xx", async () => { + const server = recorder(() => Promise.resolve({ status: 502, ok: false, json: async () => ({ error: "bad" }) })); + await assert.rejects(guardedFetchJson("/api/x", {}, opts(server.fetchImpl)), /server returned an error \(502\)/); + assert.equal(server.calls.filter((c) => c.path === "/api/client-errors").length, 1); + + const denied = recorder(() => Promise.resolve({ status: 403, ok: false, json: async () => ({ error: "no" }) })); + const { response } = await guardedFetchJson("/api/x", {}, opts(denied.fetchImpl)); + assert.equal(response.status, 403); + assert.equal(denied.calls.filter((c) => c.path === "/api/client-errors").length, 0); +}); + +test("a failing report never masks the visitor-facing error", async () => { + const fetchImpl = (path) => (path === "/api/client-errors" ? Promise.reject(new Error("down")) : Promise.reject(new TypeError("net"))); + await assert.rejects(guardedFetchJson("/api/x", {}, opts(fetchImpl)), /connection failed/); +});