import { afterEach, describe, expect, it } from "vitest"; import { NextRequest } from "next/server"; import { _resetRateLimit, proxy } from "@/proxy"; afterEach(() => { _resetRateLimit(); }); function apiRequest(path: string, init?: { method?: string; origin?: string | null }): NextRequest { const headers = new Headers(); if (init?.origin !== null) { headers.set("origin", init?.origin ?? "https://fristy.dev"); } return new NextRequest(`https://fristy.dev${path}`, { method: init?.method ?? "GET", headers, }); } describe("proxy /api/*", () => { it("answers OPTIONS preflight with CORS headers", () => { const response = proxy(apiRequest("/api/game/1", { method: "OPTIONS" })); expect(response.status).toBe(204); expect(response.headers.get("Access-Control-Allow-Origin")).toBe("https://fristy.dev"); expect(response.headers.get("Access-Control-Allow-Methods")).toBe("GET, OPTIONS"); }); it("rejects foreign origins", () => { const response = proxy(apiRequest("/api/game/1", { origin: "https://evil.test" })); expect(response.status).toBe(403); expect(response.headers.get("Access-Control-Allow-Origin")).toBe("https://fristy.dev"); }); it("passes same-origin API requests with CORS headers", () => { const response = proxy(apiRequest("/api/game/1")); expect(response.headers.get("Access-Control-Allow-Origin")).toBe("https://fristy.dev"); }); }); describe("proxy rate limit", () => { function ipRequest(ip: string): NextRequest { const request = apiRequest("/api/game/1"); request.headers.set("cf-connecting-ip", ip); return request; } it("allows requests under the per-IP limit", () => { for (let i = 0; i < 30; i += 1) { expect(proxy(ipRequest("10.0.0.1")).status).not.toBe(429); } }); it("returns 429 with Retry-After past the limit", async () => { for (let i = 0; i < 30; i += 1) { proxy(ipRequest("10.0.0.2")); } const limited = proxy(ipRequest("10.0.0.2")); expect(limited.status).toBe(429); expect(limited.headers.get("Retry-After")).toBe("60"); expect(limited.headers.get("Access-Control-Allow-Origin")).toBe("https://fristy.dev"); await expect(limited.json()).resolves.toEqual({ error: "Too many requests" }); }); it("isolates buckets per client IP", () => { for (let i = 0; i < 30; i += 1) { proxy(ipRequest("10.0.0.3")); } expect(proxy(ipRequest("10.0.0.3")).status).toBe(429); expect(proxy(ipRequest("10.0.0.4")).status).not.toBe(429); }); it("prefers cf-connecting-ip over x-forwarded-for", () => { for (let i = 0; i < 30; i += 1) { const request = apiRequest("/api/game/1"); request.headers.set("cf-connecting-ip", "10.0.0.5"); request.headers.set("x-forwarded-for", "10.0.0.6"); proxy(request); } expect(proxy(ipRequest("10.0.0.5")).status).toBe(429); expect(proxy(ipRequest("10.0.0.6")).status).not.toBe(429); }); }); describe("proxy locale", () => { it("sets the locale cookie from accept-language", () => { const request = apiRequest("/"); request.headers.set("accept-language", "de-DE,de;q=0.9"); const response = proxy(request); expect(response.cookies.get("locale")?.value).toBe("de"); }); it("passes through when the cookie already exists", () => { const request = apiRequest("/"); request.cookies.set("locale", "en"); const response = proxy(request); expect(response.headers.get("set-cookie")).toBeNull(); }); });