//! Rate limiting workflow tests. //! //! Verifies that tower_governor rate limiters enforce per-IP burst limits on //! auth, sandbox, and API write endpoints. The TestClient sets X-Forwarded-For //! on every request, and SmartIpKeyExtractor (fallback from CloudflareIpKeyExtractor) //! uses that header for keying, so rate limiting works in-process. //! //! Every test here builds `TestHarness::with_production_rate_limits()`, so the //! thresholds asserted are the ones that ship. The rest of the suite builds //! relaxed routers, because a test that logs in six times is not asking to be //! throttled. //! //! These used to be `#[ignore]`d under `--features fast-tests`, which is how //! astra ran them: the relaxed bucket refills at 100/sec, and under //! `--test-threads=8` plus postgres contention the machine could not drain it //! faster than it filled, so the 429 never came. That made the limiter that //! actually ships the one thing CI never exercised. Production values refill at //! 2/sec, which a loaded box outruns comfortably, so the tests run everywhere //! now and the feature flag is out of the picture. use crate::harness::TestHarness; use makenotwork::constants::{ API_WRITE_RATE_LIMIT_BURST, AUTH_RATE_LIMIT_BURST, SANDBOX_RATE_LIMIT_BURST, }; // Auth rate limiting /// Send up to AUTH_RATE_LIMIT_BURST * 3 rapid requests to `/auth/passkey/start` /// and verify one of them returns 429 Too Many Requests. #[tokio::test] async fn auth_rate_limit_triggers_on_burst() { let mut h = TestHarness::with_production_rate_limits().await; // Use a distinct IP so we don't collide with other tests h.client.set_forwarded_ip("10.0.0.1"); let mut got_429 = false; // Send many rapid requests. The burst limit is AUTH_RATE_LIMIT_BURST with // refill at 2/sec (per_millisecond(500)). Argon2 hashing can be slow, so // we send a generous multiple of the burst to outpace refill. // Test auth rate limiting via the passkey endpoint which shares the auth // rate limiter but doesn't invoke Argon2 password hashing. The login // handler's ~600ms Argon2 cost per request lets the token bucket refill // fast enough to prevent 429 in serial tests. for _ in 0..(AUTH_RATE_LIMIT_BURST as usize * 3) { let resp = h .client .post_json("/auth/passkey/start", r#"{"username":"nobody"}"#) .await; if resp.status == 429 { got_429 = true; break; } } assert!( got_429, "Expected 429 after bursting auth requests but never got one (burst={AUTH_RATE_LIMIT_BURST})", ); } // Retry-After header /// After triggering a rate limit, the 429 response must include a `retry-after` /// header so clients know when to retry. #[tokio::test] async fn rate_limit_returns_retry_after_header() { let mut h = TestHarness::with_production_rate_limits().await; h.client.set_forwarded_ip("10.0.1.1"); let mut last_resp = None; // Use passkey endpoint (fast, no Argon2) to trigger rate limit. for _ in 0..(AUTH_RATE_LIMIT_BURST as usize * 3) { let resp = h .client .post_json("/auth/passkey/start", r#"{"username":"nobody"}"#) .await; if resp.status == 429 { last_resp = Some(resp); break; } } let resp = last_resp.expect("Never got 429, cannot check retry-after header"); assert_eq!(resp.status, 429); assert!( resp.header("retry-after").is_some(), "429 response should include retry-after header, headers: {:?}", resp.headers ); } // Per-IP independence /// Exhaust the rate limit from one IP, then verify a different IP is not /// affected. Uses X-Forwarded-For to distinguish IPs. #[tokio::test] async fn rate_limit_different_ips_independent() { let mut h = TestHarness::with_production_rate_limits().await; // Exhaust burst from IP "1.2.3.4" using passkey endpoint (fast, no Argon2) h.client.set_forwarded_ip("1.2.3.4"); let mut ip1_got_429 = false; for _ in 0..(AUTH_RATE_LIMIT_BURST as usize * 3) { let resp = h .client .post_json("/auth/passkey/start", r#"{"username":"nobody"}"#) .await; if resp.status == 429 { ip1_got_429 = true; break; } } assert!(ip1_got_429, "IP 1.2.3.4 should be rate-limited"); // Switch to a fresh IP, should NOT be rate-limited h.client.set_forwarded_ip("5.6.7.8"); let resp = h .client .post_json("/auth/passkey/start", r#"{"username":"nobody"}"#) .await; assert_ne!( resp.status, 429, "IP 5.6.7.8 should not be rate-limited (got 429)" ); // Accept any non-429 status (likely 400 or 404) } // Sandbox rate limiting /// Send SANDBOX_RATE_LIMIT_BURST + 1 POST /sandbox requests and verify 429. #[tokio::test] async fn sandbox_rate_limit_triggers() { let mut h = TestHarness::with_production_rate_limits().await; h.client.set_forwarded_ip("10.0.2.1"); let mut got_429 = false; // Send well beyond burst to account for token refill during slow test execution for _ in 0..=(SANDBOX_RATE_LIMIT_BURST as usize + 5) { // Each POST /sandbox needs a CSRF token; GET /sandbox provides one let _page = h.client.get("/sandbox").await; let resp = h.client.post_form("/sandbox", "").await; if resp.status == 429 { got_429 = true; break; } } assert!( got_429, "Expected 429 after bursting sandbox requests but never got one (burst={SANDBOX_RATE_LIMIT_BURST})" ); } // API write rate limiting /// As a logged-in creator, send API_WRITE_RATE_LIMIT_BURST + 1 POST requests /// to a write endpoint and verify 429. #[tokio::test] async fn api_write_rate_limit_triggers() { let mut h = TestHarness::with_production_rate_limits().await; h.client.set_forwarded_ip("10.0.3.1"); let _user_id = h.create_creator("ratelimiter").await; let mut got_429 = false; // Send well beyond burst to account for token refill during slow test execution for _ in 0..=(API_WRITE_RATE_LIMIT_BURST as usize + 15) { // POST /api/projects is a write endpoint under the write rate limiter. // Most requests will fail (duplicate slug, validation) but they still // count toward the rate limit bucket. let resp = h .client .post_form("/api/projects", "slug=rl-test&title=Rate+Limit+Test") .await; if resp.status == 429 { got_429 = true; break; } } assert!( got_429, "Expected 429 after bursting API write requests but never got one (burst={API_WRITE_RATE_LIMIT_BURST})" ); } // Email-action routes /// All email-action routes share one per-IP auth rate limiter applied at the /// router (`email_action_routes`). This pins that the cap fires on /// `/login-link`, `/reset-password`, `/verify-email`, `/confirm-delete` and /// `/unsubscribe`, not only on `/forgot-password`. #[tokio::test] async fn email_action_routes_are_rate_limited() { let mut h = TestHarness::with_production_rate_limits().await; h.client.set_forwarded_ip("10.0.7.7"); let mut got_429 = false; for _ in 0..(AUTH_RATE_LIMIT_BURST as usize * 3) { // GET so no CSRF token is needed; the governor layer runs before the // handler, so the missing/invalid token doesn't matter for this assertion. let resp = h.client.get("/login-link?token=nope").await; if resp.status == 429 { got_429 = true; break; } } assert!( got_429, "Expected 429 after bursting /login-link (burst={AUTH_RATE_LIMIT_BURST})", ); }