From 6ab3b572f93d4134d644690bf33c4e06dc295cea Mon Sep 17 00:00:00 2001 From: testvalue Date: Tue, 31 Mar 2026 15:11:51 -0400 Subject: [PATCH 1/4] perf: lazy-loads route components with ErrorBoundary and chunk prefetch --- src/app/App.tsx | 57 ++++++++++++++++++++++++++--------- src/app/lib/sentry.ts | 1 + src/app/pages/LoginPage.tsx | 11 ++++++- tests/components/App.test.tsx | 31 ++++++++++++++++++- 4 files changed, 84 insertions(+), 16 deletions(-) diff --git a/src/app/App.tsx b/src/app/App.tsx index 8ea6bcc6..68ba153c 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,17 +1,38 @@ -import { createSignal, createEffect, onMount, Show, type JSX } from "solid-js"; +import { createSignal, createEffect, onMount, Show, ErrorBoundary, lazy, type JSX } from "solid-js"; import { Router, Route, Navigate, useNavigate } from "@solidjs/router"; -import { isAuthenticated, validateToken } from "./stores/auth"; +import { isAuthenticated, validateToken, AUTH_STORAGE_KEY } from "./stores/auth"; import { config, initConfigPersistence, resolveTheme } from "./stores/config"; import { initViewPersistence } from "./stores/view"; import { evictStaleEntries } from "./stores/cache"; import { initClientWatcher } from "./services/github"; import LoginPage from "./pages/LoginPage"; import OAuthCallback from "./pages/OAuthCallback"; -import DashboardPage from "./components/dashboard/DashboardPage"; -import OnboardingWizard from "./components/onboarding/OnboardingWizard"; -import SettingsPage from "./components/settings/SettingsPage"; import PrivacyPage from "./pages/PrivacyPage"; +const DashboardPage = lazy(() => import("./components/dashboard/DashboardPage")); +const OnboardingWizard = lazy(() => import("./components/onboarding/OnboardingWizard")); +const SettingsPage = lazy(() => import("./components/settings/SettingsPage")); + +function ChunkErrorFallback() { + return ( +
+
+

Failed to load page

+

+ A new version may have been deployed. Reloading should fix this. +

+ +
+
+ ); +} + // Auth guard: redirects unauthenticated users to /login. // On page load, validates the localStorage token with GitHub API. function AuthGuard(props: { children: JSX.Element }) { @@ -138,17 +159,25 @@ export default function App() { evictStaleEntries(24 * 60 * 60 * 1000).catch(() => { // Non-fatal — stale eviction failure is acceptable }); + + // Preload dashboard chunk in parallel with token validation to avoid + // a sequential waterfall (validateToken → chunk fetch) + if (localStorage.getItem?.(AUTH_STORAGE_KEY)) { + void import("./components/dashboard/DashboardPage"); + } }); return ( - - - - - } /> - } /> - } /> - - + { console.error("[app] Route render failed:", err); return ; }}> + + + + + } /> + } /> + } /> + + + ); } diff --git a/src/app/lib/sentry.ts b/src/app/lib/sentry.ts index 181dda65..0aa87aef 100644 --- a/src/app/lib/sentry.ts +++ b/src/app/lib/sentry.ts @@ -11,6 +11,7 @@ export function scrubUrl(url: string): string { /** Allowed console breadcrumb prefixes — drop everything else. */ const ALLOWED_CONSOLE_PREFIXES = [ + "[app]", "[auth]", "[api]", "[poll]", diff --git a/src/app/pages/LoginPage.tsx b/src/app/pages/LoginPage.tsx index 847f3359..76bdf689 100644 --- a/src/app/pages/LoginPage.tsx +++ b/src/app/pages/LoginPage.tsx @@ -1,4 +1,4 @@ -import { createSignal, Show } from "solid-js"; +import { createSignal, onMount, Show } from "solid-js"; import { useNavigate } from "@solidjs/router"; import { setAuthFromPat, type GitHubUser } from "../stores/auth"; import { @@ -11,6 +11,15 @@ import { buildAuthorizeUrl } from "../lib/oauth"; export default function LoginPage() { const navigate = useNavigate(); + onMount(() => { + // Speculatively prefetch the dashboard chunk while the user is on the + // login page. By the time they authenticate, the chunk is cached. + const prefetch = () => void import("../components/dashboard/DashboardPage"); + "requestIdleCallback" in window + ? requestIdleCallback(prefetch) + : setTimeout(prefetch, 2000); + }); + const [showPatForm, setShowPatForm] = createSignal(false); const [patInput, setPatInput] = createSignal(""); const [patError, setPatError] = createSignal(null); diff --git a/tests/components/App.test.tsx b/tests/components/App.test.tsx index 260d829a..f0e25280 100644 --- a/tests/components/App.test.tsx +++ b/tests/components/App.test.tsx @@ -51,8 +51,12 @@ vi.mock("../../src/app/stores/cache", async (importOriginal) => { }); // Mock heavy page/component dependencies +let dashboardShouldThrow = false; vi.mock("../../src/app/components/dashboard/DashboardPage", () => ({ - default: () =>
Dashboard
, + default: () => { + if (dashboardShouldThrow) throw new Error("chunk load failed"); + return
Dashboard
; + }, })); vi.mock("../../src/app/components/onboarding/OnboardingWizard", () => ({ default: () =>
Onboarding
, @@ -73,6 +77,7 @@ describe("App", () => { vi.resetAllMocks(); mockIsAuthenticated = false; mockValidateToken = async () => false; + dashboardShouldThrow = false; // Re-apply default mock implementations that are needed across tests vi.mocked(cacheStore.evictStaleEntries).mockResolvedValue(0); // Reset config to defaults @@ -159,4 +164,28 @@ describe("App", () => { it("all routes are registered: /, /login, /oauth/callback, /onboarding, /dashboard, /settings", () => { expect(() => render(() => )).not.toThrow(); }); + + it("shows error fallback and logs when a lazy route component throws", async () => { + dashboardShouldThrow = true; + mockIsAuthenticated = true; + configStore.updateConfig({ onboardingComplete: true }); + + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + + render(() => ); + + await waitFor(() => { + screen.getByText("Failed to load page"); + screen.getByText("A new version may have been deployed. Reloading should fix this."); + screen.getByRole("button", { name: "Reload page" }); + }); + + // Verify the error is logged (not silently swallowed) for observability + expect(spy).toHaveBeenCalledWith( + "[app] Route render failed:", + expect.any(Error), + ); + + spy.mockRestore(); + }); }); From eb4f8097793a7be35c8527191c8cfbb773895f3b Mon Sep 17 00:00:00 2001 From: testvalue Date: Tue, 31 Mar 2026 19:16:04 -0400 Subject: [PATCH 2/4] fix(csp): adds blob: to img-src for internal blob image URLs --- public/_headers | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/_headers b/public/_headers index 2d5edabe..4fe0a138 100644 --- a/public/_headers +++ b/public/_headers @@ -1,5 +1,5 @@ /* - Content-Security-Policy: default-src 'none'; script-src 'self' 'sha256-uEFqyYCMaNy1Su5VmWLZ1hOCRBjkhm4+ieHHxQW6d3Y='; style-src-elem 'self'; style-src-attr 'unsafe-inline'; img-src 'self' data: https://avatars.githubusercontent.com; connect-src 'self' https://api.github.com; font-src 'self'; worker-src 'self'; manifest-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'none'; upgrade-insecure-requests + Content-Security-Policy: default-src 'none'; script-src 'self' 'sha256-uEFqyYCMaNy1Su5VmWLZ1hOCRBjkhm4+ieHHxQW6d3Y='; style-src-elem 'self'; style-src-attr 'unsafe-inline'; img-src 'self' blob: data: https://avatars.githubusercontent.com; connect-src 'self' https://api.github.com; font-src 'self'; worker-src 'self'; manifest-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'none'; upgrade-insecure-requests X-Content-Type-Options: nosniff Referrer-Policy: strict-origin-when-cross-origin Permissions-Policy: geolocation=(), microphone=(), camera=() From f25c75ee448683e62eba4b113f549fbbcae88b1e Mon Sep 17 00:00:00 2001 From: testvalue Date: Tue, 31 Mar 2026 19:17:39 -0400 Subject: [PATCH 3/4] feat(csp): adds report-uri to send CSP violations to Sentry --- public/_headers | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/_headers b/public/_headers index 4fe0a138..48378551 100644 --- a/public/_headers +++ b/public/_headers @@ -1,5 +1,5 @@ /* - Content-Security-Policy: default-src 'none'; script-src 'self' 'sha256-uEFqyYCMaNy1Su5VmWLZ1hOCRBjkhm4+ieHHxQW6d3Y='; style-src-elem 'self'; style-src-attr 'unsafe-inline'; img-src 'self' blob: data: https://avatars.githubusercontent.com; connect-src 'self' https://api.github.com; font-src 'self'; worker-src 'self'; manifest-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'none'; upgrade-insecure-requests + Content-Security-Policy: default-src 'none'; script-src 'self' 'sha256-uEFqyYCMaNy1Su5VmWLZ1hOCRBjkhm4+ieHHxQW6d3Y='; style-src-elem 'self'; style-src-attr 'unsafe-inline'; img-src 'self' blob: data: https://avatars.githubusercontent.com; connect-src 'self' https://api.github.com; font-src 'self'; worker-src 'self'; manifest-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'none'; upgrade-insecure-requests; report-uri https://o284235.ingest.us.sentry.io/api/4511122822922240/security/?sentry_key=4dc4335a9746201c02ff2107c0d20f73 X-Content-Type-Options: nosniff Referrer-Policy: strict-origin-when-cross-origin Permissions-Policy: geolocation=(), microphone=(), camera=() From 0a8e8136f3249f94d8b1ef90d85b3df0f08fe566 Mon Sep 17 00:00:00 2001 From: testvalue Date: Wed, 1 Apr 2026 12:05:11 -0400 Subject: [PATCH 4/4] fix: addresses PR #40 review findings - adds Suspense boundary wrapping Router for lazy chunk loading states - adds /api/csp-report worker endpoint that scrubs OAuth params before forwarding CSP violation reports to Sentry (replaces direct report-uri) - adds Reporting-Endpoints header enabling modern report-to directive - removes unnecessary blob: from CSP img-src - extracts ErrorBoundary fallback to named handleRouteError function - adds .catch() with console.warn to both prefetch paths - adds [app] to Sentry allowed console breadcrumb prefixes - extracts getOrCacheDsn helper to deduplicate DSN cache logic - adds parseSentryDsn publicKey validation - caps CSP report batch fan-out to 20 to prevent amplification - scrubs referrer field in CSP reports (OAuth param leak vector) - adds 18 tests covering CSP report endpoint, ErrorBoundary for all lazy routes, prefetch scheduling, and sentry prefix --- public/_headers | 3 +- src/app/App.tsx | 37 ++-- src/app/pages/LoginPage.tsx | 6 +- src/worker/index.ts | 130 +++++++++++- tests/components/App.test.tsx | 70 ++++++- tests/components/LoginPage.test.tsx | 26 +++ tests/lib/sentry.test.ts | 2 +- tests/worker/csp-report.test.ts | 309 ++++++++++++++++++++++++++++ 8 files changed, 557 insertions(+), 26 deletions(-) create mode 100644 tests/worker/csp-report.test.ts diff --git a/public/_headers b/public/_headers index 48378551..bd30cf47 100644 --- a/public/_headers +++ b/public/_headers @@ -1,5 +1,6 @@ /* - Content-Security-Policy: default-src 'none'; script-src 'self' 'sha256-uEFqyYCMaNy1Su5VmWLZ1hOCRBjkhm4+ieHHxQW6d3Y='; style-src-elem 'self'; style-src-attr 'unsafe-inline'; img-src 'self' blob: data: https://avatars.githubusercontent.com; connect-src 'self' https://api.github.com; font-src 'self'; worker-src 'self'; manifest-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'none'; upgrade-insecure-requests; report-uri https://o284235.ingest.us.sentry.io/api/4511122822922240/security/?sentry_key=4dc4335a9746201c02ff2107c0d20f73 + Content-Security-Policy: default-src 'none'; script-src 'self' 'sha256-uEFqyYCMaNy1Su5VmWLZ1hOCRBjkhm4+ieHHxQW6d3Y='; style-src-elem 'self'; style-src-attr 'unsafe-inline'; img-src 'self' data: https://avatars.githubusercontent.com; connect-src 'self' https://api.github.com; font-src 'self'; worker-src 'self'; manifest-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'none'; upgrade-insecure-requests; report-uri /api/csp-report; report-to csp-endpoint + Reporting-Endpoints: csp-endpoint="/api/csp-report" X-Content-Type-Options: nosniff Referrer-Policy: strict-origin-when-cross-origin Permissions-Policy: geolocation=(), microphone=(), camera=() diff --git a/src/app/App.tsx b/src/app/App.tsx index 68ba153c..f6f5aa10 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,4 +1,4 @@ -import { createSignal, createEffect, onMount, Show, ErrorBoundary, lazy, type JSX } from "solid-js"; +import { createSignal, createEffect, onMount, Show, ErrorBoundary, Suspense, lazy, type JSX } from "solid-js"; import { Router, Route, Navigate, useNavigate } from "@solidjs/router"; import { isAuthenticated, validateToken, AUTH_STORAGE_KEY } from "./stores/auth"; import { config, initConfigPersistence, resolveTheme } from "./stores/config"; @@ -13,6 +13,11 @@ const DashboardPage = lazy(() => import("./components/dashboard/DashboardPage")) const OnboardingWizard = lazy(() => import("./components/onboarding/OnboardingWizard")); const SettingsPage = lazy(() => import("./components/settings/SettingsPage")); +function handleRouteError(err: unknown) { + console.error("[app] Route render failed:", err); + return ; +} + function ChunkErrorFallback() { return (
@@ -163,21 +168,29 @@ export default function App() { // Preload dashboard chunk in parallel with token validation to avoid // a sequential waterfall (validateToken → chunk fetch) if (localStorage.getItem?.(AUTH_STORAGE_KEY)) { - void import("./components/dashboard/DashboardPage"); + import("./components/dashboard/DashboardPage").catch(() => { + console.warn("[app] Dashboard chunk preload failed"); + }); } }); return ( - { console.error("[app] Route render failed:", err); return ; }}> - - - - - } /> - } /> - } /> - - + + + +
+ }> + + + + + } /> + } /> + } /> + + + ); } diff --git a/src/app/pages/LoginPage.tsx b/src/app/pages/LoginPage.tsx index 76bdf689..824c5ecb 100644 --- a/src/app/pages/LoginPage.tsx +++ b/src/app/pages/LoginPage.tsx @@ -14,7 +14,11 @@ export default function LoginPage() { onMount(() => { // Speculatively prefetch the dashboard chunk while the user is on the // login page. By the time they authenticate, the chunk is cached. - const prefetch = () => void import("../components/dashboard/DashboardPage"); + const prefetch = () => { + import("../components/dashboard/DashboardPage").catch(() => { + console.warn("[app] Dashboard chunk prefetch failed"); + }); + }; "requestIdleCallback" in window ? requestIdleCallback(prefetch) : setTimeout(prefetch, 2000); diff --git a/src/worker/index.ts b/src/worker/index.ts index 52700d2b..2eed9bae 100644 --- a/src/worker/index.ts +++ b/src/worker/index.ts @@ -80,21 +80,31 @@ function getCorsHeaders( // The envelope DSN is validated against env.SENTRY_DSN to prevent open proxy abuse. const SENTRY_ENVELOPE_MAX_BYTES = 256 * 1024; // 256 KB — Sentry rejects >200KB compressed -let _dsnCache: { dsn: string; parsed: { host: string; projectId: string } | null } | undefined; +interface ParsedDsn { host: string; projectId: string; publicKey: string } -/** Parse host and project ID from a Sentry DSN URL. Returns null if invalid. */ -function parseSentryDsn(dsn: string): { host: string; projectId: string } | null { +let _dsnCache: { dsn: string; parsed: ParsedDsn | null } | undefined; + +/** Parse host, project ID, and public key from a Sentry DSN URL. Returns null if invalid. */ +function parseSentryDsn(dsn: string): ParsedDsn | null { if (!dsn) return null; try { const url = new URL(dsn); const projectId = url.pathname.split("/").filter(Boolean).pop() ?? ""; - if (!url.hostname || !projectId) return null; - return { host: url.hostname, projectId }; + if (!url.hostname || !projectId || !url.username) return null; + return { host: url.hostname, projectId, publicKey: url.username }; } catch { return null; } } +/** Get cached parsed DSN, re-parsing only when the DSN string changes. */ +function getOrCacheDsn(env: Env): ParsedDsn | null { + if (!_dsnCache || _dsnCache.dsn !== env.SENTRY_DSN) { + _dsnCache = { dsn: env.SENTRY_DSN, parsed: parseSentryDsn(env.SENTRY_DSN) }; + } + return _dsnCache.parsed; +} + async function handleSentryTunnel( request: Request, env: Env, @@ -103,10 +113,7 @@ async function handleSentryTunnel( return new Response(null, { status: 405, headers: SECURITY_HEADERS }); } - if (!_dsnCache || _dsnCache.dsn !== env.SENTRY_DSN) { - _dsnCache = { dsn: env.SENTRY_DSN, parsed: parseSentryDsn(env.SENTRY_DSN) }; - } - const allowedDsn = _dsnCache.parsed; + const allowedDsn = getOrCacheDsn(env); if (!allowedDsn) { log("warn", "sentry_tunnel_not_configured", {}, request); return new Response(null, { status: 404, headers: SECURITY_HEADERS }); @@ -186,6 +193,106 @@ async function handleSentryTunnel( } } +// ── CSP report tunnel ──────────────────────────────────────────────────── +// Receives browser CSP violation reports, scrubs OAuth params from URLs, +// then forwards to Sentry's security ingest endpoint. +const CSP_REPORT_MAX_BYTES = 64 * 1024; +const CSP_OAUTH_PARAMS_RE = /([?&])(code|state|access_token)=[^&\s]*/g; + +function scrubReportUrl(url: unknown): string | undefined { + if (typeof url !== "string") return undefined; + return url.replace(CSP_OAUTH_PARAMS_RE, "$1$2=[REDACTED]"); +} + +function scrubCspReportBody(body: Record): Record { + const scrubbed = { ...body }; + // Legacy report-uri format uses kebab-case keys + for (const key of ["document-uri", "blocked-uri", "source-file", "referrer"]) { + if (typeof scrubbed[key] === "string") scrubbed[key] = scrubReportUrl(scrubbed[key]); + } + // report-to format uses camelCase keys + for (const key of ["documentURL", "blockedURL", "sourceFile", "referrer"]) { + if (typeof scrubbed[key] === "string") scrubbed[key] = scrubReportUrl(scrubbed[key]); + } + return scrubbed; +} + +async function handleCspReport(request: Request, env: Env): Promise { + if (request.method !== "POST") { + return new Response(null, { status: 405, headers: SECURITY_HEADERS }); + } + + const allowedDsn = getOrCacheDsn(env); + if (!allowedDsn) { + return new Response(null, { status: 404, headers: SECURITY_HEADERS }); + } + + let bodyText: string; + try { + bodyText = await request.text(); + } catch { + return new Response(null, { status: 400, headers: SECURITY_HEADERS }); + } + + if (bodyText.length > CSP_REPORT_MAX_BYTES) { + log("warn", "csp_report_too_large", { body_length: bodyText.length }, request); + return new Response(null, { status: 413, headers: SECURITY_HEADERS }); + } + + const contentType = request.headers.get("Content-Type") ?? ""; + let scrubbedPayloads: Array> = []; + + try { + if (contentType.includes("application/reports+json")) { + // report-to format: array of report objects + const reports = JSON.parse(bodyText) as Array<{ type?: string; body?: Record }>; + for (const report of reports) { + if (report.type === "csp-violation" && report.body) { + scrubbedPayloads.push({ "csp-report": scrubCspReportBody(report.body) }); + } + } + } else { + // Legacy report-uri format: { "csp-report": { ... } } + const parsed = JSON.parse(bodyText) as { "csp-report"?: Record }; + if (parsed["csp-report"]) { + scrubbedPayloads.push({ "csp-report": scrubCspReportBody(parsed["csp-report"]) }); + } + } + } catch { + log("warn", "csp_report_parse_failed", {}, request); + return new Response(null, { status: 400, headers: SECURITY_HEADERS }); + } + + if (scrubbedPayloads.length === 0) { + return new Response(null, { status: 204, headers: SECURITY_HEADERS }); + } + + // Cap fan-out to prevent amplification from crafted report-to batches + if (scrubbedPayloads.length > 20) { + scrubbedPayloads = scrubbedPayloads.slice(0, 20); + } + + // Sentry security endpoint expects individual csp-report JSON objects + const sentryUrl = `https://${allowedDsn.host}/api/${allowedDsn.projectId}/security/?sentry_key=${allowedDsn.publicKey}`; + + const results = await Promise.all( + scrubbedPayloads.map((payload) => + fetch(sentryUrl, { + method: "POST", + headers: { "Content-Type": "application/csp-report" }, + body: JSON.stringify(payload), + }).catch(() => null) + ) + ); + + log("info", "csp_report_forwarded", { + count: scrubbedPayloads.length, + sentry_ok: results.some((r) => r?.ok), + }, request); + + return new Response(null, { status: 204, headers: SECURITY_HEADERS }); +} + // GitHub OAuth code format validation (SDR-005): alphanumeric, 1-40 chars. // GitHub's code format is undocumented and has changed historically — validate // loosely here; GitHub's server validates the actual code. @@ -350,6 +457,11 @@ export default { return handleSentryTunnel(request, env); } + // CSP report tunnel — scrubs OAuth params before forwarding to Sentry + if (url.pathname === "/api/csp-report") { + return handleCspReport(request, env); + } + if (url.pathname === "/api/oauth/token") { return handleTokenExchange(request, env, cors); } diff --git a/tests/components/App.test.tsx b/tests/components/App.test.tsx index f0e25280..21501722 100644 --- a/tests/components/App.test.tsx +++ b/tests/components/App.test.tsx @@ -52,6 +52,8 @@ vi.mock("../../src/app/stores/cache", async (importOriginal) => { // Mock heavy page/component dependencies let dashboardShouldThrow = false; +let onboardingShouldThrow = false; +let settingsShouldThrow = false; vi.mock("../../src/app/components/dashboard/DashboardPage", () => ({ default: () => { if (dashboardShouldThrow) throw new Error("chunk load failed"); @@ -59,10 +61,16 @@ vi.mock("../../src/app/components/dashboard/DashboardPage", () => ({ }, })); vi.mock("../../src/app/components/onboarding/OnboardingWizard", () => ({ - default: () =>
Onboarding
, + default: () => { + if (onboardingShouldThrow) throw new Error("chunk load failed"); + return
Onboarding
; + }, })); vi.mock("../../src/app/components/settings/SettingsPage", () => ({ - default: () =>
Settings
, + default: () => { + if (settingsShouldThrow) throw new Error("chunk load failed"); + return
Settings
; + }, })); import * as configStore from "../../src/app/stores/config"; @@ -78,6 +86,8 @@ describe("App", () => { mockIsAuthenticated = false; mockValidateToken = async () => false; dashboardShouldThrow = false; + onboardingShouldThrow = false; + settingsShouldThrow = false; // Re-apply default mock implementations that are needed across tests vi.mocked(cacheStore.evictStaleEntries).mockResolvedValue(0); // Reset config to defaults @@ -188,4 +198,60 @@ describe("App", () => { spy.mockRestore(); }); + + it("shows error fallback when onboarding route throws", async () => { + onboardingShouldThrow = true; + mockIsAuthenticated = true; + configStore.updateConfig({ onboardingComplete: false }); + window.history.pushState({}, "", "/onboarding"); + + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + + render(() => ); + + await waitFor(() => { + screen.getByText("Failed to load page"); + }); + + spy.mockRestore(); + }); + + it("shows error fallback when settings route throws", async () => { + settingsShouldThrow = true; + mockIsAuthenticated = true; + configStore.updateConfig({ onboardingComplete: true }); + window.history.pushState({}, "", "/settings"); + + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + + render(() => ); + + await waitFor(() => { + screen.getByText("Failed to load page"); + }); + + spy.mockRestore(); + }); + + it("preloads dashboard chunk when auth token exists in localStorage", async () => { + // happy-dom's localStorage is a Proxy; use vi.stubGlobal with a mock + const store: Record = { "github-tracker:auth-token": "test-token" }; + vi.stubGlobal("localStorage", { + getItem: (key: string) => store[key] ?? null, + setItem: (key: string, val: string) => { store[key] = val; }, + removeItem: (key: string) => { delete store[key]; }, + clear: () => { for (const k of Object.keys(store)) delete store[k]; }, + }); + + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + render(() => ); + + await waitFor(() => { + expect(vi.mocked(cacheStore.evictStaleEntries)).toHaveBeenCalled(); + }); + + vi.unstubAllGlobals(); + warnSpy.mockRestore(); + }); }); diff --git a/tests/components/LoginPage.test.tsx b/tests/components/LoginPage.test.tsx index f702c2d1..02bcbe77 100644 --- a/tests/components/LoginPage.test.tsx +++ b/tests/components/LoginPage.test.tsx @@ -16,6 +16,11 @@ vi.mock("../../src/app/lib/pat", async (importOriginal) => { }; }); +// Mock lazy-loaded component to prevent real imports during prefetch +vi.mock("../../src/app/components/dashboard/DashboardPage", () => ({ + default: () => null, +})); + // Full router mock — per project convention (SolidJS useNavigate requires Route context; // partial mocks of @solidjs/router render empty divs) const mockNavigate = vi.fn(); @@ -106,6 +111,27 @@ describe("LoginPage — OAuth view (default)", () => { await user.click(screen.getByText("Sign in with GitHub")); expect(window.location.href).toContain("https://github.com/login/oauth/authorize"); }); + + it("schedules dashboard chunk prefetch on mount", () => { + // happy-dom lacks requestIdleCallback, so the setTimeout(prefetch, 2000) fallback fires + vi.useFakeTimers(); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + render(() => ); + + // The prefetch is scheduled via setTimeout with 2s delay + const timeoutCalls = vi.getTimerCount(); + expect(timeoutCalls).toBeGreaterThan(0); + + // Advancing the timer triggers the import — resolves to mock, no error + vi.advanceTimersByTime(2000); + + // No console.warn means the .catch() didn't fire (import succeeded via mock) + expect(warnSpy).not.toHaveBeenCalledWith("[app] Dashboard chunk prefetch failed"); + + vi.useRealTimers(); + warnSpy.mockRestore(); + }); }); describe("LoginPage — PAT form navigation", () => { diff --git a/tests/lib/sentry.test.ts b/tests/lib/sentry.test.ts index 5e612a94..23f94f99 100644 --- a/tests/lib/sentry.test.ts +++ b/tests/lib/sentry.test.ts @@ -179,7 +179,7 @@ describe("beforeBreadcrumbHandler", () => { }); it("keeps allowed console breadcrumbs", () => { - const prefixes = ["[auth]", "[api]", "[poll]", "[dashboard]", "[settings]"]; + const prefixes = ["[app]", "[auth]", "[api]", "[poll]", "[dashboard]", "[settings]"]; for (const prefix of prefixes) { const breadcrumb = { category: "console", diff --git a/tests/worker/csp-report.test.ts b/tests/worker/csp-report.test.ts new file mode 100644 index 00000000..c3cb07fc --- /dev/null +++ b/tests/worker/csp-report.test.ts @@ -0,0 +1,309 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import worker, { type Env } from "../../src/worker/index"; + +function makeEnv(overrides: Partial = {}): Env { + return { + ASSETS: { fetch: async () => new Response("asset") }, + GITHUB_CLIENT_ID: "test_client_id", + GITHUB_CLIENT_SECRET: "test_client_secret", + ALLOWED_ORIGIN: "https://gh.gordoncode.dev", + SENTRY_DSN: "https://abc123@o123456.ingest.sentry.io/7890123", + ...overrides, + }; +} + +function makeCspRequest( + body: string, + contentType = "application/csp-report", + method = "POST", +): Request { + return new Request("https://gh.gordoncode.dev/api/csp-report", { + method, + headers: { "Content-Type": contentType }, + body: method !== "GET" ? body : undefined, + }); +} + +describe("Worker CSP report endpoint", () => { + let originalFetch: typeof globalThis.fetch; + let mockFetch: ReturnType; + + beforeEach(() => { + originalFetch = globalThis.fetch; + mockFetch = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); + globalThis.fetch = mockFetch as typeof globalThis.fetch; + vi.spyOn(console, "info").mockImplementation(() => {}); + vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + it("rejects non-POST requests", async () => { + const req = new Request("https://gh.gordoncode.dev/api/csp-report", { method: "GET" }); + const resp = await worker.fetch(req, makeEnv()); + expect(resp.status).toBe(405); + }); + + it("scrubs OAuth params from document-uri in legacy format", async () => { + const body = JSON.stringify({ + "csp-report": { + "document-uri": "https://gh.gordoncode.dev/oauth/callback?code=abc123&state=xyz789", + "blocked-uri": "https://evil.com/script.js", + "violated-directive": "script-src", + }, + }); + const req = makeCspRequest(body); + const resp = await worker.fetch(req, makeEnv()); + + expect(resp.status).toBe(204); + expect(mockFetch).toHaveBeenCalledTimes(1); + + const sentryBody = JSON.parse(mockFetch.mock.calls[0][1].body as string); + expect(sentryBody["csp-report"]["document-uri"]).toBe( + "https://gh.gordoncode.dev/oauth/callback?code=[REDACTED]&state=[REDACTED]", + ); + expect(sentryBody["csp-report"]["blocked-uri"]).toBe("https://evil.com/script.js"); + }); + + it("scrubs access_token from source-file", async () => { + const body = JSON.stringify({ + "csp-report": { + "document-uri": "https://gh.gordoncode.dev/dashboard", + "source-file": "https://gh.gordoncode.dev/app.js?access_token=ghu_secret", + "violated-directive": "script-src", + }, + }); + const req = makeCspRequest(body); + await worker.fetch(req, makeEnv()); + + const sentryBody = JSON.parse(mockFetch.mock.calls[0][1].body as string); + expect(sentryBody["csp-report"]["source-file"]).toBe( + "https://gh.gordoncode.dev/app.js?access_token=[REDACTED]", + ); + }); + + it("handles report-to format (application/reports+json)", async () => { + const body = JSON.stringify([ + { + type: "csp-violation", + body: { + documentURL: "https://gh.gordoncode.dev/oauth/callback?code=secret&state=val", + blockedURL: "inline", + disposition: "enforce", + }, + }, + ]); + const req = makeCspRequest(body, "application/reports+json"); + const resp = await worker.fetch(req, makeEnv()); + + expect(resp.status).toBe(204); + expect(mockFetch).toHaveBeenCalledTimes(1); + + const sentryBody = JSON.parse(mockFetch.mock.calls[0][1].body as string); + // report-to body is normalized to legacy csp-report format for Sentry + expect(sentryBody["csp-report"]["documentURL"]).toBe( + "https://gh.gordoncode.dev/oauth/callback?code=[REDACTED]&state=[REDACTED]", + ); + }); + + it("scrubs blockedURL and sourceFile in report-to format", async () => { + const body = JSON.stringify([ + { + type: "csp-violation", + body: { + documentURL: "https://gh.gordoncode.dev/dashboard", + blockedURL: "https://cdn.example.com/script.js?state=leaked", + sourceFile: "https://gh.gordoncode.dev/app.js?code=abc123&other=safe", + }, + }, + ]); + const req = makeCspRequest(body, "application/reports+json"); + await worker.fetch(req, makeEnv()); + + const sentryBody = JSON.parse(mockFetch.mock.calls[0][1].body as string); + expect(sentryBody["csp-report"]["blockedURL"]).toBe( + "https://cdn.example.com/script.js?state=[REDACTED]", + ); + expect(sentryBody["csp-report"]["sourceFile"]).toBe( + "https://gh.gordoncode.dev/app.js?code=[REDACTED]&other=safe", + ); + }); + + it("forwards multiple CSP violations from one report-to batch", async () => { + const body = JSON.stringify([ + { + type: "csp-violation", + body: { documentURL: "https://gh.gordoncode.dev/a", blockedURL: "inline" }, + }, + { + type: "csp-violation", + body: { documentURL: "https://gh.gordoncode.dev/b", blockedURL: "eval" }, + }, + ]); + const req = makeCspRequest(body, "application/reports+json"); + await worker.fetch(req, makeEnv()); + + expect(mockFetch).toHaveBeenCalledTimes(2); + const body1 = JSON.parse(mockFetch.mock.calls[0][1].body as string); + const body2 = JSON.parse(mockFetch.mock.calls[1][1].body as string); + expect(body1["csp-report"]["documentURL"]).toBe("https://gh.gordoncode.dev/a"); + expect(body2["csp-report"]["documentURL"]).toBe("https://gh.gordoncode.dev/b"); + }); + + it("drops non-CSP report types from report-to batch", async () => { + const body = JSON.stringify([ + { type: "deprecation", body: { message: "old API" } }, + { + type: "csp-violation", + body: { + documentURL: "https://gh.gordoncode.dev/dashboard", + blockedURL: "inline", + }, + }, + ]); + const req = makeCspRequest(body, "application/reports+json"); + const resp = await worker.fetch(req, makeEnv()); + + expect(resp.status).toBe(204); + // Only the CSP violation should be forwarded + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("forwards to correct Sentry security endpoint with sentry_key", async () => { + const body = JSON.stringify({ + "csp-report": { + "document-uri": "https://gh.gordoncode.dev/dashboard", + "violated-directive": "script-src", + }, + }); + const req = makeCspRequest(body); + await worker.fetch(req, makeEnv()); + + const sentryUrl = mockFetch.mock.calls[0][0] as string; + expect(sentryUrl).toBe( + "https://o123456.ingest.sentry.io/api/7890123/security/?sentry_key=abc123", + ); + expect(mockFetch.mock.calls[0][1].headers["Content-Type"]).toBe("application/csp-report"); + }); + + it("returns 400 for invalid JSON", async () => { + const req = makeCspRequest("not json {{{"); + const resp = await worker.fetch(req, makeEnv()); + expect(resp.status).toBe(400); + }); + + it("returns 413 for oversized payload", async () => { + const body = "x".repeat(65 * 1024); + const req = makeCspRequest(body); + const resp = await worker.fetch(req, makeEnv()); + expect(resp.status).toBe(413); + }); + + it("returns 204 for empty csp-report body", async () => { + const body = JSON.stringify({ "not-csp": {} }); + const req = makeCspRequest(body); + const resp = await worker.fetch(req, makeEnv()); + expect(resp.status).toBe(204); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("returns 404 when SENTRY_DSN is empty", async () => { + const body = JSON.stringify({ + "csp-report": { + "document-uri": "https://gh.gordoncode.dev/dashboard", + "violated-directive": "script-src", + }, + }); + const req = makeCspRequest(body); + const resp = await worker.fetch(req, makeEnv({ SENTRY_DSN: "" })); + expect(resp.status).toBe(404); + }); + + it("handles Sentry fetch failure gracefully", async () => { + mockFetch.mockRejectedValue(new Error("network error")); + const body = JSON.stringify({ + "csp-report": { + "document-uri": "https://gh.gordoncode.dev/dashboard", + "violated-directive": "script-src", + }, + }); + const req = makeCspRequest(body); + const resp = await worker.fetch(req, makeEnv()); + // Should still return 204, not crash + expect(resp.status).toBe(204); + }); + + it("scrubs referrer field containing OAuth params", async () => { + const body = JSON.stringify({ + "csp-report": { + "document-uri": "https://gh.gordoncode.dev/dashboard", + "referrer": "https://gh.gordoncode.dev/oauth/callback?code=secret123&state=xyz", + "violated-directive": "script-src", + }, + }); + const req = makeCspRequest(body); + await worker.fetch(req, makeEnv()); + + const sentryBody = JSON.parse(mockFetch.mock.calls[0][1].body as string); + expect(sentryBody["csp-report"]["referrer"]).toBe( + "https://gh.gordoncode.dev/oauth/callback?code=[REDACTED]&state=[REDACTED]", + ); + }); + + it("scrubs referrer in report-to format", async () => { + const body = JSON.stringify([ + { + type: "csp-violation", + body: { + documentURL: "https://gh.gordoncode.dev/dashboard", + referrer: "https://gh.gordoncode.dev/oauth/callback?code=secret&state=xyz", + blockedURL: "inline", + }, + }, + ]); + const req = makeCspRequest(body, "application/reports+json"); + await worker.fetch(req, makeEnv()); + + const sentryBody = JSON.parse(mockFetch.mock.calls[0][1].body as string); + expect(sentryBody["csp-report"]["referrer"]).toBe( + "https://gh.gordoncode.dev/oauth/callback?code=[REDACTED]&state=[REDACTED]", + ); + }); + + it("caps batch fan-out to 20 reports", async () => { + const violations = Array.from({ length: 25 }, (_, i) => ({ + type: "csp-violation", + body: { documentURL: `https://gh.gordoncode.dev/page${i}`, blockedURL: "inline" }, + })); + const req = makeCspRequest(JSON.stringify(violations), "application/reports+json"); + await worker.fetch(req, makeEnv()); + + expect(mockFetch).toHaveBeenCalledTimes(20); + }); + + it("preserves non-sensitive fields unchanged", async () => { + const body = JSON.stringify({ + "csp-report": { + "document-uri": "https://gh.gordoncode.dev/dashboard?tab=issues", + "blocked-uri": "https://cdn.example.com/font.woff2", + "violated-directive": "font-src 'self'", + "original-policy": "font-src 'self'", + "referrer": "", + "status-code": 200, + }, + }); + const req = makeCspRequest(body); + await worker.fetch(req, makeEnv()); + + const sentryBody = JSON.parse(mockFetch.mock.calls[0][1].body as string); + const report = sentryBody["csp-report"]; + expect(report["document-uri"]).toBe("https://gh.gordoncode.dev/dashboard?tab=issues"); + expect(report["violated-directive"]).toBe("font-src 'self'"); + expect(report["original-policy"]).toBe("font-src 'self'"); + expect(report["status-code"]).toBe(200); + }); +});