//! 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. //! //! ## Flakiness on CI (astra) //! //! Under `--features fast-tests` the token bucket refills at 100/sec (burst //! 20). On a fast Mac this is easy to deplete sequentially, but astra under //! `--test-threads=8` + postgres contention slows per-request execution past //! the refill rate — the bucket never empties and the test fails. Tests //! tagged `#[cfg_attr(feature = "fast-tests", ignore)]` for that reason. //! //! Run them locally with: //! //! ```sh //! TEST_DATABASE_URL="postgres:///postgres" \ //! cargo test --features fast-tests --test integration \ //! -- --ignored --test-threads=1 rate_limit //! ``` use crate::harness::TestHarness; use makenotwork::constants::{ API_WRITE_RATE_LIMIT_BURST, AUTH_RATE_LIMIT_BURST, SANDBOX_RATE_LIMIT_BURST, }; // ============================================================================= // Auth rate limiting // ============================================================================= /// Send AUTH_RATE_LIMIT_BURST + 1 login attempts rapidly and verify the last /// one returns 429 Too Many Requests. #[tokio::test] #[cfg_attr(feature = "fast-tests", ignore)] async fn auth_rate_limit_triggers_on_burst() { let mut h = TestHarness::new().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. // Login is CSRF-exempt so no CSRF token needed. // 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] #[cfg_attr(feature = "fast-tests", ignore)] async fn rate_limit_returns_retry_after_header() { let mut h = TestHarness::new().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] #[cfg_attr(feature = "fast-tests", ignore)] async fn rate_limit_different_ips_independent() { let mut h = TestHarness::new().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. /// Skipped with `fast-tests` — relaxed rate limits make this test meaningless. #[tokio::test] #[cfg_attr(feature = "fast-tests", ignore)] async fn sandbox_rate_limit_triggers() { let mut h = TestHarness::new().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] #[cfg_attr(feature = "fast-tests", ignore)] async fn api_write_rate_limit_triggers() { let mut h = TestHarness::new().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 (Run #11 Security fix) // ============================================================================= /// All email-action routes share one per-IP auth rate limiter applied at the /// router (`email_action_routes`). Run #11 found `/login-link`, `/reset-password`, /// `/verify-email`, `/confirm-delete`, and `/unsubscribe` uncapped while only /// `/forgot-password` was limited. This pins that the cap now fires on the /// previously-uncapped routes. Ignored under fast-tests for the same /// token-bucket-refill-vs-request-rate flakiness as the other rate-limit tests; /// run with `--ignored --test-threads=1`. #[tokio::test] #[cfg_attr(feature = "fast-tests", ignore)] async fn email_action_routes_are_rate_limited() { let mut h = TestHarness::new().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, ); }