Files
Leon SerfatyandClaude Opus 5 1d02598786 chore: sync in-progress work across marketing, admin, API and tests
Snapshot of uncommitted work that had accumulated in the tree alongside
the Turnstile changes:

- marketing pages, SEO helpers (lib/seo.ts, lib/marketing/) and
  structured data
- admin billing actions and a per-user portfolio view, plus an admin
  error boundary
- rate limiting (lib/rate-limit.ts) applied across the /api/v1 surface
- CSP and proxy adjustments, accounting/webhook lib updates
- Playwright config and an e2e/unit test suite
- next bumped to ^16.3.4 with the lockfile regenerated
- generated AGENTS.md / CLAUDE.md

Authored by other sessions working in this tree; committed here so the
Turnstile work could be pushed without leaving the tree dirty.
Typecheck passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 16:27:16 -04:00

85 lines
3.2 KiB
TypeScript

import { test, expect } from "@playwright/test"
import { hit, clientIp } from "@/lib/rate-limit"
// The limiter is module-level state shared across the process, so every test
// uses a unique key prefix to stay independent of the others.
let n = 0
const key = (label: string) => `test:${label}:${++n}:${Math.random()}`
test.describe("hit()", () => {
test("allows requests up to the limit and rejects the one after", () => {
const k = key("basic")
for (let i = 0; i < 5; i++) {
expect(hit(k, 5, 60).ok, `request ${i + 1} of 5 should be allowed`).toBe(true)
}
expect(hit(k, 5, 60).ok, "the 6th request should be rejected").toBe(false)
})
test("reports remaining budget accurately", () => {
const k = key("remaining")
expect(hit(k, 3, 60).remaining).toBe(2)
expect(hit(k, 3, 60).remaining).toBe(1)
expect(hit(k, 3, 60).remaining).toBe(0)
// Over the limit, remaining stays clamped at zero rather than going negative.
expect(hit(k, 3, 60).remaining).toBe(0)
})
test("keeps separate keys independent", () => {
const a = key("iso-a")
const b = key("iso-b")
for (let i = 0; i < 3; i++) hit(a, 3, 60)
expect(hit(a, 3, 60).ok, "key A is exhausted").toBe(false)
expect(hit(b, 3, 60).ok, "key B is untouched").toBe(true)
})
test("resets after the window elapses", async () => {
const k = key("window")
// A 1-second window so the test can actually wait it out.
expect(hit(k, 1, 1).ok).toBe(true)
expect(hit(k, 1, 1).ok).toBe(false)
await new Promise((r) => setTimeout(r, 1100))
expect(hit(k, 1, 1).ok, "a fresh window should allow requests again").toBe(true)
})
test("returns a retryAfter of at least one second while limited", () => {
const k = key("retry")
hit(k, 1, 60)
const limited = hit(k, 1, 60)
expect(limited.ok).toBe(false)
expect(limited.retryAfter).toBeGreaterThan(0)
expect(limited.retryAfter).toBeLessThanOrEqual(60)
})
test("reports the configured limit back to the caller", () => {
expect(hit(key("limit"), 42, 60).limit).toBe(42)
})
})
test.describe("clientIp()", () => {
const req = (headers: Record<string, string>) => new Request("https://x.test", { headers })
test("takes the first entry of x-forwarded-for", () => {
expect(clientIp(req({ "x-forwarded-for": "1.2.3.4, 5.6.7.8, 9.9.9.9" }))).toBe("1.2.3.4")
})
test("trims whitespace around the client address", () => {
expect(clientIp(req({ "x-forwarded-for": " 1.2.3.4 , 5.6.7.8" }))).toBe("1.2.3.4")
})
test("falls back to x-real-ip when x-forwarded-for is absent", () => {
expect(clientIp(req({ "x-real-ip": "8.8.8.8" }))).toBe("8.8.8.8")
})
test("degrades to a single shared bucket rather than no limit at all", () => {
// The important property: a request with no proxy headers must still land in
// SOME bucket. Returning a unique value per request would silently disable
// the limit for anyone who can strip headers.
expect(clientIp(req({}))).toBe("unknown")
expect(clientIp(req({}))).toBe("unknown")
})
test("ignores an empty x-forwarded-for and falls through", () => {
expect(clientIp(req({ "x-forwarded-for": "", "x-real-ip": "8.8.4.4" }))).toBe("8.8.4.4")
})
})