Skip to content

test(csp): cover the csp-report collector's route logic + bound its invocation - #2709

Merged
innolope-dev merged 2 commits into
devfrom
fix/csp-report-route-tests
Aug 18, 2026
Merged

test(csp): cover the csp-report collector's route logic + bound its invocation#2709
innolope-dev merged 2 commits into
devfrom
fix/csp-report-route-tests

Conversation

@innolope-dev

Copy link
Copy Markdown
Collaborator

Closes #2543 — the two follow-ups deliberately left out of #2519.

1. Route-level test coverage

src/app/api/csp-report/route.ts held the subtlest logic in the feature and had no tests. This adds 37, and extracts the part worth isolating.

Extraction, as the issue described it. The per-batch selection loop moves out of the handler into a pure selectReportsToForward(reports, shouldForward) in csp-report.utils.ts, taking the predicate as a parameter. seenGroups and the 1% sampling stay in the route — no module-level mutable state moves into a utils file. The handler is now two lines where the loop was.

12 unit tests for the loop:

  • intra-batch de-duplication (asks about each distinct group once, keeps the first report of it)
  • the cap at MAX_FORWARDS_PER_REQUEST distinct groups
  • ignored (extension-scheme) reports never costing a cap slot
  • the ordering invariant, stated directly: it never asks the predicate about a group it cannot send. The predicate records what it is asked about, so asking past the cap marks a group seen that was never sent — first sighting lost, every later repeat sampled away at 1%. That was the original Major on fix(csp): close the wss allow-list gap and stop the Sentry report flood #2519.

25 route tests for what only the route holds:

  • the always-204 contract, including a body that is not JSON and an unreachable Sentry
  • content-type gating: parameters/case/padding accepted, and a rejected type never reads the body
  • DSN derivation, plus the missing and malformed DSN paths
  • the User-Agent / X-Forwarded-For pass-through (and that empty ones are omitted, not forwarded blank)
  • 1% duplicate sampling
  • oldest-group eviction at SEEN_GROUPS_MAX — asserting one group is dropped rather than all 500, which a clear() would otherwise pass unnoticed

seenGroups outlives a request, so each test takes a fresh module via jest.isolateModules + require (the pattern from auth-token.test.ts), with Math.random pinned so "forwarded" always means "first sighting".

Mutation-tested rather than trusted green: moving the cap after shouldForward fails 5 tests, dropping the intra-batch de-duplication fails 1, replacing oldest-eviction with clear() fails 1.

2. Rate limit

The control is a Vercel WAF rate-limit rule on /api/csp-report — it runs before the function and holds across instances, where an in-memory limiter on a force-dynamic route resets every cold start and misses a distributed source entirely. That is a dashboard change, not a code one, so this PR records the rule (path, ~100 req/60s per IP, action deny) next to the code that needs it and does the code half:

export const maxDuration = 10. The handler is one JSON parse plus at most 20 parallel fetches already capped at 3s each, so it can never legitimately need more.

