diff --git a/public/_headers b/public/_headers
index 2d5edabe..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' 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' 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 8ea6bcc6..f6f5aa10 100644
--- a/src/app/App.tsx
+++ b/src/app/App.tsx
@@ -1,17 +1,43 @@
-import { createSignal, createEffect, onMount, Show, 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 } 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 handleRouteError(err: unknown) {
+ console.error("[app] Route render failed:", err);
+ return ;
+}
+
+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 +164,33 @@ 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)) {
+ import("./components/dashboard/DashboardPage").catch(() => {
+ console.warn("[app] Dashboard chunk preload failed");
+ });
+ }
});
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..824c5ecb 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,19 @@ 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 = () => {
+ import("../components/dashboard/DashboardPage").catch(() => {
+ console.warn("[app] Dashboard chunk prefetch failed");
+ });
+ };
+ "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/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 260d829a..21501722 100644
--- a/tests/components/App.test.tsx
+++ b/tests/components/App.test.tsx
@@ -51,14 +51,26 @@ 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: () => Dashboard
,
+ default: () => {
+ if (dashboardShouldThrow) throw new Error("chunk load failed");
+ return Dashboard
;
+ },
}));
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";
@@ -73,6 +85,9 @@ describe("App", () => {
vi.resetAllMocks();
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
@@ -159,4 +174,84 @@ 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();
+ });
+
+ 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);
+ });
+});