Skip to main content

max / makenotwork

9.0 KB · 244 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 //! ## Flakiness on CI (astra)
9 //!
10 //! Under `--features fast-tests` the token bucket refills at 100/sec (burst
11 //! 20). On a fast Mac this is easy to deplete sequentially, but astra under
12 //! `--test-threads=8` + postgres contention slows per-request execution past
13 //! the refill rate — the bucket never empties and the test fails. Tests
14 //! tagged `#[cfg_attr(feature = "fast-tests", ignore)]` for that reason.
15 //!
16 //! Run them locally with:
17 //!
18 //! ```sh
19 //! TEST_DATABASE_URL="postgres:///postgres" \
20 //! cargo test --features fast-tests --test integration \
21 //! -- --ignored --test-threads=1 rate_limit
22 //! ```
23
24 use crate::harness::TestHarness;
25 use makenotwork::constants::{
26 API_WRITE_RATE_LIMIT_BURST, AUTH_RATE_LIMIT_BURST, SANDBOX_RATE_LIMIT_BURST,
27 };
28
29 // =============================================================================
30 // Auth rate limiting
31 // =============================================================================
32
33 /// Send AUTH_RATE_LIMIT_BURST + 1 login attempts rapidly and verify the last
34 /// one returns 429 Too Many Requests.
35 #[tokio::test]
36 #[cfg_attr(feature = "fast-tests", ignore)]
37 async fn auth_rate_limit_triggers_on_burst() {
38 let mut h = TestHarness::new().await;
39
40 // Use a distinct IP so we don't collide with other tests
41 h.client.set_forwarded_ip("10.0.0.1");
42
43 let mut got_429 = false;
44 // Send many rapid requests. The burst limit is AUTH_RATE_LIMIT_BURST with
45 // refill at 2/sec (per_millisecond(500)). Argon2 hashing can be slow, so
46 // we send a generous multiple of the burst to outpace refill.
47 // Login is CSRF-exempt so no CSRF token needed.
48 // Test auth rate limiting via the passkey endpoint which shares the auth
49 // rate limiter but doesn't invoke Argon2 password hashing. The login
50 // handler's ~600ms Argon2 cost per request lets the token bucket refill
51 // fast enough to prevent 429 in serial tests.
52 for _ in 0..(AUTH_RATE_LIMIT_BURST as usize * 3) {
53 let resp = h
54 .client
55 .post_json("/auth/passkey/start", r#"{"username":"nobody"}"#)
56 .await;
57 if resp.status == 429 {
58 got_429 = true;
59 break;
60 }
61 }
62
63 assert!(
64 got_429,
65 "Expected 429 after bursting auth requests but never got one (burst={})",
66 AUTH_RATE_LIMIT_BURST,
67 );
68 }
69
70 // =============================================================================
71 // Retry-After header
72 // =============================================================================
73
74 /// After triggering a rate limit, the 429 response must include a `retry-after`
75 /// header so clients know when to retry.
76 #[tokio::test]
77 #[cfg_attr(feature = "fast-tests", ignore)]
78 async fn rate_limit_returns_retry_after_header() {
79 let mut h = TestHarness::new().await;
80 h.client.set_forwarded_ip("10.0.1.1");
81
82 let mut last_resp = None;
83 // Use passkey endpoint (fast, no Argon2) to trigger rate limit.
84 for _ in 0..(AUTH_RATE_LIMIT_BURST as usize * 3) {
85 let resp = h
86 .client
87 .post_json("/auth/passkey/start", r#"{"username":"nobody"}"#)
88 .await;
89 if resp.status == 429 {
90 last_resp = Some(resp);
91 break;
92 }
93 }
94
95 let resp = last_resp.expect("Never got 429 — cannot check retry-after header");
96 assert_eq!(resp.status, 429);
97 assert!(
98 resp.header("retry-after").is_some(),
99 "429 response should include retry-after header, headers: {:?}",
100 resp.headers
101 );
102 }
103
104 // =============================================================================
105 // Per-IP independence
106 // =============================================================================
107
108 /// Exhaust the rate limit from one IP, then verify a different IP is not
109 /// affected. Uses X-Forwarded-For to distinguish IPs.
110 #[tokio::test]
111 #[cfg_attr(feature = "fast-tests", ignore)]
112 async fn rate_limit_different_ips_independent() {
113 let mut h = TestHarness::new().await;
114
115 // Exhaust burst from IP "1.2.3.4" using passkey endpoint (fast, no Argon2)
116 h.client.set_forwarded_ip("1.2.3.4");
117 let mut ip1_got_429 = false;
118 for _ in 0..(AUTH_RATE_LIMIT_BURST as usize * 3) {
119 let resp = h
120 .client
121 .post_json("/auth/passkey/start", r#"{"username":"nobody"}"#)
122 .await;
123 if resp.status == 429 {
124 ip1_got_429 = true;
125 break;
126 }
127 }
128 assert!(ip1_got_429, "IP 1.2.3.4 should be rate-limited");
129
130 // Switch to a fresh IP — should NOT be rate-limited
131 h.client.set_forwarded_ip("5.6.7.8");
132 let resp = h
133 .client
134 .post_json("/auth/passkey/start", r#"{"username":"nobody"}"#)
135 .await;
136 assert_ne!(
137 resp.status, 429,
138 "IP 5.6.7.8 should not be rate-limited (got 429)"
139 );
140 // Accept any non-429 status (likely 400 or 404)
141 }
142
143 // =============================================================================
144 // Sandbox rate limiting
145 // =============================================================================
146
147 /// Send SANDBOX_RATE_LIMIT_BURST + 1 POST /sandbox requests and verify 429.
148 /// Skipped with `fast-tests` — relaxed rate limits make this test meaningless.
149 #[tokio::test]
150 #[cfg_attr(feature = "fast-tests", ignore)]
151 async fn sandbox_rate_limit_triggers() {
152 let mut h = TestHarness::new().await;
153 h.client.set_forwarded_ip("10.0.2.1");
154
155 let mut got_429 = false;
156 // Send well beyond burst to account for token refill during slow test execution
157 for _ in 0..=(SANDBOX_RATE_LIMIT_BURST as usize + 5) {
158 // Each POST /sandbox needs a CSRF token; GET /sandbox provides one
159 let _page = h.client.get("/sandbox").await;
160 let resp = h.client.post_form("/sandbox", "").await;
161 if resp.status == 429 {
162 got_429 = true;
163 break;
164 }
165 }
166
167 assert!(
168 got_429,
169 "Expected 429 after bursting sandbox requests but never got one (burst={})",
170 SANDBOX_RATE_LIMIT_BURST
171 );
172 }
173
174 // =============================================================================
175 // API write rate limiting
176 // =============================================================================
177
178 /// As a logged-in creator, send API_WRITE_RATE_LIMIT_BURST + 1 POST requests
179 /// to a write endpoint and verify 429.
180 #[tokio::test]
181 #[cfg_attr(feature = "fast-tests", ignore)]
182 async fn api_write_rate_limit_triggers() {
183 let mut h = TestHarness::new().await;
184 h.client.set_forwarded_ip("10.0.3.1");
185 let _user_id = h.create_creator("ratelimiter").await;
186
187 let mut got_429 = false;
188 // Send well beyond burst to account for token refill during slow test execution
189 for _ in 0..=(API_WRITE_RATE_LIMIT_BURST as usize + 15) {
190 // POST /api/projects is a write endpoint under the write rate limiter.
191 // Most requests will fail (duplicate slug, validation) but they still
192 // count toward the rate limit bucket.
193 let resp = h
194 .client
195 .post_form("/api/projects", "slug=rl-test&title=Rate+Limit+Test")
196 .await;
197 if resp.status == 429 {
198 got_429 = true;
199 break;
200 }
201 }
202
203 assert!(
204 got_429,
205 "Expected 429 after bursting API write requests but never got one (burst={})",
206 API_WRITE_RATE_LIMIT_BURST
207 );
208 }
209
210 // =============================================================================
211 // Email-action routes (Run #11 Security fix)
212 // =============================================================================
213
214 /// All email-action routes share one per-IP auth rate limiter applied at the
215 /// router (`email_action_routes`). Run #11 found `/login-link`, `/reset-password`,
216 /// `/verify-email`, `/confirm-delete`, and `/unsubscribe` uncapped while only
217 /// `/forgot-password` was limited. This pins that the cap now fires on the
218 /// previously-uncapped routes. Ignored under fast-tests for the same
219 /// token-bucket-refill-vs-request-rate flakiness as the other rate-limit tests;
220 /// run with `--ignored --test-threads=1`.
221 #[tokio::test]
222 #[cfg_attr(feature = "fast-tests", ignore)]
223 async fn email_action_routes_are_rate_limited() {
224 let mut h = TestHarness::new().await;
225 h.client.set_forwarded_ip("10.0.7.7");
226
227 let mut got_429 = false;
228 for _ in 0..(AUTH_RATE_LIMIT_BURST as usize * 3) {
229 // GET so no CSRF token is needed; the governor layer runs before the
230 // handler, so the missing/invalid token doesn't matter for this assertion.
231 let resp = h.client.get("/login-link?token=nope").await;
232 if resp.status == 429 {
233 got_429 = true;
234 break;
235 }
236 }
237
238 assert!(
239 got_429,
240 "Expected 429 after bursting /login-link (burst={})",
241 AUTH_RATE_LIMIT_BURST,
242 );
243 }
244