Skip to main content

max / makenotwork

7.8 KB · 216 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 AUTH_RATE_LIMIT_BURST + 1 login attempts rapidly and verify the last
29 /// one 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 // Login is CSRF-exempt so no CSRF token needed.
42 // Test auth rate limiting via the passkey endpoint which shares the auth
43 // rate limiter but doesn't invoke Argon2 password hashing. The login
44 // handler's ~600ms Argon2 cost per request lets the token bucket refill
45 // fast enough to prevent 429 in serial tests.
46 for _ in 0..(AUTH_RATE_LIMIT_BURST as usize * 3) {
47 let resp = h
48 .client
49 .post_json("/auth/passkey/start", r#"{"username":"nobody"}"#)
50 .await;
51 if resp.status == 429 {
52 got_429 = true;
53 break;
54 }
55 }
56
57 assert!(
58 got_429,
59 "Expected 429 after bursting auth requests but never got one (burst={AUTH_RATE_LIMIT_BURST})",
60 );
61 }
62
63 // Retry-After header
64
65 /// After triggering a rate limit, the 429 response must include a `retry-after`
66 /// header so clients know when to retry.
67 #[tokio::test]
68 async fn rate_limit_returns_retry_after_header() {
69 let mut h = TestHarness::with_production_rate_limits().await;
70 h.client.set_forwarded_ip("10.0.1.1");
71
72 let mut last_resp = None;
73 // Use passkey endpoint (fast, no Argon2) to trigger rate limit.
74 for _ in 0..(AUTH_RATE_LIMIT_BURST as usize * 3) {
75 let resp = h
76 .client
77 .post_json("/auth/passkey/start", r#"{"username":"nobody"}"#)
78 .await;
79 if resp.status == 429 {
80 last_resp = Some(resp);
81 break;
82 }
83 }
84
85 let resp = last_resp.expect("Never got 429, cannot check retry-after header");
86 assert_eq!(resp.status, 429);
87 assert!(
88 resp.header("retry-after").is_some(),
89 "429 response should include retry-after header, headers: {:?}",
90 resp.headers
91 );
92 }
93
94 // Per-IP independence
95
96 /// Exhaust the rate limit from one IP, then verify a different IP is not
97 /// affected. Uses X-Forwarded-For to distinguish IPs.
98 #[tokio::test]
99 async fn rate_limit_different_ips_independent() {
100 let mut h = TestHarness::with_production_rate_limits().await;
101
102 // Exhaust burst from IP "1.2.3.4" using passkey endpoint (fast, no Argon2)
103 h.client.set_forwarded_ip("1.2.3.4");
104 let mut ip1_got_429 = false;
105 for _ in 0..(AUTH_RATE_LIMIT_BURST as usize * 3) {
106 let resp = h
107 .client
108 .post_json("/auth/passkey/start", r#"{"username":"nobody"}"#)
109 .await;
110 if resp.status == 429 {
111 ip1_got_429 = true;
112 break;
113 }
114 }
115 assert!(ip1_got_429, "IP 1.2.3.4 should be rate-limited");
116
117 // Switch to a fresh IP, should NOT be rate-limited
118 h.client.set_forwarded_ip("5.6.7.8");
119 let resp = h
120 .client
121 .post_json("/auth/passkey/start", r#"{"username":"nobody"}"#)
122 .await;
123 assert_ne!(
124 resp.status, 429,
125 "IP 5.6.7.8 should not be rate-limited (got 429)"
126 );
127 // Accept any non-429 status (likely 400 or 404)
128 }
129
130 // Sandbox rate limiting
131
132 /// Send SANDBOX_RATE_LIMIT_BURST + 1 POST /sandbox requests and verify 429.
133 #[tokio::test]
134 async fn sandbox_rate_limit_triggers() {
135 let mut h = TestHarness::with_production_rate_limits().await;
136 h.client.set_forwarded_ip("10.0.2.1");
137
138 let mut got_429 = false;
139 // Send well beyond burst to account for token refill during slow test execution
140 for _ in 0..=(SANDBOX_RATE_LIMIT_BURST as usize + 5) {
141 // Each POST /sandbox needs a CSRF token; GET /sandbox provides one
142 let _page = h.client.get("/sandbox").await;
143 let resp = h.client.post_form("/sandbox", "").await;
144 if resp.status == 429 {
145 got_429 = true;
146 break;
147 }
148 }
149
150 assert!(
151 got_429,
152 "Expected 429 after bursting sandbox requests but never got one (burst={SANDBOX_RATE_LIMIT_BURST})"
153 );
154 }
155
156 // API write rate limiting
157
158 /// As a logged-in creator, send API_WRITE_RATE_LIMIT_BURST + 1 POST requests
159 /// to a write endpoint and verify 429.
160 #[tokio::test]
161 async fn api_write_rate_limit_triggers() {
162 let mut h = TestHarness::with_production_rate_limits().await;
163 h.client.set_forwarded_ip("10.0.3.1");
164 let _user_id = h.create_creator("ratelimiter").await;
165
166 let mut got_429 = false;
167 // Send well beyond burst to account for token refill during slow test execution
168 for _ in 0..=(API_WRITE_RATE_LIMIT_BURST as usize + 15) {
169 // POST /api/projects is a write endpoint under the write rate limiter.
170 // Most requests will fail (duplicate slug, validation) but they still
171 // count toward the rate limit bucket.
172 let resp = h
173 .client
174 .post_form("/api/projects", "slug=rl-test&title=Rate+Limit+Test")
175 .await;
176 if resp.status == 429 {
177 got_429 = true;
178 break;
179 }
180 }
181
182 assert!(
183 got_429,
184 "Expected 429 after bursting API write requests but never got one (burst={API_WRITE_RATE_LIMIT_BURST})"
185 );
186 }
187
188 // Email-action routes (Run #11 Security fix)
189
190 /// All email-action routes share one per-IP auth rate limiter applied at the
191 /// router (`email_action_routes`). Run #11 found `/login-link`, `/reset-password`,
192 /// `/verify-email`, `/confirm-delete`, and `/unsubscribe` uncapped while only
193 /// `/forgot-password` was limited. This pins that the cap now fires on the
194 /// previously-uncapped routes.
195 #[tokio::test]
196 async fn email_action_routes_are_rate_limited() {
197 let mut h = TestHarness::with_production_rate_limits().await;
198 h.client.set_forwarded_ip("10.0.7.7");
199
200 let mut got_429 = false;
201 for _ in 0..(AUTH_RATE_LIMIT_BURST as usize * 3) {
202 // GET so no CSRF token is needed; the governor layer runs before the
203 // handler, so the missing/invalid token doesn't matter for this assertion.
204 let resp = h.client.get("/login-link?token=nope").await;
205 if resp.status == 429 {
206 got_429 = true;
207 break;
208 }
209 }
210
211 assert!(
212 got_429,
213 "Expected 429 after bursting /login-link (burst={AUTH_RATE_LIMIT_BURST})",
214 );
215 }
216