From 7753a3b8c05c13e7558b36c92372ebfc0a5a0e99 Mon Sep 17 00:00:00 2001 From: dcccrypto Date: Fri, 24 Jul 2026 17:41:55 +0100 Subject: [PATCH] chore(tests): remove tests orphaned by the Phase A feature removal Phase A (7f5b5121, "cut marketing cruft, lock devnet, decouple Supabase") deliberately removed the waitlist/launch/guide/bugs/ideas/applications routes and lib/waitlist/, but left their test files behind. All 12 reference source that no longer exists, so they fail at import/collection and assert nothing: bugs-proxy, bugs-trusted-client-ip -> app/api/bugs/route launch-invalid-json-guard -> app/api/launch/route trusted-ip-rate-limit-headers -> app/api/ideas, app/api/applications waitlist-signup-{bot-ua,ip,shape} -> app/api/waitlist/signup/route Guide -> app/guide/page waitlist-{client-ip,disposable-domains,referral-code,turnstile} -> lib/waitlist/* They provide zero coverage (they cannot import the module under test), and they are the entire remainder of playground's failing suite once the open test-repair PRs land. Verified: full suite on playground goes 87 failed -> 61 failed, and the 61 are exactly the failures the open PRs (#2451/#2452/#2453/#2455/#2456) repair. If any of these features returns, its tests return with it via git history. --- app/__tests__/api/bugs-proxy.test.ts | 194 ----------- .../api/bugs-trusted-client-ip.test.ts | 16 - .../api/launch-invalid-json-guard.test.ts | 39 --- .../api/trusted-ip-rate-limit-headers.test.ts | 27 -- .../api/waitlist-signup-bot-ua.test.ts | 33 -- app/__tests__/api/waitlist-signup-ip.test.ts | 160 --------- .../api/waitlist-signup-shape.test.ts | 138 -------- app/__tests__/components/Guide.test.tsx | 314 ------------------ app/__tests__/lib/waitlist-client-ip.test.ts | 186 ----------- .../lib/waitlist-disposable-domains.test.ts | 117 ------- .../lib/waitlist-referral-code.test.ts | 60 ---- app/__tests__/lib/waitlist-turnstile.test.ts | 129 ------- 12 files changed, 1413 deletions(-) delete mode 100644 app/__tests__/api/bugs-proxy.test.ts delete mode 100644 app/__tests__/api/bugs-trusted-client-ip.test.ts delete mode 100644 app/__tests__/api/launch-invalid-json-guard.test.ts delete mode 100644 app/__tests__/api/trusted-ip-rate-limit-headers.test.ts delete mode 100644 app/__tests__/api/waitlist-signup-bot-ua.test.ts delete mode 100644 app/__tests__/api/waitlist-signup-ip.test.ts delete mode 100644 app/__tests__/api/waitlist-signup-shape.test.ts delete mode 100644 app/__tests__/components/Guide.test.tsx delete mode 100644 app/__tests__/lib/waitlist-client-ip.test.ts delete mode 100644 app/__tests__/lib/waitlist-disposable-domains.test.ts delete mode 100644 app/__tests__/lib/waitlist-referral-code.test.ts delete mode 100644 app/__tests__/lib/waitlist-turnstile.test.ts diff --git a/app/__tests__/api/bugs-proxy.test.ts b/app/__tests__/api/bugs-proxy.test.ts deleted file mode 100644 index 9801f5b7d..000000000 --- a/app/__tests__/api/bugs-proxy.test.ts +++ /dev/null @@ -1,194 +0,0 @@ -/** - * Tests for /api/bugs proxy route. - * - * GET /api/bugs — auth-gated proxy (x-api-key required) - * POST /api/bugs — public proxy (IP forwarding for per-IP rate limiting) - * - * Business logic (rate limiting, sanitisation, DB writes) lives in - * percolator-api. These tests verify the proxy wrapper behaves correctly. - */ - -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { NextRequest } from "next/server"; -import { NextResponse } from "next/server"; - -// Mock api-proxy so no real network calls are made -vi.mock("@/lib/api-proxy", () => ({ - proxyToApi: vi.fn(), -})); - -// Mock api-auth to control auth check results -vi.mock("@/lib/api-auth", () => ({ - requireAuth: vi.fn(), - UNAUTHORIZED: NextResponse.json({ error: "Unauthorized" }, { status: 401 }), -})); - -import { proxyToApi } from "@/lib/api-proxy"; -import { requireAuth } from "@/lib/api-auth"; -import { GET, POST } from "../../app/api/bugs/route"; - -function makeGetReq(apiKey?: string): NextRequest { - const headers: Record = { "Content-Type": "application/json" }; - if (apiKey) headers["x-api-key"] = apiKey; - return new NextRequest("http://localhost/api/bugs", { headers }); -} - -function makePostReq(body: object, ip = "1.2.3.4"): NextRequest { - return new NextRequest("http://localhost/api/bugs", { - method: "POST", - headers: { - "Content-Type": "application/json", - "x-forwarded-for": ip, - }, - body: JSON.stringify(body), - }); -} - -describe("GET /api/bugs", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("returns 401 when auth check fails (no proxy call)", async () => { - vi.mocked(requireAuth).mockReturnValue(false); - - const res = await GET(makeGetReq()); - expect(res.status).toBe(401); - expect(vi.mocked(proxyToApi)).not.toHaveBeenCalled(); - }); - - it("proxies to /bugs when auth passes, forwarding x-api-key", async () => { - vi.mocked(requireAuth).mockReturnValue(true); - const mockBugs = [{ id: 1, title: "test bug", severity: "medium" }]; - vi.mocked(proxyToApi).mockResolvedValue( - NextResponse.json(mockBugs, { status: 200 }) - ); - - const res = await GET(makeGetReq("my-api-key")); - expect(res.status).toBe(200); - const body = await res.json(); - expect(body).toHaveLength(1); - expect(vi.mocked(proxyToApi)).toHaveBeenCalledOnce(); - expect(vi.mocked(proxyToApi)).toHaveBeenCalledWith( - expect.any(Object), // req - "/bugs", - { "x-api-key": "my-api-key" } - ); - }); - - it("forwards empty x-api-key when header is absent", async () => { - vi.mocked(requireAuth).mockReturnValue(true); - vi.mocked(proxyToApi).mockResolvedValue( - NextResponse.json([], { status: 200 }) - ); - - await GET(makeGetReq()); // no key in headers - expect(vi.mocked(proxyToApi)).toHaveBeenCalledWith( - expect.any(Object), - "/bugs", - { "x-api-key": "" } - ); - }); - - it("forwards 502 when backend is unreachable", async () => { - vi.mocked(requireAuth).mockReturnValue(true); - vi.mocked(proxyToApi).mockResolvedValue( - NextResponse.json({ error: "Backend unavailable" }, { status: 502 }) - ); - - const res = await GET(makeGetReq("key")); - expect(res.status).toBe(502); - }); -}); - -describe("POST /api/bugs", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("proxies to /bugs with body and x-real-ip from x-forwarded-for", async () => { - vi.mocked(proxyToApi).mockResolvedValue( - NextResponse.json({ ok: true }, { status: 201 }) - ); - - const req = makePostReq( - { twitter_handle: "alice", title: "bug", description: "details", severity: "low" }, - "203.0.113.1" - ); - const res = await POST(req); - expect(res.status).toBe(201); - expect(vi.mocked(proxyToApi)).toHaveBeenCalledOnce(); - expect(vi.mocked(proxyToApi)).toHaveBeenCalledWith( - expect.any(Object), - "/bugs", - { "x-real-ip": "203.0.113.1" }, - { includeBody: true } - ); - }); - - it("uses x-real-ip header directly when x-forwarded-for is absent", async () => { - vi.mocked(proxyToApi).mockResolvedValue( - NextResponse.json({ ok: true }, { status: 201 }) - ); - - const req = new NextRequest("http://localhost/api/bugs", { - method: "POST", - headers: { - "Content-Type": "application/json", - "x-real-ip": "198.51.100.5", - }, - body: JSON.stringify({ twitter_handle: "bob", title: "b", description: "d", severity: "high" }), - }); - - await POST(req); - expect(vi.mocked(proxyToApi)).toHaveBeenCalledWith( - expect.any(Object), - "/bugs", - { "x-real-ip": "198.51.100.5" }, - { includeBody: true } - ); - }); - - it("uses 'unknown' as IP when neither forwarding header is present", async () => { - vi.mocked(proxyToApi).mockResolvedValue( - NextResponse.json({ ok: true }, { status: 201 }) - ); - - const req = new NextRequest("http://localhost/api/bugs", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ twitter_handle: "t", title: "t", description: "d", severity: "low" }), - }); - - await POST(req); - expect(vi.mocked(proxyToApi)).toHaveBeenCalledWith( - expect.any(Object), - "/bugs", - { "x-real-ip": "unknown" }, - { includeBody: true } - ); - }); - - it("forwards 429 from backend when IP is rate-limited", async () => { - vi.mocked(proxyToApi).mockResolvedValue( - NextResponse.json( - { error: "Rate limited — max 3 bug reports per hour" }, - { status: 429 } - ) - ); - - const req = makePostReq({ twitter_handle: "t", title: "t", description: "d", severity: "low" }); - const res = await POST(req); - expect(res.status).toBe(429); - }); - - it("forwards 400 validation errors from backend", async () => { - vi.mocked(proxyToApi).mockResolvedValue( - NextResponse.json({ error: "Title required (max 120 chars)" }, { status: 400 }) - ); - - const req = makePostReq({ twitter_handle: "t" }); // missing required fields - const res = await POST(req); - expect(res.status).toBe(400); - }); -}); diff --git a/app/__tests__/api/bugs-trusted-client-ip.test.ts b/app/__tests__/api/bugs-trusted-client-ip.test.ts deleted file mode 100644 index d6814a7ec..000000000 --- a/app/__tests__/api/bugs-trusted-client-ip.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { readFileSync } from "node:fs"; -import { resolve } from "node:path"; - -describe("POST /api/bugs trusted client IP forwarding", () => { - it("uses getClientIp rather than parsing first forwarded hop", () => { - const source = readFileSync( - resolve(__dirname, "../../app/api/bugs/route.ts"), - "utf8", - ); - - expect(source).toContain('import { getClientIp } from "@/lib/get-client-ip"'); - expect(source).toContain("const ip = getClientIp(req);"); - expect(source).not.toContain('x-forwarded-for")?.split(",")[0]'); - }); -}); diff --git a/app/__tests__/api/launch-invalid-json-guard.test.ts b/app/__tests__/api/launch-invalid-json-guard.test.ts deleted file mode 100644 index 359308630..000000000 --- a/app/__tests__/api/launch-invalid-json-guard.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { NextRequest } from "next/server"; - -async function loadPostHandler() { - vi.resetModules(); - - vi.doMock("@/lib/create-market-rate-limit", () => ({ - CREATE_MARKET_RATE_LIMIT: 5, - checkLaunchRateLimit: vi.fn().mockResolvedValue({ allowed: true, retryAfterSecs: 0 }), - })); - - vi.doMock("@/lib/get-client-ip", () => ({ - getClientIp: () => "1.2.3.4", - })); - - const mod = await import("@/app/api/launch/route"); - return mod.POST as (req: NextRequest) => Promise; -} - -describe("POST /api/launch invalid JSON guard", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("returns 400 for malformed JSON", async () => { - const POST = await loadPostHandler(); - const req = new NextRequest("http://localhost/api/launch", { - method: "POST", - body: "{", - headers: { "content-type": "application/json" }, - }); - - const res = await POST(req); - const body = await res.json(); - - expect(res.status).toBe(400); - expect(body.error).toMatch(/Invalid JSON body/i); - }); -}); diff --git a/app/__tests__/api/trusted-ip-rate-limit-headers.test.ts b/app/__tests__/api/trusted-ip-rate-limit-headers.test.ts deleted file mode 100644 index d6f9703ee..000000000 --- a/app/__tests__/api/trusted-ip-rate-limit-headers.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { readFileSync } from "node:fs"; -import { resolve } from "node:path"; - -describe("trusted proxy-aware rate-limit identity", () => { - it("uses getClientIp in /api/ideas POST", () => { - const source = readFileSync( - resolve(__dirname, "../../app/api/ideas/route.ts"), - "utf8", - ); - - expect(source).toContain('import { getClientIp } from "@/lib/get-client-ip"'); - expect(source).toContain("const ip = getClientIp(req);"); - expect(source).not.toContain('x-forwarded-for")?.split(",")[0]'); - }); - - it("uses getClientIp in /api/applications POST", () => { - const source = readFileSync( - resolve(__dirname, "../../app/api/applications/route.ts"), - "utf8", - ); - - expect(source).toContain('import { getClientIp } from "@/lib/get-client-ip"'); - expect(source).toContain("const ip = getClientIp(req);"); - expect(source).not.toContain('x-forwarded-for")?.split(",")[0]'); - }); -}); diff --git a/app/__tests__/api/waitlist-signup-bot-ua.test.ts b/app/__tests__/api/waitlist-signup-bot-ua.test.ts deleted file mode 100644 index 278c4fc08..000000000 --- a/app/__tests__/api/waitlist-signup-bot-ua.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Waitlist signup — bot user-agent gate. - * - * Complements the Turnstile + rate-limit defenses: a cheap pre-filter that - * rejects scripted clients (python/curl/aiohttp/etc. + the wave's hardcoded - * Chrome/120 spoof + missing/stub UAs) before the network Turnstile verify. - * Real browsers and wallet in-app browsers never match. - */ -import { describe, it, expect } from "vitest"; -import * as fs from "fs"; -import * as path from "path"; - -const ROUTE = path.resolve(__dirname, "../../app/api/waitlist/signup/route.ts"); - -describe("/api/waitlist/signup bot user-agent gate", () => { - const source = fs.readFileSync(ROUTE, "utf8"); - - it("defines a bot user-agent matcher covering scripted clients", () => { - expect(source).toContain("isBotUserAgent"); - expect(source).toMatch(/python|aiohttp|requests|urllib|curl/i); - }); - - it("blocks the hardcoded Chrome/120 spoof (escape hatch via env)", () => { - expect(source).toMatch(/Chrome\\\/120\\\.0\\\.0\\\.0 Safari/); - expect(source).toContain("WAITLIST_ALLOW_CHROME120"); - }); - - it("invokes the gate in the handler and rejects with 403", () => { - expect(source).toMatch(/if \(isBotUserAgent\(userAgent\)\)/); - const idx = source.indexOf("if (isBotUserAgent(userAgent))"); - expect(source.slice(idx, idx + 200)).toMatch(/status:\s*403/); - }); -}); diff --git a/app/__tests__/api/waitlist-signup-ip.test.ts b/app/__tests__/api/waitlist-signup-ip.test.ts deleted file mode 100644 index 441607dcc..000000000 --- a/app/__tests__/api/waitlist-signup-ip.test.ts +++ /dev/null @@ -1,160 +0,0 @@ -/** - * Source-pattern guards for the waitlist signup route's IP capture, - * per-IP rate limit, and Cloudflare Turnstile gate. Mirrors the - * existing waitlist-signup-shape style — greps the route source so a - * future refactor can't silently drop a write, lose a gate, or - * reorder them in a way that defeats the design (e.g. moving the - * captcha verify AFTER the IP rate limit would let bots burn the - * limiter without solving the challenge). - */ - -import { describe, it, expect } from "vitest"; -import * as fs from "fs"; -import * as path from "path"; - -const ROUTE_PATH = path.resolve( - __dirname, - "../../app/api/waitlist/signup/route.ts", -); - -describe("/api/waitlist/signup IP capture + rate limit", () => { - it("imports getClientIp + hashIp from the dedicated helper module", () => { - const source = fs.readFileSync(ROUTE_PATH, "utf8"); - expect(source).toContain( - `import { getClientIp, hashIp } from "@/lib/waitlist/client-ip"`, - ); - }); - - it("extracts the client IP from the request headers", () => { - const source = fs.readFileSync(ROUTE_PATH, "utf8"); - expect(source).toMatch(/const\s+clientIp\s*=\s*getClientIp\(req\.headers\)/); - }); - - it("derives the IP hash with the WAITLIST_IP_SALT env var", () => { - const source = fs.readFileSync(ROUTE_PATH, "utf8"); - expect(source).toContain( - "hashIp(clientIp, process.env.WAITLIST_IP_SALT)", - ); - }); - - it("writes ip_address and ip_hash onto the inserted row", () => { - const source = fs.readFileSync(ROUTE_PATH, "utf8"); - expect(source).toMatch(/baseRow\.ip_address\s*=\s*clientIp/); - expect(source).toMatch(/baseRow\.ip_hash\s*=\s*clientIpHash/); - }); - - it("rate-limits per IP and returns 429 when the cap is exceeded", () => { - const source = fs.readFileSync(ROUTE_PATH, "utf8"); - expect(source).toContain("getIpLimiter()"); - expect(source).toMatch( - /too many signups from this network — try again later/, - ); - expect(source).toMatch(/status:\s*429/); - }); - - it("places the per-IP rate limit BEFORE the sign-in fast-path", () => { - // Earlier the fast-path ran first to spare honest refreshers from - // the per-IP budget, but that left the Supabase service-role - // SELECT inside the fast-path reachable without consuming captcha - // or rate-limit — an attacker with locally-generated ed25519 - // keys could fire valid-shape signups and hammer the DB. The - // gates now run BEFORE the fast-path; the UX cost (one captcha - // solve per session for returning users) is much smaller than - // the security cost of the bypass. - const source = fs.readFileSync(ROUTE_PATH, "utf8"); - const fastPathIdx = source.indexOf("Sign-in fast path for existing"); - const ipLimitIdx = source.indexOf("Per-IP rate limit"); - expect(fastPathIdx).toBeGreaterThan(0); - expect(ipLimitIdx).toBeGreaterThan(0); - expect(ipLimitIdx).toBeLessThan(fastPathIdx); - }); - - it("places the Turnstile gate BEFORE the sign-in fast-path", () => { - // Same rationale as the per-IP rate-limit ordering test above: - // the fast-path's Supabase service-role read must be gated on - // captcha so a locally-generated valid signature isn't a free - // DB-probe oracle. - const source = fs.readFileSync(ROUTE_PATH, "utf8"); - const fastPathIdx = source.indexOf("Sign-in fast path for existing"); - const captchaIdx = source.indexOf("Cloudflare Turnstile gate"); - expect(fastPathIdx).toBeGreaterThan(0); - expect(captchaIdx).toBeGreaterThan(0); - expect(captchaIdx).toBeLessThan(fastPathIdx); - }); - - it("uses sha256 of the raw IP as the Redis key, never the cleartext", () => { - const source = fs.readFileSync(ROUTE_PATH, "utf8"); - // The ipRateKey helper hashes the IP before it becomes the Redis - // key. The route must call that helper, not pass the raw IP. - expect(source).toContain("ipRateKey(clientIp)"); - expect(source).toMatch( - /createHash\("sha256"\)\.update\(ip\)\.digest\("hex"\)/, - ); - }); - - it("verifies the Turnstile token before any other expensive work", () => { - const source = fs.readFileSync(ROUTE_PATH, "utf8"); - expect(source).toContain( - `import { verifyTurnstile } from "@/lib/waitlist/turnstile"`, - ); - expect(source).toMatch(/await verifyTurnstile\(turnstileToken, clientIp\)/); - }); - - it("rejects with 400 when the captcha verdict is not ok", () => { - const source = fs.readFileSync(ROUTE_PATH, "utf8"); - expect(source).toContain("captcha required"); - expect(source).toContain("captcha verification failed"); - // The error body carries a `captcha: reason` field so the UI can - // tell "missing token" apart from "Cloudflare said no". - expect(source).toMatch(/captcha:\s*turnstileVerdict\.reason/); - }); - - it("places the Turnstile gate BEFORE the per-IP rate limit", () => { - // Order matters: a bad token must short-circuit BEFORE we consume - // any Upstash budget. If the rate limit ran first an attacker - // could burn the per-IP cap with garbage tokens and DoS legitimate - // signups from the same network. - const source = fs.readFileSync(ROUTE_PATH, "utf8"); - const captchaIdx = source.indexOf("Cloudflare Turnstile gate"); - const rateLimitIdx = source.indexOf("Per-IP rate limit"); - expect(captchaIdx).toBeGreaterThan(0); - expect(rateLimitIdx).toBeGreaterThan(0); - expect(captchaIdx).toBeLessThan(rateLimitIdx); - }); - - it("rate-limits per referral code in addition to per IP", () => { - const source = fs.readFileSync(ROUTE_PATH, "utf8"); - // Helper module shape mirrors the email + IP limiter pattern. - expect(source).toContain("getRefCodeLimiter()"); - expect(source).toMatch(/refCodeLimiter\.limit\(referredByCode\)/); - expect(source).toContain( - "this referral code is at its hourly cap", - ); - expect(source).toMatch(/status:\s*429/); - }); - - it("places the refcode rate-limit AFTER the existence check", () => { - // An invalid code must be rejected before it consumes any budget — - // otherwise an attacker could submit many invalid codes from one - // IP to exhaust the rate-limit slots of arbitrary codes they - // happened to guess. Pin the ordering. - // - // The marker is the call expression itself — it appears only at - // the in-handler check, not in any helper-block comments above. - const source = fs.readFileSync(ROUTE_PATH, "utf8"); - const existenceIdx = source.indexOf("waitlist_referral_code_exists"); - const refLimitIdx = source.indexOf("refCodeLimiter.limit(referredByCode)"); - expect(existenceIdx).toBeGreaterThan(0); - expect(refLimitIdx).toBeGreaterThan(0); - expect(refLimitIdx).toBeGreaterThan(existenceIdx); - }); - - it("wraps the refcode limit() call in try/catch (fail-open)", () => { - // Matches the per-IP limiter posture — a transient Upstash blip - // shouldn't bubble up as an unhandled 500. - const source = fs.readFileSync(ROUTE_PATH, "utf8"); - expect(source).toMatch( - /refcode rate-limit check failed, falling open/, - ); - }); -}); diff --git a/app/__tests__/api/waitlist-signup-shape.test.ts b/app/__tests__/api/waitlist-signup-shape.test.ts deleted file mode 100644 index 747aedcd8..000000000 --- a/app/__tests__/api/waitlist-signup-shape.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -/** - * Waitlist signup input-shape rejection. - * - * The route used to accept three shapes: email-only, wallet-only, and a - * combined email+wallet shape that skipped the on-chain mainnet check on - * the assumption that Privy's OTP gate proved real intent. The server - * never actually verified anything from Privy, so the combined shape let - * any caller bind an arbitrary email to a self-controlled keypair, and a - * downstream silent 23505 swallow then made a victim's later email-only - * signup look successful while their pubkey was never persisted. - * - * The fix rejects the combined shape outright. This test guards the - * route source so a future "let's bring back combined" refactor can't - * land without tripping the assertion. - */ - -import { describe, it, expect } from "vitest"; -import * as fs from "fs"; -import * as path from "path"; - -const ROUTE_PATH = path.resolve( - __dirname, - "../../app/api/waitlist/signup/route.ts", -); - -describe("/api/waitlist/signup input shape", () => { - it("rejects the combined email + wallet shape with 400", () => { - const source = fs.readFileSync(ROUTE_PATH, "utf8"); - - expect(source).toMatch(/if\s*\(\s*hasEmail\s*&&\s*hasWalletPart\s*\)/); - // The 400 status must live inside that branch, not somewhere else. - const combinedBranch = source - .split("if (hasEmail && hasWalletPart)")[1] - ?.split("if (!hasEmail && !hasWalletPart)")[0]; - expect(combinedBranch).toBeDefined(); - expect(combinedBranch).toContain("status: 400"); - }); - - it("requires either an email OR a wallet signature, not both", () => { - const source = fs.readFileSync(ROUTE_PATH, "utf8"); - expect(source).toContain('"provide an email or a wallet signature"'); - // The pre-fix copy ("…or both") must be gone. - expect(source).not.toContain('"provide an email, a wallet signature, or both"'); - }); - - it("runs the mainnet existence check unconditionally on the wallet path", () => { - const source = fs.readFileSync(ROUTE_PATH, "utf8"); - // Pre-fix the call sat behind `if (!hasEmail) { … }`. Post-fix the - // wrapper is gone. Confirm both: the call still exists and the - // wrapper guard does not. - expect(source).toContain("walletExistsOnMainnet(pubkey!)"); - expect(source).not.toMatch(/if\s*\(\s*!hasEmail\s*\)\s*\{\s*const\s+exists\s*=\s*await\s+walletExistsOnMainnet/); - }); - - it("rejects signups missing a referral code (invite-only)", () => { - const source = fs.readFileSync(ROUTE_PATH, "utf8"); - // The required-referrer branch must return 400. Pin the error string - // so a future refactor can't accidentally re-open the route to the - // unauthenticated public. - expect(source).toMatch( - /referredByRaw\s*===\s*null\s*\|\|\s*referredByRaw\.length\s*===\s*0/, - ); - expect(source).toContain( - 'referral code required — Percolator is invite-only', - ); - expect(source).toMatch(/status:\s*400/); - }); - - it("rejects disposable email domains at signup, not just in admin", () => { - const source = fs.readFileSync(ROUTE_PATH, "utf8"); - // Import from the shared module — pins the single-source-of-truth - // contract. If a future refactor re-defines the list inline in the - // route, the admin panel's count would silently drift from what - // the route blocks. - expect(source).toContain( - `import { isDisposableEmail } from "@/lib/waitlist/disposable-domains"`, - ); - // The check runs inside the email-shape branch, returns 400, and - // the error string does NOT echo the rejected domain (no need to - // confirm to a bot which entries are on the list). - expect(source).toMatch(/if\s*\(\s*isDisposableEmail\s*\(\s*emailRaw\s*\)\s*\)/); - expect(source).toContain("this email provider isn't accepted"); - }); - - it("places the disposable check INSIDE the email-shape branch", () => { - // Defence-in-depth: the disposable check should only run after the - // email passed shape validation. If a future refactor moves it - // above the shape check, malformed inputs could reach the helper - // (it'd return false for them, but the route would then fall - // through to the wallet path with a half-validated email field). - const source = fs.readFileSync(ROUTE_PATH, "utf8"); - const emailShapeIdx = source.indexOf("EMAIL_RE.test(emailRaw)"); - const disposableCheckIdx = source.indexOf("isDisposableEmail(emailRaw)"); - expect(emailShapeIdx).toBeGreaterThan(0); - expect(disposableCheckIdx).toBeGreaterThan(0); - expect(disposableCheckIdx).toBeGreaterThan(emailShapeIdx); - }); - - it("enforces a minimum time-on-page (dwell) before accepting a submit", () => { - const source = fs.readFileSync(ROUTE_PATH, "utf8"); - // Floor + stale cap constants present. - expect(source).toMatch(/MIN_DWELL_MS\s*=\s*1500/); - expect(source).toMatch(/MAX_STALE_MS\s*=\s*7\s*\*\s*24\s*\*\s*60\s*\*\s*60\s*\*\s*1000/); - // Reads from the body, validates as a finite number. - expect(source).toMatch(/typeof\s+b\.mounted_at\s*===\s*"number"/); - expect(source).toContain("Number.isFinite(b.mounted_at)"); - // Rejects with 400 (opaque error so a bot can't refine its script - // off our copy). - expect(source).toContain("request rejected — refresh the page"); - }); - - it("places the dwell check immediately after the honeypot", () => { - // The check is essentially free (one branch, no I/O) so positioning - // it as the very first non-trivial gate maximises the work saved - // when the dwell is wrong — a missing `mounted_at` short-circuits - // BEFORE the captcha siteverify call and BEFORE the wallet - // signature verify. - const source = fs.readFileSync(ROUTE_PATH, "utf8"); - const honeypotIdx = source.indexOf("Honeypot — silently accept"); - const dwellIdx = source.indexOf("Time-on-page (dwell) check"); - const captchaIdx = source.indexOf("Cloudflare Turnstile gate"); - expect(honeypotIdx).toBeGreaterThan(0); - expect(dwellIdx).toBeGreaterThan(0); - expect(captchaIdx).toBeGreaterThan(0); - expect(dwellIdx).toBeGreaterThan(honeypotIdx); - expect(dwellIdx).toBeLessThan(captchaIdx); - }); - - it("rejects when dwell is below the floor, above the cap, or negative", () => { - // The conditional should cover three independent fail modes, - // not just the under-floor case. Pin all three so a future - // refactor that drops one branch is flagged. - const source = fs.readFileSync(ROUTE_PATH, "utf8"); - expect(source).toMatch(/dwellMs\s*<\s*0/); - expect(source).toMatch(/dwellMs\s*<\s*MIN_DWELL_MS/); - expect(source).toMatch(/dwellMs\s*>\s*MAX_STALE_MS/); - }); -}); diff --git a/app/__tests__/components/Guide.test.tsx b/app/__tests__/components/Guide.test.tsx deleted file mode 100644 index 2aa7474f0..000000000 --- a/app/__tests__/components/Guide.test.tsx +++ /dev/null @@ -1,314 +0,0 @@ -/** - * Guide Page Component Tests - * - * Test Coverage: - * - P-MED-6: Table of contents rendering and navigation - * - Section headers and content structure - * - Internal navigation links - * - Responsive table rendering - */ - -import { describe, it, expect, vi } from 'vitest'; -import '@testing-library/jest-dom'; -import { render, screen, fireEvent } from '@testing-library/react'; -import GuidePage from '../../app/guide/page'; - -// Mock next/link -vi.mock('next/link', () => ({ - default: ({ children, href, ...props }: any) => {children}, -})); - -// Mock ScrollReveal component -vi.mock('@/components/ui/ScrollReveal', () => ({ - ScrollReveal: ({ children }: any) =>
{children}
, -})); - -describe('Guide Page', () => { - /** - * P-MED-6: Table of Contents - * Should render a navigable table of contents with all sections - */ - it('should render table of contents with all sections', () => { - render(); - - // Check for ToC heading - expect(screen.getByText(/Contents/i)).toBeInTheDocument(); - - // Check for all expected ToC links - const expectedSections = [ - 'What is Percolator?', - 'Devnet vs Mainnet', - 'How Markets Work', - 'Oracle Modes', - 'Market Tiers', - 'Getting Started', - 'FAQ', - ]; - - expectedSections.forEach((section) => { - const matches = screen.getAllByText(section); - expect(matches.length).toBeGreaterThanOrEqual(1); - }); - }); - - it('should have correct navigation links in table of contents', () => { - render(); - - // Check that ToC links have correct href attributes - const overviewLink = screen.getByRole('link', { name: /What is Percolator/i }); - expect(overviewLink).toHaveAttribute('href', '#overview'); - - const environmentsLink = screen.getByRole('link', { name: /Devnet vs Mainnet/i }); - expect(environmentsLink).toHaveAttribute('href', '#environments'); - - const mechanicsLink = screen.getByRole('link', { name: /How Markets Work/i }); - expect(mechanicsLink).toHaveAttribute('href', '#mechanics'); - - const oraclesLink = screen.getByRole('link', { name: /Oracle Modes/i }); - expect(oraclesLink).toHaveAttribute('href', '#oracles'); - - const capacityLink = screen.getByRole('link', { name: /Market Tiers/i }); - expect(capacityLink).toHaveAttribute('href', '#capacity'); - - const quickstartLink = screen.getByRole('link', { name: /Getting Started/i }); - expect(quickstartLink).toHaveAttribute('href', '#quickstart'); - - const faqLink = screen.getByRole('link', { name: /FAQ/i }); - expect(faqLink).toHaveAttribute('href', '#faq'); - }); - - /** - * Section Structure Tests - */ - it('should render all major sections with correct IDs', () => { - const { container } = render(); - - // Check that all sections have correct IDs for anchor navigation - expect(container.querySelector('#overview')).toBeInTheDocument(); - expect(container.querySelector('#environments')).toBeInTheDocument(); - expect(container.querySelector('#mechanics')).toBeInTheDocument(); - expect(container.querySelector('#oracles')).toBeInTheDocument(); - expect(container.querySelector('#capacity')).toBeInTheDocument(); - expect(container.querySelector('#quickstart')).toBeInTheDocument(); - expect(container.querySelector('#faq')).toBeInTheDocument(); - }); - - it('should render Overview section with key content', () => { - render(); - - expect(screen.getAllByText(/What is Percolator\?/i).length).toBeGreaterThanOrEqual(1); - expect(screen.getByText(/pump\.fun for perps/i)).toBeInTheDocument(); - expect(screen.getByText(/No approvals, no gatekeepers/i)).toBeInTheDocument(); - }); - - /** - * Table Rendering Tests - */ - it('should render Devnet vs Mainnet comparison table', () => { - render(); - - // Check for table headers - expect(screen.getByText('Devnet')).toBeInTheDocument(); - expect(screen.getByText('Mainnet')).toBeInTheDocument(); - - // Check for table content - expect(screen.getByText(/Live Pyth feeds.*Hyperp EMA/i)).toBeInTheDocument(); - expect(screen.getByText(/Live Pyth \/ DexScreener \/ Jupiter feeds/i)).toBeInTheDocument(); - expect(screen.getByText(/Test tokens from faucet/i)).toBeInTheDocument(); - expect(screen.getByText(/Real SPL tokens with DEX pools/i)).toBeInTheDocument(); - }); - - it('should render Market Tiers table with cost information', () => { - render(); - - // Check for tier information (multiple elements may match) - expect(screen.getAllByText(/Small/i).length).toBeGreaterThanOrEqual(1); - expect(screen.getAllByText(/Medium/i).length).toBeGreaterThanOrEqual(1); - expect(screen.getAllByText(/Large/i).length).toBeGreaterThanOrEqual(1); - - // Check for slot counts - expect(screen.getByText('256')).toBeInTheDocument(); - expect(screen.getByText('1,024')).toBeInTheDocument(); - expect(screen.getByText('4,096')).toBeInTheDocument(); - - // Check for cost estimates (V1 slab sizes: small ~$67, medium ~$260, large ~$1,000) - expect(screen.getAllByText(/~\$6[567]/i).length).toBeGreaterThanOrEqual(1); - expect(screen.getAllByText(/~\$260/i).length).toBeGreaterThanOrEqual(1); - expect(screen.getAllByText(/~\$1,000/i).length).toBeGreaterThanOrEqual(1); - }); - - /** - * How Markets Work Section - */ - it('should render How Markets Work section with mechanics', () => { - render(); - - // Check for key concepts - expect(screen.getAllByText(/Coin-Margined/i).length).toBeGreaterThanOrEqual(1); - expect(screen.getAllByText(/vAMM Liquidity/i).length).toBeGreaterThanOrEqual(1); - expect(screen.getAllByText(/Crank Service/i).length).toBeGreaterThanOrEqual(1); - expect(screen.getAllByText(/Insurance Fund/i).length).toBeGreaterThanOrEqual(1); - - // Check descriptions - expect(screen.getByText(/You deposit the same token you are trading/i)).toBeInTheDocument(); - expect(screen.getByText(/virtual AMM/i)).toBeInTheDocument(); - }); - - /** - * Oracle Modes Section - */ - it('should render Oracle Modes section with all modes', () => { - render(); - - expect(screen.getAllByText(/Admin Oracle/i).length).toBeGreaterThanOrEqual(1); - expect(screen.getAllByText(/Pyth Oracle/i).length).toBeGreaterThanOrEqual(1); - expect(screen.getAllByText(/DexScreener \/ Jupiter/i).length).toBeGreaterThanOrEqual(1); - - // Check for mode descriptions - expect(screen.getByText(/Market creator pushes prices manually/i)).toBeInTheDocument(); - expect(screen.getByText(/Automatic real-time prices from the Pyth network/i)).toBeInTheDocument(); - }); - - /** - * Getting Started Section - */ - it('should render Getting Started section with step-by-step guide', () => { - render(); - - expect(screen.getAllByText(/Connect Phantom/i).length).toBeGreaterThanOrEqual(1); - expect(screen.getAllByText(/Get Test SOL/i).length).toBeGreaterThanOrEqual(1); - expect(screen.getAllByText(/Create a Test Token/i).length).toBeGreaterThanOrEqual(1); - expect(screen.getAllByText(/Launch a Market/i).length).toBeGreaterThanOrEqual(1); - expect(screen.getAllByText(/Push Oracle Prices/i).length).toBeGreaterThanOrEqual(1); - expect(screen.getAllByText(/Open Trades/i).length).toBeGreaterThanOrEqual(1); - - // Check for step numbers - expect(screen.getByText('01')).toBeInTheDocument(); - expect(screen.getByText('02')).toBeInTheDocument(); - expect(screen.getByText('03')).toBeInTheDocument(); - expect(screen.getByText('04')).toBeInTheDocument(); - expect(screen.getByText('05')).toBeInTheDocument(); - expect(screen.getByText('06')).toBeInTheDocument(); - }); - - /** - * FAQ Section - */ - it('should render FAQ section with collapsible questions', () => { - render(); - - // Check for FAQ questions - expect(screen.getByText(/What happens if the oracle price is not updated/i)).toBeInTheDocument(); - expect(screen.getByText(/Can I recover the rent from a market/i)).toBeInTheDocument(); - expect(screen.getByText(/What is the insurance fund for/i)).toBeInTheDocument(); - expect(screen.getByText(/Can I use any Solana token/i)).toBeInTheDocument(); - expect(screen.getByText(/What is coin-margined trading/i)).toBeInTheDocument(); - expect(screen.getByText(/How do I switch between devnet and mainnet/i)).toBeInTheDocument(); - }); - - it('should have expandable FAQ details elements', () => { - const { container } = render(); - - // Check that FAQ items are rendered as details elements - const detailsElements = container.querySelectorAll('details'); - expect(detailsElements.length).toBeGreaterThanOrEqual(6); - }); - - /** - * Call-to-Action Section - */ - it('should render CTA buttons at the bottom', () => { - render(); - - const launchMarketButton = screen.getByRole('link', { name: /Launch a Market/i }); - expect(launchMarketButton).toBeInTheDocument(); - expect(launchMarketButton).toHaveAttribute('href', '/create'); - - const browseMarketsButton = screen.getByRole('link', { name: /Browse Markets/i }); - expect(browseMarketsButton).toBeInTheDocument(); - expect(browseMarketsButton).toHaveAttribute('href', '/markets'); - }); - - /** - * Page Header - */ - it('should render page header with title and description', () => { - render(); - - expect(screen.getAllByText(/Percolator/i).length).toBeGreaterThanOrEqual(1); - expect(screen.getAllByText(/Guide/i).length).toBeGreaterThanOrEqual(1); - expect(screen.getByText(/Everything you need to know about launching and trading/i)).toBeInTheDocument(); - }); - - /** - * Accessibility - */ - it('should have semantic HTML structure', () => { - const { container } = render(); - - // Check for main element - const main = container.querySelector('main'); - expect(main).toBeInTheDocument(); - - // Check for nav element (table of contents) - const nav = container.querySelector('nav'); - expect(nav).toBeInTheDocument(); - - // Check for section elements - const sections = container.querySelectorAll('section'); - expect(sections.length).toBeGreaterThanOrEqual(7); - }); - - it('should have proper heading hierarchy', () => { - const { container } = render(); - - // Check for h1 (main title) - const h1 = container.querySelector('h1'); - expect(h1).toBeInTheDocument(); - expect(h1?.textContent).toContain('Guide'); - - // Check for h2 elements (section titles and ToC) - const h2Elements = container.querySelectorAll('h2'); - expect(h2Elements.length).toBeGreaterThanOrEqual(7); - }); - - /** - * Visual Indicators - */ - it('should render oracle mode indicators with correct colors', () => { - const { container } = render(); - - // Oracle section should have colored indicators for devnet/mainnet - const oracleSection = container.querySelector('#oracles'); - expect(oracleSection).toBeInTheDocument(); - - // Check for environment badges - expect(screen.getByText('devnet')).toBeInTheDocument(); - const mainnetBadges = screen.getAllByText('mainnet'); - expect(mainnetBadges.length).toBeGreaterThanOrEqual(2); // Pyth and DexScreener - }); - - /** - * Navigation Interaction - */ - it('should support keyboard navigation for ToC links', () => { - render(); - - const firstToCLink = screen.getByRole('link', { name: /What is Percolator/i }); - - // Should be focusable - firstToCLink.focus(); - expect(document.activeElement).toBe(firstToCLink); - }); - - it('should have scroll-margin classes for anchor targets', () => { - const { container } = render(); - - // Sections should have scroll-margin for proper anchor scrolling - const sections = container.querySelectorAll('section'); - sections.forEach(section => { - const classList = Array.from(section.classList); - expect(classList.some(cls => cls.includes('scroll'))).toBe(true); - }); - }); -}); diff --git a/app/__tests__/lib/waitlist-client-ip.test.ts b/app/__tests__/lib/waitlist-client-ip.test.ts deleted file mode 100644 index 102f9c71a..000000000 --- a/app/__tests__/lib/waitlist-client-ip.test.ts +++ /dev/null @@ -1,186 +0,0 @@ -/** - * Header-precedence + IP-hashing tests for the waitlist client-IP helper. - * - * The route trusts header values from upstream proxies. Wrong precedence - * here would let a client inject a fake IP via `x-forwarded-for` and - * bypass the per-IP rate limit. The tests pin: - * - * 1. cf-connecting-ip wins over x-real-ip wins over x-forwarded-for. - * 2. x-forwarded-for chain handling takes the LEFTMOST entry only. - * 3. IPv6 bracketed-with-port is normalised. - * 4. IPv4 with trailing port is stripped. - * 5. Garbage in any header → null returned (route writes NULL). - * 6. Hash requires a salt; returns null without one. - * 7. Same IP + same salt → stable hash; different salt → different hash. - */ - -import { describe, it, expect, afterEach } from "vitest"; -import { getClientIp, hashIp } from "../../lib/waitlist/client-ip"; - -const ORIGINAL_NODE_ENV = process.env.NODE_ENV; -afterEach(() => { - if (ORIGINAL_NODE_ENV === undefined) delete process.env.NODE_ENV; - else process.env.NODE_ENV = ORIGINAL_NODE_ENV; -}); - -function headers(map: Record): Headers { - const h = new Headers(); - for (const [k, v] of Object.entries(map)) h.set(k, v); - return h; -} - -describe("getClientIp", () => { - it("prefers cf-connecting-ip over x-real-ip and x-forwarded-for", () => { - const h = headers({ - "cf-connecting-ip": "203.0.113.10", - "x-real-ip": "198.51.100.1", - "x-forwarded-for": "192.0.2.1, 10.0.0.1", - }); - expect(getClientIp(h)).toBe("203.0.113.10"); - }); - - it("falls back to x-real-ip when cf-connecting-ip absent", () => { - const h = headers({ - "x-real-ip": "198.51.100.1", - "x-forwarded-for": "192.0.2.1, 10.0.0.1", - }); - expect(getClientIp(h)).toBe("198.51.100.1"); - }); - - it("falls back to leftmost x-forwarded-for entry when both above absent", () => { - const h = headers({ "x-forwarded-for": "192.0.2.1, 10.0.0.1, 172.16.0.1" }); - expect(getClientIp(h)).toBe("192.0.2.1"); - }); - - it("returns null when no forwarding header is present", () => { - expect(getClientIp(headers({}))).toBeNull(); - }); - - it("returns null when the header value is malformed garbage", () => { - const h = headers({ "cf-connecting-ip": "not-an-ip!@#$" }); - expect(getClientIp(h)).toBeNull(); - }); - - it("returns null when the header value is empty after trim", () => { - const h = headers({ "x-forwarded-for": " , , " }); - expect(getClientIp(h)).toBeNull(); - }); - - it("strips the trailing port off an IPv4 with :port", () => { - const h = headers({ "cf-connecting-ip": "192.0.2.1:51234" }); - expect(getClientIp(h)).toBe("192.0.2.1"); - }); - - it("strips the brackets off a bracketed IPv6", () => { - const h = headers({ "cf-connecting-ip": "[2001:db8::1]:443" }); - expect(getClientIp(h)).toBe("2001:db8::1"); - }); - - it("returns a bare IPv6 unmodified", () => { - const h = headers({ "cf-connecting-ip": "2001:db8::1" }); - expect(getClientIp(h)).toBe("2001:db8::1"); - }); - - it("does not treat the colon in IPv6 as a port separator", () => { - // Same hex format, no brackets — we must not strip after the first colon. - const h = headers({ "cf-connecting-ip": "fe80::1" }); - expect(getClientIp(h)).toBe("fe80::1"); - }); - - it("rejects implausibly long header values", () => { - const h = headers({ "cf-connecting-ip": "1.2.3.4" + "5".repeat(100) }); - expect(getClientIp(h)).toBeNull(); - }); - - // In production we refuse to trust x-forwarded-for entirely — CF or - // Vercel set cf-connecting-ip / x-real-ip for legitimate traffic, so - // a request reaching the XFF fallback in prod implies a spoofing - // attempt (e.g., direct-to-Vercel bypassing Cloudflare). - it("ignores x-forwarded-for fallback when NODE_ENV === production", () => { - process.env.NODE_ENV = "production"; - expect( - getClientIp(headers({ "x-forwarded-for": "192.0.2.1" })), - ).toBeNull(); - }); - - it("still trusts cf-connecting-ip in production", () => { - process.env.NODE_ENV = "production"; - expect( - getClientIp(headers({ "cf-connecting-ip": "203.0.113.10" })), - ).toBe("203.0.113.10"); - }); - - it("still trusts x-real-ip in production", () => { - process.env.NODE_ENV = "production"; - expect(getClientIp(headers({ "x-real-ip": "198.51.100.1" }))).toBe( - "198.51.100.1", - ); - }); - - // Regression: an earlier loose-regex validator accepted these as - // "plausible," let them through to Postgres `inet` cast which - // rejected them with 22P02, and crashed the signup INSERT. The - // current `net.isIP()`-based check rejects them at the front door. - it.each([ - "1.2.3.4.5", - "::::::", - "a:b:c:d.e", - "............", - "------", - "999.999.999.999", - "1.2.3", - "g::1", - "2001:db8::1::2", - ])("rejects malformed IP-looking string %s", (bad) => { - expect(getClientIp(headers({ "cf-connecting-ip": bad }))).toBeNull(); - expect(getClientIp(headers({ "x-real-ip": bad }))).toBeNull(); - expect(getClientIp(headers({ "x-forwarded-for": bad }))).toBeNull(); - }); -}); - -describe("hashIp", () => { - it("returns null when no salt is configured", () => { - expect(hashIp("203.0.113.10", undefined)).toBeNull(); - expect(hashIp("203.0.113.10", null)).toBeNull(); - expect(hashIp("203.0.113.10", "")).toBeNull(); - }); - - it("returns a 64-char hex string when a salt is configured", () => { - const h = hashIp("203.0.113.10", "0123456789abcdef"); - expect(h).toMatch(/^[0-9a-f]{64}$/); - }); - - it("returns the same hash for the same ip + salt across calls", () => { - const a = hashIp("203.0.113.10", "stable-salt-1234"); - const b = hashIp("203.0.113.10", "stable-salt-1234"); - expect(a).toBe(b); - }); - - it("returns different hashes when the salt changes", () => { - const a = hashIp("203.0.113.10", "first-salt-abcde"); - const b = hashIp("203.0.113.10", "second-salt-xyzw"); - expect(a).not.toBe(b); - }); - - it("returns different hashes for different IPs under the same salt", () => { - const a = hashIp("203.0.113.10", "shared-salt-1234"); - const b = hashIp("203.0.113.11", "shared-salt-1234"); - expect(a).not.toBe(b); - }); - - // Regression: previous version accepted any truthy salt. A one-char - // salt only multiplies precompute cost by 256, leaving the IPv4 - // space brute-forceable in seconds. Min length 16 (≈ 128 bits) is - // the standard salt floor. - it.each(["x", "ab", "shortsalt"])( - "returns null when salt is below 16 chars (%s)", - (badSalt) => { - expect(hashIp("203.0.113.10", badSalt)).toBeNull(); - }, - ); - - it("accepts the boundary salt length of exactly 16 chars", () => { - const h = hashIp("203.0.113.10", "x".repeat(16)); - expect(h).toMatch(/^[0-9a-f]{64}$/); - }); -}); diff --git a/app/__tests__/lib/waitlist-disposable-domains.test.ts b/app/__tests__/lib/waitlist-disposable-domains.test.ts deleted file mode 100644 index 1e0a84590..000000000 --- a/app/__tests__/lib/waitlist-disposable-domains.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -/** - * Disposable-email helper behaviour. - * - * The list is the single source of truth shared between the signup - * route (rejects at the door) and the admin spam panel (post-facto - * detection). These tests pin the match semantics so a future refactor - * can't accidentally: - * • switch to a subdomain match (legit forwarders would be blocked) - * • re-introduce case sensitivity (the caller lowercases; the helper - * trusts that contract — both directions of the contract should - * break loudly here if violated) - * • drop the lastIndexOf("@") in favour of indexOf (display-name - * emails like "Alice " would then match against "x>" or - * similar garbage) - */ - -import { describe, it, expect } from "vitest"; -import { - DISPOSABLE_EMAIL_DOMAINS, - isDisposableEmail, -} from "../../lib/waitlist/disposable-domains"; - -describe("DISPOSABLE_EMAIL_DOMAINS", () => { - it("is a non-empty ReadonlySet", () => { - expect(DISPOSABLE_EMAIL_DOMAINS.size).toBeGreaterThan(0); - }); - - it("contains the canonical throwaway providers", () => { - // Smoke-check that the list is roughly the right shape — pinned - // against the well-known names so an accidental wipe is obvious. - for (const known of [ - "mailinator.com", - "guerrillamail.com", - "10minutemail.com", - "yopmail.com", - "tempmail.com", - ]) { - expect(DISPOSABLE_EMAIL_DOMAINS.has(known)).toBe(true); - } - }); - - it("does NOT contain mainstream providers", () => { - // Defence against an accidental "add all .com" entry — the legit - // providers below must always be allowed through. - for (const real of [ - "gmail.com", - "outlook.com", - "yahoo.com", - "icloud.com", - "proton.me", - "protonmail.com", - "fastmail.com", - "hey.com", - ]) { - expect(DISPOSABLE_EMAIL_DOMAINS.has(real)).toBe(false); - } - }); -}); - -describe("isDisposableEmail", () => { - it("flags a basic mailinator address", () => { - expect(isDisposableEmail("alice@mailinator.com")).toBe(true); - }); - - it("flags every entry in the blocklist for a trivial local part", () => { - // Walk every entry — catches a regression where the helper used - // indexOf instead of lastIndexOf, or strip-port-style cruft. - for (const dom of DISPOSABLE_EMAIL_DOMAINS) { - expect(isDisposableEmail(`user@${dom}`)).toBe(true); - } - }); - - it("returns false for canonical real providers", () => { - for (const real of [ - "alice@gmail.com", - "bob@outlook.com", - "carol@yahoo.com", - "dave@icloud.com", - "eve@proton.me", - ]) { - expect(isDisposableEmail(real)).toBe(false); - } - }); - - it("matches the LAST @-segment (display-name immunity)", () => { - // If isDisposableEmail used indexOf instead of lastIndexOf, a - // pasted display-name shape like the below would slice to - // "mailinator.com>" and miss the match. Pin the correct semantics. - // (The signup route's EMAIL_RE already rejects display-name shapes - // before this helper runs, but the helper itself must be robust.) - expect(isDisposableEmail("Alice ")).toBe(false); - // ...because the last `@` is followed by "mailinator.com>" which - // isn't in the set. Confirms the helper doesn't try to be too - // clever about parsing. - }); - - it("treats subdomains as NOT disposable", () => { - // Documented design choice — legit forwarders sometimes live on - // vendor subdomains. Stricter matching would risk false positives. - expect(isDisposableEmail("user@mail.mailinator.com")).toBe(false); - }); - - it("does NOT lowercase input — caller's contract", () => { - // The signup route lowercases before calling. If a caller passes - // mixed case, the helper returns false (the blocklist entries are - // all lowercase). This is the contract; a regression that - // auto-lowercased here would mask bugs at the caller. - expect(isDisposableEmail("User@Mailinator.com")).toBe(false); - }); - - it("returns false for malformed shapes", () => { - expect(isDisposableEmail("")).toBe(false); - expect(isDisposableEmail("@mailinator.com")).toBe(false); // no local - expect(isDisposableEmail("user@")).toBe(false); // no domain - expect(isDisposableEmail("user")).toBe(false); // no @ - }); -}); diff --git a/app/__tests__/lib/waitlist-referral-code.test.ts b/app/__tests__/lib/waitlist-referral-code.test.ts deleted file mode 100644 index 8fb5e8471..000000000 --- a/app/__tests__/lib/waitlist-referral-code.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * Referral code generator — format + uniqueness contract. - * - * The route relies on the generator producing codes that match the SQL - * unique constraint's alphabet and length. If either drifts, signups will - * either reject codes that should be valid or generate codes the SQL - * function would refuse. - */ - -import { describe, it, expect } from "vitest"; -import { - generateReferralCode, - isValidReferralCodeShape, - REFERRAL_CODE_LENGTH, -} from "@/lib/waitlist/referralCode"; - -const CROCKFORD_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; - -describe("generateReferralCode", () => { - it("returns the expected length by default", () => { - expect(generateReferralCode().length).toBe(REFERRAL_CODE_LENGTH); - }); - - it("uses only Crockford base32 characters (no I/L/O/U, no lowercase)", () => { - for (let i = 0; i < 200; i++) { - const code = generateReferralCode(); - for (const ch of code) { - expect(CROCKFORD_ALPHABET).toContain(ch); - } - } - }); - - it("produces unique codes across a large sample (no collisions at this scale)", () => { - const N = 10_000; - const seen = new Set(); - for (let i = 0; i < N; i++) seen.add(generateReferralCode()); - expect(seen.size).toBe(N); - }); - - it("rejects lengths outside the supported range", () => { - expect(() => generateReferralCode(3)).toThrow(); - expect(() => generateReferralCode(65)).toThrow(); - }); -}); - -describe("isValidReferralCodeShape", () => { - it("accepts a freshly generated code", () => { - expect(isValidReferralCodeShape(generateReferralCode())).toBe(true); - }); - - it("rejects lowercase, wrong length, and confusable chars", () => { - expect(isValidReferralCodeShape("abc23xyz")).toBe(false); // lowercase - expect(isValidReferralCodeShape("ABC23X")).toBe(false); // too short - expect(isValidReferralCodeShape("ABC23XYZ9")).toBe(false); // too long - expect(isValidReferralCodeShape("ABCI3XYZ")).toBe(false); // contains I - expect(isValidReferralCodeShape("ABCL3XYZ")).toBe(false); // contains L - expect(isValidReferralCodeShape("ABCO3XYZ")).toBe(false); // contains O - expect(isValidReferralCodeShape("ABCU3XYZ")).toBe(false); // contains U - }); -}); diff --git a/app/__tests__/lib/waitlist-turnstile.test.ts b/app/__tests__/lib/waitlist-turnstile.test.ts deleted file mode 100644 index 5bf838dbe..000000000 --- a/app/__tests__/lib/waitlist-turnstile.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -/** - * Cloudflare Turnstile server-side verification posture. - * - * The wire format to siteverify is fixed by Cloudflare's docs and pinned - * here so a future refactor can't switch to JSON, drop the remoteip - * hint, or change the secret-handling. The prod / non-prod fail-mode - * inversion is the load-bearing security property: missing secret in - * prod rejects, missing secret in dev accepts. - */ - -import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; -import { verifyTurnstile } from "../../lib/waitlist/turnstile"; - -const ORIGINAL_ENV = { ...process.env }; - -beforeEach(() => { - vi.restoreAllMocks(); - delete process.env.TURNSTILE_SECRET; - delete process.env.NODE_ENV; -}); - -afterEach(() => { - process.env = { ...ORIGINAL_ENV }; -}); - -describe("verifyTurnstile", () => { - it("fails CLOSED in production when TURNSTILE_SECRET is missing", async () => { - process.env.NODE_ENV = "production"; - const v = await verifyTurnstile("any-token", "203.0.113.10"); - expect(v.ok).toBe(false); - if (!v.ok) expect(v.reason).toBe("not_configured"); - }); - - it("fails OPEN in non-production when TURNSTILE_SECRET is missing", async () => { - process.env.NODE_ENV = "development"; - const v = await verifyTurnstile("any-token", "203.0.113.10"); - expect(v.ok).toBe(true); - }); - - it("rejects empty / missing tokens even with a secret configured", async () => { - process.env.TURNSTILE_SECRET = "test-secret"; - const a = await verifyTurnstile(null, "203.0.113.10"); - const b = await verifyTurnstile(undefined, "203.0.113.10"); - const c = await verifyTurnstile("", "203.0.113.10"); - for (const v of [a, b, c]) { - expect(v.ok).toBe(false); - if (!v.ok) expect(v.reason).toBe("missing_token"); - } - }); - - it("returns ok:true when Cloudflare reports success:true", async () => { - process.env.TURNSTILE_SECRET = "test-secret"; - const fetchMock = vi - .spyOn(global, "fetch") - .mockResolvedValue( - new Response(JSON.stringify({ success: true }), { status: 200 }), - ); - const v = await verifyTurnstile("good-token", "203.0.113.10"); - expect(v.ok).toBe(true); - expect(fetchMock).toHaveBeenCalledOnce(); - }); - - it("returns ok:false with verification_failed when success:false", async () => { - process.env.TURNSTILE_SECRET = "test-secret"; - vi.spyOn(global, "fetch").mockResolvedValue( - new Response(JSON.stringify({ success: false }), { status: 200 }), - ); - const v = await verifyTurnstile("bad-token", "203.0.113.10"); - expect(v.ok).toBe(false); - if (!v.ok) expect(v.reason).toBe("verification_failed"); - }); - - it("returns ok:false with verification_error on Cloudflare 5xx", async () => { - process.env.TURNSTILE_SECRET = "test-secret"; - vi.spyOn(global, "fetch").mockResolvedValue( - new Response("Bad Gateway", { status: 502 }), - ); - const v = await verifyTurnstile("any-token", "203.0.113.10"); - expect(v.ok).toBe(false); - if (!v.ok) expect(v.reason).toBe("verification_error"); - }); - - it("returns ok:false with verification_error on network throw (fail-CLOSED)", async () => { - process.env.TURNSTILE_SECRET = "test-secret"; - vi.spyOn(global, "fetch").mockRejectedValue(new Error("network down")); - const v = await verifyTurnstile("any-token", "203.0.113.10"); - expect(v.ok).toBe(false); - if (!v.ok) expect(v.reason).toBe("verification_error"); - }); - - it("forwards secret + response + remoteip in URL-encoded body", async () => { - process.env.TURNSTILE_SECRET = "test-secret"; - let capturedBody = ""; - vi.spyOn(global, "fetch").mockImplementation(async (_url, init) => { - capturedBody = String(init?.body ?? ""); - return new Response(JSON.stringify({ success: true }), { status: 200 }); - }); - await verifyTurnstile("the-token", "203.0.113.10"); - const params = new URLSearchParams(capturedBody); - expect(params.get("secret")).toBe("test-secret"); - expect(params.get("response")).toBe("the-token"); - expect(params.get("remoteip")).toBe("203.0.113.10"); - }); - - it("omits remoteip when no client IP was extracted", async () => { - process.env.TURNSTILE_SECRET = "test-secret"; - let capturedBody = ""; - vi.spyOn(global, "fetch").mockImplementation(async (_url, init) => { - capturedBody = String(init?.body ?? ""); - return new Response(JSON.stringify({ success: true }), { status: 200 }); - }); - await verifyTurnstile("the-token", null); - const params = new URLSearchParams(capturedBody); - expect(params.has("remoteip")).toBe(false); - }); - - it("POSTs to Cloudflare's canonical siteverify URL", async () => { - process.env.TURNSTILE_SECRET = "test-secret"; - let capturedUrl = ""; - vi.spyOn(global, "fetch").mockImplementation(async (url) => { - capturedUrl = String(url); - return new Response(JSON.stringify({ success: true }), { status: 200 }); - }); - await verifyTurnstile("the-token", null); - expect(capturedUrl).toBe( - "https://challenges.cloudflare.com/turnstile/v0/siteverify", - ); - }); -});