Skip to main content

max / makenotwork

7.6 KB · 214 lines History Blame Raw
1 //! Rate limiting workflow tests.
2 //!
3 //! Verifies that tower_governor rate limiters enforce per-IP burst limits on
4 //! auth, sandbox, and API write endpoints. The TestClient sets X-Forwarded-For
5 //! on every request, and SmartIpKeyExtractor (fallback from CloudflareIpKeyExtractor)
6 //! uses that header for keying, so rate limiting works in-process.
7 //!
8 //! Every test here builds `TestHarness::with_production_rate_limits()`, so the
9 //! thresholds asserted are the ones that ship. The rest of the suite builds
10 //! relaxed routers, because a test that logs in six times is not asking to be
11 //! throttled.
12 //!
13 //! These used to be `#[ignore]`d under `--features fast-tests`, which is how
14 //! astra ran them: the relaxed bucket refills at 100/sec, and under
15 //! `--test-threads=8` plus postgres contention the machine could not drain it
16 //! faster than it filled, so the 429 never came. That made the limiter that
17 //! actually ships the one thing CI never exercised. Production values refill at
18 //! 2/sec, which a loaded box outruns comfortably, so the tests run everywhere
19 //! now and the feature flag is out of the picture.
20
21 use crate::harness::TestHarness;
22 use makenotwork::constants::{
23 API_WRITE_RATE_LIMIT_BURST, AUTH_RATE_LIMIT_BURST, SANDBOX_RATE_LIMIT_BURST,
24 };
25
26 // Auth rate limiting
27
28 /// Send up to AUTH_RATE_LIMIT_BURST * 3 rapid requests to `/auth/passkey/start`
29 /// and verify one of them returns 429 Too Many Requests.
30 #[tokio::test]
31 async fn auth_rate_limit_triggers_on_burst() {
32 let mut h = TestHarness::with_production_rate_limits().await;
33
34 // Use a distinct IP so we don't collide with other tests
35 h.client.set_forwarded_ip("10.0.0.1");
36
37 let mut got_429 = false;
38 // Send many rapid requests. The burst limit is AUTH_RATE_LIMIT_BURST with
39 // refill at 2/sec (per_millisecond(500)). Argon2 hashing can be slow, so
40 // we send a generous multiple of the burst to outpace refill.
41 // Test auth rate limiting via the passkey endpoint which shares the auth
42 // rate limiter but doesn't invoke Argon2 password hashing. The login
43 // handler's ~600ms Argon2 cost per request lets the token bucket refill
44 // fast enough to prevent 429 in serial tests.
45 for _ in 0..(AUTH_RATE_LIMIT_BURST as usize * 3) {
46 let resp = h
47 .client
48 .post_json("/auth/passkey/start", r#"{"username":"nobody"}"#)
49 .await;
50 if resp.status == 429 {
51 got_429 = true;
52 break;
53 }
54 }
55
56 assert!(
57 got_429,
58 "Expected 429 after bursting auth requests but never got one (burst={AUTH_RATE_LIMIT_BURST})",
59 );
60 }
61
62 // Retry-After header
63
64 /// After triggering a rate limit, the 429 response must include a `retry-after`
65 /// header so clients know when to retry.
66 #[tokio::test]
67 async fn rate_limit_returns_retry_after_header() {
68 let mut h = TestHarness::with_production_rate_limits().await;
69 h.client.set_forwarded_ip("10.0.1.1");
70
71 let mut last_resp = None;
72 // Use passkey endpoint (fast, no Argon2) to trigger rate limit.
73 for _ in 0..(AUTH_RATE_LIMIT_BURST as usize * 3) {
74 let resp = h
75 .client
76 .post_json("/auth/passkey/start", r#"{"username":"nobody"}"#)
77 .await;
78 if resp.status == 429 {
79 last_resp = Some(resp);
80 break;
81 }
82 }
83
84 let resp = last_resp.expect("Never got 429, cannot check retry-after header");
85 assert_eq!(resp.status, 429);
86 assert!(
87 resp.header("retry-after").is_some(),
88 "429 response should include retry-after header, headers: {:?}",
89 resp.headers
90 );
91 }
92
93 // Per-IP independence
94
95 /// Exhaust the rate limit from one IP, then verify a different IP is not
96 /// affected. Uses X-Forwarded-For to distinguish IPs.
97 #[tokio::test]
98 async fn rate_limit_different_ips_independent() {
99 let mut h = TestHarness::with_production_rate_limits().await;
100
101 // Exhaust burst from IP "1.2.3.4" using passkey endpoint (fast, no Argon2)
102 h.client.set_forwarded_ip("1.2.3.4");
103 let mut ip1_got_429 = false;
104 for _ in 0..(AUTH_RATE_LIMIT_BURST as usize * 3) {
105 let resp = h
106 .client
107 .post_json("/auth/passkey/start", r#"{"username":"nobody"}"#)
108 .await;
109 if resp.status == 429 {
110 ip1_got_429 = true;
111 break;
112 }
113 }
114 assert!(ip1_got_429, "IP 1.2.3.4 should be rate-limited");
115
116 // Switch to a fresh IP, should NOT be rate-limited
117 h.client.set_forwarded_ip("5.6.7.8");
118 let resp = h
119 .client
120 .post_json("/auth/passkey/start", r#"{"username":"nobody"}"#)
121 .await;
122 assert_ne!(
123 resp.status, 429,
124 "IP 5.6.7.8 should not be rate-limited (got 429)"
125 );
126 // Accept any non-429 status (likely 400 or 404)
127 }
128
129 // Sandbox rate limiting
130
131 /// Send SANDBOX_RATE_LIMIT_BURST + 1 POST /sandbox requests and verify 429.
132 #[tokio::test]
133 async fn sandbox_rate_limit_triggers() {
134 let mut h = TestHarness::with_production_rate_limits().await;
135 h.client.set_forwarded_ip("10.0.2.1");
136
137 let mut got_429 = false;
138 // Send well beyond burst to account for token refill during slow test execution
139 for _ in 0..=(SANDBOX_RATE_LIMIT_BURST as usize + 5) {
140 // Each POST /sandbox needs a CSRF token; GET /sandbox provides one
141 let _page = h.client.get("/sandbox").await;
142 let resp = h.client.post_form("/sandbox", "").await;
143 if resp.status == 429 {
144 got_429 = true;
145 break;
146 }
147 }
148
149 assert!(
150 got_429,
151 "Expected 429 after bursting sandbox requests but never got one (burst={SANDBOX_RATE_LIMIT_BURST})"
152 );
153 }
154
155 // API write rate limiting
156
157 /// As a logged-in creator, send API_WRITE_RATE_LIMIT_BURST + 1 POST requests
158 /// to a write endpoint and verify 429.
159 #[tokio::test]
160 async fn api_write_rate_limit_triggers() {
161 let mut h = TestHarness::with_production_rate_limits().await;
162 h.client.set_forwarded_ip("10.0.3.1");
163 let _user_id = h.create_creator("ratelimiter").await;
164
165 let mut got_429 = false;
166 // Send well beyond burst to account for token refill during slow test execution
167 for _ in 0..=(API_WRITE_RATE_LIMIT_BURST as usize + 15) {
168 // POST /api/projects is a write endpoint under the write rate limiter.
169 // Most requests will fail (duplicate slug, validation) but they still
170 // count toward the rate limit bucket.
171 let resp = h
172 .client
173 .post_form("/api/projects", "slug=rl-test&title=Rate+Limit+Test")
174 .await;
175 if resp.status == 429 {
176 got_429 = true;
177 break;
178 }
179 }
180
181 assert!(
182 got_429,
183 "Expected 429 after bursting API write requests but never got one (burst={API_WRITE_RATE_LIMIT_BURST})"
184 );
185 }
186
187 // Email-action routes
188
189 /// All email-action routes share one per-IP auth rate limiter applied at the
190 /// router (`email_action_routes`). This pins that the cap fires on
191 /// `/login-link`, `/reset-password`, `/verify-email`, `/confirm-delete` and
192 /// `/unsubscribe`, not only on `/forgot-password`.
193 #[tokio::test]
194 async fn email_action_routes_are_rate_limited() {
195 let mut h = TestHarness::with_production_rate_limits().await;
196 h.client.set_forwarded_ip("10.0.7.7");
197
198 let mut got_429 = false;
199 for _ in 0..(AUTH_RATE_LIMIT_BURST as usize * 3) {
200 // GET so no CSRF token is needed; the governor layer runs before the
201 // handler, so the missing/invalid token doesn't matter for this assertion.
202 let resp = h.client.get("/login-link?token=nope").await;
203 if resp.status == 429 {
204 got_429 = true;
205 break;
206 }
207 }
208
209 assert!(
210 got_429,
211 "Expected 429 after bursting /login-link (burst={AUTH_RATE_LIMIT_BURST})",
212 );
213 }
214