Skip to main content

max / synckit

15.7 KB · 539 lines History Blame Raw
1 //! Transport behavior independent of any one endpoint: error classification,
2 //! retry policy, timeouts, the response body cap, and what the client does with
3 //! a malformed or unexpected response body.
4
5 use crate::common::*;
6
7 const STATUS_PATH: &str = "/api/v1/sync/status";
8 const PUSH_PATH: &str = "/api/v1/sync/push";
9 const PULL_PATH: &str = "/api/v1/sync/pull";
10 const AUTH_PATH: &str = "/api/v1/sync/auth";
11 const DEVICES_PATH: &str = "/api/v1/sync/devices";
12
13 /// The status body the retry tests fall through to once the failures are spent.
14 /// They assert on the retry, not on the numbers, so any well-formed body does.
15 fn ok_status() -> serde_json::Value {
16 json!({"total_changes": 0, "latest_cursor": null})
17 }
18
19 // ── Response body cap (DoS) ──
20
21 #[tokio::test]
22 async fn oversized_response_body_is_capped_not_buffered() {
23 // A hostile/buggy server streams a control-plane body far larger than the
24 // 8 MiB cap. The client must reject it (the cap fast-rejects on the honest
25 // Content-Length) instead of buffering it into memory and OOMing.
26 let kit = MockKit::start().await;
27 kit.get(STATUS_PATH)
28 .bytes(vec![b'x'; 9 * 1024 * 1024])
29 .await;
30
31 let err = kit.authed().status().await.unwrap_err();
32 assert!(
33 matches!(err, SyncKitError::Internal(ref m) if m.contains("cap")),
34 "expected the body cap to reject the oversized response, got: {err:?}"
35 );
36 }
37
38 // ── Error classification ──
39
40 #[tokio::test]
41 async fn error_429_is_retried() {
42 let kit = MockKit::start().await;
43 kit.get(STATUS_PATH)
44 .code(429)
45 .once()
46 .text("Too Many Requests")
47 .await;
48 kit.get(STATUS_PATH)
49 .json(json!({"total_changes": 10, "latest_cursor": 5}))
50 .await;
51
52 let status = kit.authed().status().await.unwrap();
53 assert_eq!(status.total_changes, 10);
54 }
55
56 #[tokio::test]
57 async fn error_400_not_retried() {
58 let kit = MockKit::start().await;
59 kit.post(DEVICES_PATH)
60 .code(400)
61 .exactly(1)
62 .text("Bad Request")
63 .await;
64
65 let err = kit
66 .authed()
67 .register_device("Device", "test")
68 .await
69 .unwrap_err();
70 assert!(matches!(err, SyncKitError::Server { status: 400, .. }));
71 }
72
73 // ── Status endpoint ──
74
75 #[tokio::test]
76 async fn status_success() {
77 let kit = MockKit::start().await;
78 kit.get(STATUS_PATH)
79 .json(json!({"total_changes": 42, "latest_cursor": 100}))
80 .await;
81
82 let status = kit.authed().status().await.unwrap();
83 assert_eq!(status.total_changes, 42);
84 assert_eq!(status.latest_cursor, Some(100));
85 }
86
87 #[tokio::test]
88 async fn status_retries_on_transient() {
89 let kit = MockKit::start().await;
90 kit.get(STATUS_PATH)
91 .code(504)
92 .once()
93 .text("Gateway Timeout")
94 .await;
95 kit.get(STATUS_PATH).json(ok_status()).await;
96
97 let status = kit.authed().status().await.unwrap();
98 assert_eq!(status.total_changes, 0);
99 }
100
101 // ── Malformed server responses ──
102
103 #[tokio::test]
104 async fn push_malformed_json_response_handled() {
105 let kit = MockKit::start().await;
106 kit.post(PUSH_PATH).text("not valid json at all").await;
107
108 let (client, _key) = kit.keyed();
109 let result = client.push(DeviceId::new(Uuid::new_v4()), vec![]).await;
110 assert!(result.is_err(), "Malformed JSON should produce an error");
111 // Should be a JSON parse error, not a panic
112 let err = result.unwrap_err();
113 assert!(
114 matches!(err, SyncKitError::Http(_) | SyncKitError::Json(_)),
115 "Expected Http or Json error for malformed response, got: {err:?}"
116 );
117 }
118
119 #[tokio::test]
120 async fn pull_malformed_json_response_handled() {
121 let kit = MockKit::start().await;
122 kit.post(PULL_PATH).text("{invalid json}").await;
123
124 let (client, _key) = kit.keyed();
125 let result = client.pull(DeviceId::new(Uuid::new_v4()), 0).await;
126 assert!(result.is_err(), "Malformed JSON should produce an error");
127 }
128
129 #[tokio::test]
130 async fn status_malformed_json_response_handled() {
131 let kit = MockKit::start().await;
132 kit.get(STATUS_PATH).text("this is not json").await;
133
134 let result = kit.authed().status().await;
135 assert!(result.is_err());
136 }
137
138 // ── Server error messages preserved ──
139
140 #[tokio::test]
141 async fn server_error_message_preserved() {
142 let kit = MockKit::start().await;
143 kit.get(STATUS_PATH)
144 .code(422)
145 .text("Validation failed: missing field")
146 .await;
147
148 let err = kit.authed().status().await.unwrap_err();
149 match err {
150 SyncKitError::Server {
151 status, message, ..
152 } => {
153 assert_eq!(status, 422);
154 assert!(
155 message.contains("Validation failed"),
156 "Error message should be preserved: {message}"
157 );
158 }
159 other => panic!("Expected Server error, got: {other:?}"),
160 }
161 }
162
163 // ── Config persistence (SyncKitConfig serialization) ──
164
165 #[tokio::test]
166 async fn config_serialization_roundtrip() {
167 let config = SyncKitConfig {
168 server_url: "https://makenot.work".to_string(),
169 api_key: "ak_test_12345".to_string(),
170 };
171
172 // SyncKitConfig derives Clone and Debug; verify round trip through Debug
173 let debug = format!("{config:?}");
174 assert!(debug.contains("makenot.work"));
175 assert!(debug.contains("ak_test_12345"));
176
177 let cloned = config.clone();
178 assert_eq!(cloned.server_url, config.server_url);
179 assert_eq!(cloned.api_key, config.api_key);
180 }
181
182 // ── API error mapping ──
183
184 #[tokio::test]
185 async fn all_4xx_error_codes_mapped() {
186 let kit = MockKit::start().await;
187
188 for status_code in [400, 401, 403, 404, 409, 422] {
189 kit.reset().await;
190 kit.get(STATUS_PATH)
191 .code(status_code)
192 .text(format!("Error {status_code}"))
193 .await;
194
195 let err = kit.authed().status().await.unwrap_err();
196
197 match err {
198 SyncKitError::Server {
199 status, message, ..
200 } => {
201 assert_eq!(status, status_code);
202 assert!(message.contains(&format!("Error {status_code}")));
203 }
204 other => panic!("Status {status_code} should map to Server error, got: {other:?}"),
205 }
206 }
207 }
208
209 #[tokio::test]
210 async fn all_5xx_error_codes_retried() {
211 for status_code in [500, 502, 503, 504] {
212 let kit = MockKit::start().await;
213 kit.get(STATUS_PATH)
214 .code(status_code)
215 .once()
216 .text("Server Error")
217 .await;
218 kit.get(STATUS_PATH).json(ok_status()).await;
219
220 let result = kit.authed().status().await;
221 assert!(
222 result.is_ok(),
223 "Status {status_code} should be retried and succeed: {result:?}"
224 );
225 }
226 }
227
228 // ── Retry count verification ──
229
230 #[tokio::test]
231 async fn retry_exhausts_all_attempts_on_persistent_503() {
232 let kit = MockKit::start().await;
233 kit.get(STATUS_PATH)
234 .code(503)
235 .text("Service Unavailable")
236 .await;
237
238 let err = kit.authed().status().await.unwrap_err();
239 assert!(matches!(err, SyncKitError::Server { status: 503, .. }));
240
241 // Should have made exactly 4 requests (1 initial + 3 retries)
242 let attempts = kit.hits(STATUS_PATH).await;
243 assert_eq!(
244 attempts, 4,
245 "Expected 4 total requests (1 + MAX_RETRIES=3), got {attempts}"
246 );
247 }
248
249 #[tokio::test]
250 async fn unsafe_op_makes_exactly_one_attempt_on_transient_error() {
251 // create_subscription_checkout is Idempotency::Unsafe (a retry could mint a
252 // second Stripe session), so a transient 503 must NOT be retried.
253 const CHECKOUT_PATH: &str = "/api/v1/sync/subscription/checkout";
254 let kit = MockKit::start().await;
255 kit.post(CHECKOUT_PATH)
256 .code(503)
257 .text("Service Unavailable")
258 .await;
259
260 let err = kit
261 .authed()
262 .create_subscription_checkout(1_000_000_000, synckit_client::BillingInterval::Monthly)
263 .await
264 .unwrap_err();
265 assert!(matches!(err, SyncKitError::Server { status: 503, .. }));
266
267 let attempts = kit.hits(CHECKOUT_PATH).await;
268 assert_eq!(
269 attempts, 1,
270 "Unsafe op must be attempted exactly once, got {attempts}"
271 );
272 }
273
274 #[tokio::test]
275 async fn retry_not_attempted_on_404() {
276 let kit = MockKit::start().await;
277 kit.get(STATUS_PATH).code(404).text("Not Found").await;
278
279 let err = kit.authed().status().await.unwrap_err();
280 assert!(matches!(err, SyncKitError::Server { status: 404, .. }));
281
282 assert_eq!(kit.hits(STATUS_PATH).await, 1, "404 should not be retried");
283 }
284
285 #[tokio::test]
286 async fn retry_succeeds_on_third_attempt() {
287 let kit = MockKit::start().await;
288 kit.get(STATUS_PATH)
289 .code(503)
290 .at_most(2)
291 .text("Service Unavailable")
292 .await;
293 kit.get(STATUS_PATH)
294 .json(json!({"total_changes": 7, "latest_cursor": 3}))
295 .await;
296
297 let status = kit.authed().status().await.unwrap();
298 assert_eq!(status.total_changes, 7);
299
300 assert_eq!(
301 kit.hits(STATUS_PATH).await,
302 3,
303 "Should succeed on 3rd attempt"
304 );
305 }
306
307 // ── Malformed / unexpected responses ──
308
309 #[tokio::test]
310 async fn authenticate_html_response_returns_error() {
311 let kit = MockKit::start().await;
312 kit.post(AUTH_PATH)
313 .reply(
314 ResponseTemplate::new(200)
315 .insert_header("content-type", "text/html")
316 .set_body_string("<html><body>Not JSON</body></html>"),
317 )
318 .await;
319
320 let err = kit
321 .client()
322 .authenticate("user@test.com", "pass", "test-key")
323 .await
324 .unwrap_err();
325 // reqwest .json() fails when body isn't valid JSON
326 assert!(
327 matches!(err, SyncKitError::Http(_) | SyncKitError::Json(_)),
328 "HTML response should produce Http or Json error, got: {err:?}"
329 );
330 }
331
332 #[tokio::test]
333 async fn push_empty_response_body_returns_error() {
334 let kit = MockKit::start().await;
335 kit.post(PUSH_PATH).text("").await;
336
337 let (client, _key) = kit.keyed();
338 let err = client
339 .push(DeviceId::new(Uuid::new_v4()), vec![])
340 .await
341 .unwrap_err();
342 assert!(
343 matches!(err, SyncKitError::Http(_) | SyncKitError::Json(_)),
344 "Empty body should produce parse error, got: {err:?}"
345 );
346 }
347
348 #[tokio::test]
349 async fn pull_response_missing_has_more_returns_error() {
350 let kit = MockKit::start().await;
351 kit.post(PULL_PATH)
352 .json(json!({
353 "changes": [],
354 "cursor": 0
355 // missing "has_more"
356 }))
357 .await;
358
359 let (client, _key) = kit.keyed();
360 let err = client
361 .pull(DeviceId::new(Uuid::new_v4()), 0)
362 .await
363 .unwrap_err();
364 assert!(
365 matches!(err, SyncKitError::Http(_) | SyncKitError::Json(_)),
366 "Missing has_more should produce parse error, got: {err:?}"
367 );
368 }
369
370 #[tokio::test]
371 async fn status_response_cursor_wrong_type_returns_error() {
372 let kit = MockKit::start().await;
373 kit.get(STATUS_PATH)
374 .json(json!({
375 "total_changes": 10,
376 "latest_cursor": "not-a-number"
377 }))
378 .await;
379
380 let err = kit.authed().status().await.unwrap_err();
381 assert!(
382 matches!(err, SyncKitError::Http(_) | SyncKitError::Json(_)),
383 "Wrong type for cursor should produce parse error, got: {err:?}"
384 );
385 }
386
387 #[tokio::test]
388 async fn authenticate_response_missing_app_id_returns_error() {
389 let kit = MockKit::start().await;
390 kit.post(AUTH_PATH)
391 .json(json!({
392 "token": fresh_token(),
393 "user_id": "550e8400-e29b-41d4-a716-446655440000"
394 // missing "app_id"
395 }))
396 .await;
397
398 let err = kit
399 .client()
400 .authenticate("user@test.com", "pass", "test-key")
401 .await
402 .unwrap_err();
403 assert!(
404 matches!(err, SyncKitError::Http(_) | SyncKitError::Json(_)),
405 "Missing app_id should produce parse error, got: {err:?}"
406 );
407 }
408
409 #[tokio::test]
410 async fn register_device_extra_fields_ignored() {
411 let kit = MockKit::start().await;
412
413 let (user_id, app_id) = test_ids();
414 kit.post(DEVICES_PATH)
415 .json(json!({
416 "id": Uuid::new_v4(),
417 "app_id": app_id,
418 "user_id": user_id,
419 "device_name": "Test Device",
420 "platform": "test",
421 "last_seen_at": "2025-01-01T00:00:00Z",
422 "created_at": "2025-01-01T00:00:00Z",
423 "extra_field": "should be ignored",
424 "unknown_number": 42
425 }))
426 .await;
427
428 let device = kit.authed().register_device("Test", "test").await.unwrap();
429 assert_eq!(device.device_name, "Test Device");
430 }
431
432 #[tokio::test]
433 async fn blob_upload_url_response_missing_already_exists_returns_error() {
434 let kit = MockKit::start().await;
435 kit.post("/api/v1/sync/blobs/upload")
436 .json(json!({
437 "upload_url": "https://s3.example.com/put"
438 // missing "already_exists"
439 }))
440 .await;
441
442 let result = kit.authed().blob_upload_url("hash", 100).await;
443 match result {
444 Err(err) => assert!(
445 matches!(err, SyncKitError::Http(_) | SyncKitError::Json(_)),
446 "Missing already_exists should produce error, got: {err:?}"
447 ),
448 Ok(_) => panic!("Expected error for missing already_exists field"),
449 }
450 }
451
452 #[tokio::test]
453 async fn server_returns_413_request_entity_too_large() {
454 let kit = MockKit::start().await;
455 kit.post(PUSH_PATH)
456 .code(413)
457 .text("Request entity too large")
458 .await;
459
460 let (client, _key) = kit.keyed();
461 let err = client
462 .push(DeviceId::new(Uuid::new_v4()), vec![])
463 .await
464 .unwrap_err();
465 match err {
466 SyncKitError::Server {
467 status, message, ..
468 } => {
469 assert_eq!(status, 413);
470 assert!(message.contains("too large"));
471 }
472 other => panic!("Expected Server error, got: {other:?}"),
473 }
474 }
475
476 // ── Timeout tests ──
477
478 /// The timeout lives on the reqwest client, so it has to be set at construction
479 /// rather than on the SyncKit client afterwards.
480 fn authed_short_timeout_client(kit: &MockKit) -> SyncKitClient {
481 // Builds a reqwest client directly rather than through SyncKitClient::new,
482 // so the provider has to be installed here.
483 ensure_crypto_provider();
484 let http = reqwest::Client::builder()
485 .timeout(Duration::from_millis(100))
486 .connect_timeout(Duration::from_millis(100))
487 .build()
488 .unwrap();
489 kit.authed_with_http(http)
490 }
491
492 #[tokio::test]
493 async fn status_times_out_on_slow_server() {
494 let kit = MockKit::start().await;
495 kit.get(STATUS_PATH)
496 .reply(
497 ResponseTemplate::new(200)
498 .set_body_json(ok_status())
499 .set_delay(Duration::from_secs(5)),
500 )
501 .await;
502
503 let err = authed_short_timeout_client(&kit)
504 .status()
505 .await
506 .unwrap_err();
507 // Timeout triggers Http error, which is transient, so it retries and eventually exhausts
508 assert!(
509 matches!(err, SyncKitError::Http(_)),
510 "Slow server should produce Http (timeout) error, got: {err:?}"
511 );
512 }
513
514 #[tokio::test]
515 async fn push_retries_on_timeout_then_succeeds() {
516 let kit = MockKit::start().await;
517
518 // First request: slow (will timeout)
519 kit.post(PUSH_PATH)
520 .once()
521 .reply(
522 ResponseTemplate::new(200)
523 .set_body_json(json!({"cursor": 1}))
524 .set_delay(Duration::from_secs(5)),
525 )
526 .await;
527 // Second request: fast (succeeds)
528 kit.post(PUSH_PATH).json(json!({"cursor": 42})).await;
529
530 let client = authed_short_timeout_client(&kit);
531 client.set_master_key_raw(synckit_client::crypto::generate_master_key());
532
533 let cursor = client
534 .push(DeviceId::new(Uuid::new_v4()), vec![])
535 .await
536 .unwrap();
537 assert_eq!(cursor, 42);
538 }
539