One correction to the issue's premise: vercel.json's maxDuration: 300 does not currently apply to this route. Its glob is app/api/** while this project's routes live under src/app/api/, which Vercel does not match — already documented at SERVER_FETCH_TIMEOUT_MS in sentry.utils.ts. The real ceiling is the unverifiable project default, which is the better reason to declare one explicitly.

Verification

  • 75/75 csp tests pass; full suite 2964 passed (the 4 failures are src/content being uninitialized in a fresh worktree, unrelated)
  • prettier --check clean, eslint clean on all four files, tsc --noEmit adds no errors

No behaviour change to what gets forwarded — the selection logic is moved verbatim.

…ract

The collector's subtlest logic lived inline in the route handler and had no
tests: cap-before-de-duplicate ordering, intra-batch de-duplication, oldest-
group eviction, the 1% duplicate sampling, content-type gating and the
always-204 contract.

Extract the per-batch selection into a pure selectReportsToForward() taking
shouldForward as a parameter, so the loop's invariants are testable without
moving the route's module-level seenGroups into a utils file. The route keeps
that state and its sampling.

Pins in particular that a group the per-request cap turned away is never
recorded as seen — getting that backwards silently loses a group's first
sighting and samples every later repeat away at 1%.

Refs #2543
…F rule

The endpoint is unauthenticated, so one POST can drive up to 20 outbound
fetches on an invocation whose ceiling is a project default measured in
minutes. maxDuration = 10 bounds that; the handler is one JSON parse plus
those fetches, each already capped at 3s.

The actual rate limit belongs in the Vercel WAF, which runs before the
function and holds across instances — an in-memory limiter on a force-dynamic
route resets on every cold start and misses any distributed source. That is a
dashboard change, so the rule is recorded next to the code that needs it.

Refs #2543
@innolope-dev innolope-dev self-assigned this Aug 17, 2026
@vercel

vercel Bot commented Aug 17, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
peanut-wallet Ready Ready Preview Aug 17, 2026 7:35pm

Request Review

@coderabbitai

coderabbitai Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 430c285e-e801-49b9-bc13-936c400b87f8

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

Code-analysis diff

Painscore total: 7159.84 → 7159.4 (-0.44)
Findings: -1 net (+3 new, -4 resolved)

🆕 New findings (3)

  • high complexity — src/utils/csp-report.utils.ts — CC 37, MI 59.26, SLOC 107
  • medium complexity — src/app/api/csp-report/route.ts — CC 11, MI 55.87, SLOC 63
  • low high-mdd — src/utils/csp-report.utils.ts:200 — selectReportsToForward: MDD 10.1 (uses across many lines from declarations)

✅ Resolved (4)

  • src/utils/csp-report.utils.ts — CC 32, MI 60.73, SLOC 87
  • src/app/api/csp-report/route.ts — CC 15, MI 52.7, SLOC 78
  • src/app/api/csp-report/route.ts:75 — POST: DLT 19 (calls 19 distinct functions — high context load)
  • src/app/api/csp-report/route.ts:75 — POST: MDD 14.0 (uses across many lines from declarations)

📈 Painscore deltas (top movers)

File Before After Δ
src/utils/csp-report.utils.ts 6.8 7.4 +0.7
src/app/api/csp-report/route.ts 10.1 9.0 -1.1

@github-actions

Copy link
Copy Markdown
Contributor

🧪 UI test report — ✅ all green

Suites

  • unit: 3100 ran, 0 failed, 0 skipped, 51.7s

📊 Coverage (unit)

metric %
statements 67.0%
branches 52.1%
functions 57.2%
lines 67.8%
⏱ 10 slowest test cases
time test
3.7s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › never places two stickers in heavy overlap (broad seed sweep)
1.3s src/utils/__tests__/demo-api.test.ts › isDemoMode() is false when not running under Capacitor
0.4s src/components/Card/share-asset/__tests__/shareAssetLayout.test.ts › every sticker stays within canvas at any count
0.3s src/app/actions/__tests__/api-headers.test.ts › should include Content-Type in validateInviteCode
0.3s src/utils/__tests__/sentry.utils.test.ts › defaults to the client budget under a browser global
0.3s src/utils/__tests__/auth-token.test.ts › ignores the guarded marker and falls back to the plain token
0.3s src/utils/__tests__/auth-token.test.ts › is none — never guarded — when only the guarded marker is present
0.3s src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx › Bank withdrawal keeps the $1 minimum for sub-$1 amounts
0.3s src/utils/__tests__/sentry.utils.test.ts › still lets a per-call timeoutMs win over the default
0.3s src/utils/__tests__/auth-token.test.ts › authReady does not park — hydrates the plain token without an unlock
📍 Inline annotations are in the **Unit test report** check above. Coverage artifact: `coverage-unit`. Generated by `.github/workflows/tests.yml`.

@innolope-dev
innolope-dev merged commit 4b33f92 into dev Aug 18, 2026
20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant