"""Tests for middleware stack: CORS headers, rate limiting, dev auth flow. Critical invariants: - ALL responses (including 429) must carry CORS headers when origin is allowed - Dev login flow must survive repeated logins without hitting rate limit - OPTIONS preflight must never be rate-limited Note: each test uses a unique X-Forwarded-For IP to isolate rate limit counters, since the rate limiter is in-memory and shared across the app instance. """ from __future__ import annotations import pytest from httpx import ASGITransport, AsyncClient from app.main import app ORIGIN = "http://localhost:3002" CHALLENGE_URL = "/api/v1/auth/challenge" VERIFY_URL = "/api/v1/auth/verify" ME_URL = "/api/v1/auth/me" DEV_ADDRESS = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" # --------------------------------------------------------------------------- # INVARIANT 1: 429 responses must include CORS headers # Without this, the browser sees "Failed to fetch" instead of "Too Many Requests" # --------------------------------------------------------------------------- @pytest.mark.anyio async def test_rate_limited_response_has_cors_headers(): """A 429 from the rate limiter must still carry Access-Control-Allow-Origin. Root cause of the "no response / Failed to fetch" bug: the rate limiter sits outside CORS in the middleware stack, so its 429 responses have no CORS headers and the browser discards them as network errors. """ # dev auth limit = 60/min (RATE_LIMIT_DEFAULT), prod = 10/min (RATE_LIMIT_AUTH) # Send 65 requests to guarantee 429 regardless of environment. transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as client: last_response = None for _ in range(65): resp = await client.post( CHALLENGE_URL, json={"address": DEV_ADDRESS}, headers={"Origin": ORIGIN, "X-Forwarded-For": "10.0.1.1"}, ) last_response = resp if resp.status_code == 429: break assert last_response is not None assert last_response.status_code == 429, ( "Expected 429 after exceeding auth rate limit" ) assert "access-control-allow-origin" in last_response.headers, ( "429 response must include CORS headers so the browser can read the error" ) assert last_response.headers["access-control-allow-origin"] == ORIGIN # --------------------------------------------------------------------------- # INVARIANT 2: OPTIONS preflight must never be rate-limited # --------------------------------------------------------------------------- @pytest.mark.anyio async def test_options_preflight_never_rate_limited(): """OPTIONS requests must pass through regardless of request count. Browsers send a preflight before every cross-origin POST with custom headers. A 429 on OPTIONS prevents the real request from ever being sent. """ transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as client: for i in range(20): resp = await client.options( CHALLENGE_URL, headers={ "Origin": ORIGIN, "Access-Control-Request-Method": "POST", "Access-Control-Request-Headers": "content-type", "X-Forwarded-For": "10.0.2.1", }, ) assert resp.status_code != 429, ( f"OPTIONS request #{i + 1} was rate-limited (429) — preflights must never be blocked" ) # --------------------------------------------------------------------------- # INVARIANT 3: Dev login flow must survive ≥ 10 consecutive logins # (challenge + verify cycle, dev profile bypass) # --------------------------------------------------------------------------- @pytest.mark.anyio async def test_dev_login_survives_repeated_cycles(): """Complete login cycle (challenge → verify) must work ≥ 10 times in a row. In dev mode, the developer disconnects and reconnects frequently. With auth rate limit = 10/min, the 6th challenge request would be blocked. Dev mode must use a higher limit (≥ 60/min) to prevent login lockout. """ transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as client: tokens = [] for i in range(10): # Step 1: get challenge ch_resp = await client.post( CHALLENGE_URL, json={"address": DEV_ADDRESS}, headers={"Origin": ORIGIN, "X-Forwarded-For": "10.0.3.1"}, ) assert ch_resp.status_code == 200, ( f"Login cycle #{i + 1}: challenge returned {ch_resp.status_code} — " f"rate limit likely hit. Dev mode requires RATE_LIMIT_AUTH ≥ 60/min." ) challenge = ch_resp.json()["challenge"] # Step 2: verify (dev bypass — any signature accepted for dev addresses) v_resp = await client.post( VERIFY_URL, json={ "address": DEV_ADDRESS, "challenge": challenge, "signature": "0x" + "ab" * 64, }, headers={"Origin": ORIGIN, "X-Forwarded-For": "10.0.3.1"}, ) assert v_resp.status_code == 200, ( f"Login cycle #{i + 1}: verify returned {v_resp.status_code}" ) tokens.append(v_resp.json()["token"]) assert len(tokens) == 10, "Expected 10 successful login cycles" assert len(set(tokens)) == 10, "Each login cycle must produce a unique token" # --------------------------------------------------------------------------- # INVARIANT 4: /auth/me OPTIONS preflight must return CORS headers # --------------------------------------------------------------------------- @pytest.mark.anyio async def test_auth_me_options_preflight_returns_cors(): """OPTIONS preflight for /auth/me must return 200 with CORS headers. This was the root cause of the session-lost-on-reload bug: repeated /auth/me calls would exhaust the auth rate limit, the 429 OPTIONS response had no CORS headers, and the browser threw 'Failed to fetch'. """ transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as client: resp = await client.options( ME_URL, headers={ "Origin": ORIGIN, "Access-Control-Request-Method": "GET", "Access-Control-Request-Headers": "authorization", "X-Forwarded-For": "10.0.4.1", }, ) assert resp.status_code in (200, 204), ( f"OPTIONS /auth/me returned {resp.status_code}" ) assert "access-control-allow-origin" in resp.headers