Skip to main content

max / synckit-client

81.7 KB · 2743 lines History Blame Raw
1 //! Integration tests using wiremock to simulate the MNW SyncKit server.
2 //!
3 //! These tests verify the full HTTP round-trip including retry behavior,
4 //! encryption/decryption, and error classification.
5
6 use std::sync::Arc;
7
8 use base64::Engine;
9 use chrono::Utc;
10 use serde_json::json;
11 use uuid::Uuid;
12 use wiremock::matchers::{method, path};
13 use wiremock::{Mock, MockServer, ResponseTemplate};
14
15 use std::time::Duration;
16 use synckit_client::{ChangeEntry, ChangeOp, SyncKitClient, SyncKitConfig, SyncKitError};
17
18 // ── Helpers ──
19
20 fn fake_jwt(exp: i64) -> String {
21 let header =
22 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(r#"{"alg":"HS256","typ":"JWT"}"#);
23 let payload = json!({
24 "sub": "550e8400-e29b-41d4-a716-446655440000",
25 "app": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
26 "exp": exp,
27 "iat": exp - 3600,
28 });
29 let payload_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD
30 .encode(payload.to_string().as_bytes());
31 let sig = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"fake-signature");
32 format!("{header}.{payload_b64}.{sig}")
33 }
34
35 fn fresh_token() -> String {
36 fake_jwt(Utc::now().timestamp() + 3600)
37 }
38
39 fn test_ids() -> (Uuid, Uuid) {
40 (
41 Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(),
42 Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap(),
43 )
44 }
45
46 fn client_for(server: &MockServer) -> SyncKitClient {
47 SyncKitClient::new(SyncKitConfig {
48 server_url: server.uri(),
49 api_key: "test-api-key".to_string(),
50 })
51 }
52
53 fn authed_client(server: &MockServer) -> SyncKitClient {
54 let client = client_for(server);
55 let (user_id, app_id) = test_ids();
56 client
57 .restore_session(&fresh_token(), user_id, app_id);
58 client
59 }
60
61 fn auth_response_json() -> serde_json::Value {
62 let (user_id, app_id) = test_ids();
63 json!({
64 "token": fresh_token(),
65 "user_id": user_id,
66 "app_id": app_id,
67 })
68 }
69
70 fn device_json() -> serde_json::Value {
71 let (user_id, app_id) = test_ids();
72 json!({
73 "id": Uuid::new_v4(),
74 "app_id": app_id,
75 "user_id": user_id,
76 "device_name": "Test Device",
77 "platform": "test",
78 "last_seen_at": "2025-01-01T00:00:00Z",
79 "created_at": "2025-01-01T00:00:00Z",
80 })
81 }
82
83 // ── Auth flow ──
84
85 #[tokio::test]
86 async fn authenticate_success_stores_session() {
87 let server = MockServer::start().await;
88
89 Mock::given(method("POST"))
90 .and(path("/api/sync/auth"))
91 .respond_with(ResponseTemplate::new(200).set_body_json(auth_response_json()))
92 .mount(&server)
93 .await;
94
95 let client = client_for(&server);
96 let (user_id, app_id) = client
97 .authenticate("user@test.com", "password")
98 .await
99 .unwrap();
100
101 let info = client.session_info().expect("session stored");
102 assert_eq!(info.user_id, user_id);
103 assert_eq!(info.app_id, app_id);
104 }
105
106 #[tokio::test]
107 async fn authenticate_wrong_password_no_retry() {
108 let server = MockServer::start().await;
109
110 Mock::given(method("POST"))
111 .and(path("/api/sync/auth"))
112 .respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized"))
113 .expect(1) // Must be called exactly once (no retry)
114 .mount(&server)
115 .await;
116
117 let client = client_for(&server);
118 let err = client
119 .authenticate("user@test.com", "wrong")
120 .await
121 .unwrap_err();
122
123 assert!(
124 matches!(err, SyncKitError::Server { status: 401, .. }),
125 "Expected 401 error, got: {err:?}"
126 );
127 }
128
129 #[tokio::test]
130 async fn authenticate_retries_on_503() {
131 let server = MockServer::start().await;
132
133 Mock::given(method("POST"))
134 .and(path("/api/sync/auth"))
135 .respond_with(ResponseTemplate::new(503).set_body_string("Service Unavailable"))
136 .up_to_n_times(1)
137 .mount(&server)
138 .await;
139
140 Mock::given(method("POST"))
141 .and(path("/api/sync/auth"))
142 .respond_with(ResponseTemplate::new(200).set_body_json(auth_response_json()))
143 .mount(&server)
144 .await;
145
146 let client = client_for(&server);
147 let result = client.authenticate("user@test.com", "password").await;
148 assert!(result.is_ok(), "Should succeed after retry: {result:?}");
149 }
150
151 #[tokio::test]
152 async fn authenticate_with_code_success() {
153 let server = MockServer::start().await;
154
155 let (user_id, app_id) = test_ids();
156 Mock::given(method("POST"))
157 .and(path("/oauth/token"))
158 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
159 "access_token": fresh_token(),
160 "token_type": "Bearer",
161 "expires_in": 3600,
162 "user_id": user_id,
163 "app_id": app_id,
164 })))
165 .mount(&server)
166 .await;
167
168 let client = client_for(&server);
169 let (uid, aid) = client
170 .authenticate_with_code("auth-code", "verifier", 8080)
171 .await
172 .unwrap();
173
174 assert_eq!(uid, user_id);
175 assert_eq!(aid, app_id);
176 assert!(client.session_info().is_some());
177 }
178
179 // ── Device management ──
180
181 #[tokio::test]
182 async fn register_device_success() {
183 let server = MockServer::start().await;
184
185 Mock::given(method("POST"))
186 .and(path("/api/sync/devices"))
187 .respond_with(ResponseTemplate::new(200).set_body_json(device_json()))
188 .mount(&server)
189 .await;
190
191 let client = authed_client(&server);
192 let device = client.register_device("MacBook", "macos").await.unwrap();
193 assert_eq!(device.device_name, "Test Device");
194 }
195
196 #[tokio::test]
197 async fn register_device_retries_on_transient() {
198 let server = MockServer::start().await;
199
200 Mock::given(method("POST"))
201 .and(path("/api/sync/devices"))
202 .respond_with(ResponseTemplate::new(502).set_body_string("Bad Gateway"))
203 .up_to_n_times(1)
204 .mount(&server)
205 .await;
206
207 Mock::given(method("POST"))
208 .and(path("/api/sync/devices"))
209 .respond_with(ResponseTemplate::new(200).set_body_json(device_json()))
210 .mount(&server)
211 .await;
212
213 let client = authed_client(&server);
214 let result = client.register_device("MacBook", "macos").await;
215 assert!(result.is_ok(), "Should succeed after retry: {result:?}");
216 }
217
218 #[tokio::test]
219 async fn list_devices_success() {
220 let server = MockServer::start().await;
221
222 Mock::given(method("GET"))
223 .and(path("/api/sync/devices"))
224 .respond_with(ResponseTemplate::new(200).set_body_json(json!([device_json()])))
225 .mount(&server)
226 .await;
227
228 let client = authed_client(&server);
229 let devices = client.list_devices().await.unwrap();
230 assert_eq!(devices.len(), 1);
231 assert_eq!(devices[0].device_name, "Test Device");
232 }
233
234 // ── Push / Pull with encryption ──
235
236 #[tokio::test]
237 async fn push_encrypts_data() {
238 let server = MockServer::start().await;
239
240 Mock::given(method("POST"))
241 .and(path("/api/sync/push"))
242 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"cursor": 1})))
243 .mount(&server)
244 .await;
245
246 let client = authed_client(&server);
247 let key = synckit_client::crypto::generate_master_key();
248 client.set_master_key_raw(key);
249
250 let device_id = Uuid::new_v4();
251 let cursor = client
252 .push(
253 device_id,
254 vec![ChangeEntry {
255 table: "tasks".into(),
256 op: ChangeOp::Insert,
257 row_id: "row-1".into(),
258 timestamp: Utc::now(),
259 data: Some(json!({"title": "Secret task"})),
260 }],
261 )
262 .await
263 .unwrap();
264
265 assert_eq!(cursor, 1);
266
267 // Verify the request body was sent with encrypted data (not plaintext)
268 let requests = server.received_requests().await.unwrap();
269 let push_req = requests
270 .iter()
271 .find(|r| r.url.path() == "/api/sync/push")
272 .unwrap();
273 let body: serde_json::Value = serde_json::from_slice(&push_req.body).unwrap();
274 let wire_data = body["changes"][0]["data"].as_str().unwrap();
275 assert!(
276 !wire_data.contains("Secret task"),
277 "Plaintext should not appear on the wire"
278 );
279 }
280
281 #[tokio::test]
282 async fn pull_decrypts_data() {
283 let server = MockServer::start().await;
284
285 let client = authed_client(&server);
286 let key = synckit_client::crypto::generate_master_key();
287 client.set_master_key_raw(key);
288
289 // Encrypt a value to simulate what the server would return
290 let plaintext = json!({"title": "Decrypted task"});
291 let encrypted = synckit_client::crypto::encrypt_json(&plaintext, &key).unwrap();
292
293 let device_id = Uuid::new_v4();
294 Mock::given(method("POST"))
295 .and(path("/api/sync/pull"))
296 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
297 "changes": [{
298 "seq": 1,
299 "device_id": device_id,
300 "table": "tasks",
301 "op": "INSERT",
302 "row_id": "row-1",
303 "timestamp": "2025-06-01T12:00:00Z",
304 "data": encrypted,
305 }],
306 "cursor": 1,
307 "has_more": false,
308 })))
309 .mount(&server)
310 .await;
311
312 let (changes, cursor, has_more) = client.pull(device_id, 0).await.unwrap();
313 assert_eq!(changes.len(), 1);
314 assert_eq!(cursor, 1);
315 assert!(!has_more);
316 assert_eq!(changes[0].data.as_ref().unwrap(), &plaintext);
317 }
318
319 #[tokio::test]
320 async fn push_retries_on_503() {
321 let server = MockServer::start().await;
322
323 Mock::given(method("POST"))
324 .and(path("/api/sync/push"))
325 .respond_with(ResponseTemplate::new(503))
326 .up_to_n_times(1)
327 .mount(&server)
328 .await;
329
330 Mock::given(method("POST"))
331 .and(path("/api/sync/push"))
332 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"cursor": 5})))
333 .mount(&server)
334 .await;
335
336 let client = authed_client(&server);
337 let key = synckit_client::crypto::generate_master_key();
338 client.set_master_key_raw(key);
339
340 let cursor = client.push(Uuid::new_v4(), vec![]).await.unwrap();
341 assert_eq!(cursor, 5);
342 }
343
344 #[tokio::test]
345 async fn push_fails_immediately_on_401() {
346 let server = MockServer::start().await;
347
348 Mock::given(method("POST"))
349 .and(path("/api/sync/push"))
350 .respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized"))
351 .expect(1)
352 .mount(&server)
353 .await;
354
355 let client = authed_client(&server);
356 let key = synckit_client::crypto::generate_master_key();
357 client.set_master_key_raw(key);
358
359 let err = client.push(Uuid::new_v4(), vec![]).await.unwrap_err();
360 assert!(matches!(err, SyncKitError::Server { status: 401, .. }));
361 }
362
363 #[tokio::test]
364 async fn pull_with_has_more_pagination() {
365 let server = MockServer::start().await;
366
367 let client = authed_client(&server);
368 let key = synckit_client::crypto::generate_master_key();
369 client.set_master_key_raw(key);
370
371 let device_id = Uuid::new_v4();
372
373 // First pull: has_more = true
374 Mock::given(method("POST"))
375 .and(path("/api/sync/pull"))
376 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
377 "changes": [],
378 "cursor": 50,
379 "has_more": true,
380 })))
381 .up_to_n_times(1)
382 .mount(&server)
383 .await;
384
385 let (changes, cursor, has_more) = client.pull(device_id, 0).await.unwrap();
386 assert!(changes.is_empty());
387 assert_eq!(cursor, 50);
388 assert!(has_more);
389
390 // Second pull from cursor 50: has_more = false
391 Mock::given(method("POST"))
392 .and(path("/api/sync/pull"))
393 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
394 "changes": [],
395 "cursor": 100,
396 "has_more": false,
397 })))
398 .mount(&server)
399 .await;
400
401 let (_, cursor2, has_more2) = client.pull(device_id, 50).await.unwrap();
402 assert_eq!(cursor2, 100);
403 assert!(!has_more2);
404 }
405
406 // ── Blob operations ──
407
408 #[tokio::test]
409 async fn blob_upload_url_success() {
410 let server = MockServer::start().await;
411
412 Mock::given(method("POST"))
413 .and(path("/api/sync/blobs/upload"))
414 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
415 "upload_url": "https://s3.example.com/put",
416 "already_exists": false,
417 })))
418 .mount(&server)
419 .await;
420
421 let client = authed_client(&server);
422 let resp = client.blob_upload_url("sha256-abc", 1024).await.unwrap();
423 assert_eq!(resp.upload_url, "https://s3.example.com/put");
424 assert!(!resp.already_exists);
425 }
426
427 #[tokio::test]
428 async fn blob_upload_encrypts_data() {
429 let server = MockServer::start().await;
430
431 let upload_path = "/s3/upload";
432 Mock::given(method("PUT"))
433 .and(path(upload_path))
434 .respond_with(ResponseTemplate::new(200))
435 .mount(&server)
436 .await;
437
438 let client = authed_client(&server);
439 let key = synckit_client::crypto::generate_master_key();
440 client.set_master_key_raw(key);
441
442 let plaintext = b"hello blob data";
443 let presigned = format!("{}{}", server.uri(), upload_path);
444 client
445 .blob_upload(&presigned, plaintext.to_vec())
446 .await
447 .unwrap();
448
449 // Verify uploaded body is encrypted (not plaintext)
450 let requests = server.received_requests().await.unwrap();
451 let upload_req = requests
452 .iter()
453 .find(|r| r.url.path() == upload_path)
454 .unwrap();
455 assert!(
456 !upload_req
457 .body
458 .windows(plaintext.len())
459 .any(|w| w == plaintext),
460 "Plaintext should not appear in uploaded body"
461 );
462 // Encrypted blob should be larger due to nonce + tag overhead
463 assert!(upload_req.body.len() > plaintext.len());
464 }
465
466 #[tokio::test]
467 async fn blob_download_decrypts_data() {
468 let server = MockServer::start().await;
469
470 let client = authed_client(&server);
471 let key = synckit_client::crypto::generate_master_key();
472 client.set_master_key_raw(key);
473
474 // Encrypt data to simulate what S3 would return
475 let plaintext = b"decrypted blob content";
476 let encrypted = synckit_client::crypto::encrypt_bytes(plaintext, &key).unwrap();
477
478 let download_path = "/s3/download";
479 Mock::given(method("GET"))
480 .and(path(download_path))
481 .respond_with(ResponseTemplate::new(200).set_body_bytes(encrypted))
482 .mount(&server)
483 .await;
484
485 let presigned = format!("{}{}", server.uri(), download_path);
486 let result = client.blob_download(&presigned).await.unwrap();
487 assert_eq!(result, plaintext);
488 }
489
490 #[tokio::test]
491 async fn blob_upload_retries_on_503() {
492 let server = MockServer::start().await;
493
494 let upload_path = "/s3/retry-upload";
495 Mock::given(method("PUT"))
496 .and(path(upload_path))
497 .respond_with(ResponseTemplate::new(503))
498 .up_to_n_times(1)
499 .mount(&server)
500 .await;
501
502 Mock::given(method("PUT"))
503 .and(path(upload_path))
504 .respond_with(ResponseTemplate::new(200))
505 .mount(&server)
506 .await;
507
508 let client = authed_client(&server);
509 let key = synckit_client::crypto::generate_master_key();
510 client.set_master_key_raw(key);
511
512 let presigned = format!("{}{}", server.uri(), upload_path);
513 let result = client.blob_upload(&presigned, b"data".to_vec()).await;
514 assert!(result.is_ok(), "Should succeed after retry: {result:?}");
515 }
516
517 // ── Token handling ──
518
519 #[tokio::test]
520 async fn expired_jwt_returns_token_expired() {
521 let server = MockServer::start().await;
522 let client = client_for(&server);
523 let (user_id, app_id) = test_ids();
524
525 let expired = fake_jwt(Utc::now().timestamp() - 3600);
526 client.restore_session(&expired, user_id, app_id);
527
528 let err = client.status().await.unwrap_err();
529 assert!(
530 matches!(err, SyncKitError::TokenExpired),
531 "Expected TokenExpired, got: {err:?}"
532 );
533 }
534
535 #[tokio::test]
536 async fn near_expiry_jwt_returns_token_expired() {
537 let server = MockServer::start().await;
538 let client = client_for(&server);
539 let (user_id, app_id) = test_ids();
540
541 // Token expires in 10 seconds (within 30-second buffer)
542 let near_expiry = fake_jwt(Utc::now().timestamp() + 10);
543 client
544 .restore_session(&near_expiry, user_id, app_id);
545
546 let err = client.status().await.unwrap_err();
547 assert!(
548 matches!(err, SyncKitError::TokenExpired),
549 "Expected TokenExpired, got: {err:?}"
550 );
551 }
552
553 // ── Session management ──
554
555 #[tokio::test]
556 async fn restore_then_clear_session() {
557 let server = MockServer::start().await;
558
559 Mock::given(method("GET"))
560 .and(path("/api/sync/status"))
561 .respond_with(
562 ResponseTemplate::new(200)
563 .set_body_json(json!({"total_changes": 0, "latest_cursor": null})),
564 )
565 .mount(&server)
566 .await;
567
568 let client = authed_client(&server);
569
570 // Should work while authenticated
571 let status = client.status().await.unwrap();
572 assert_eq!(status.total_changes, 0);
573
574 // Clear session
575 client.clear_session();
576
577 // Now should fail
578 let err = client.status().await.unwrap_err();
579 assert!(matches!(err, SyncKitError::NotAuthenticated));
580 }
581
582 #[tokio::test]
583 async fn status_without_auth_returns_not_authenticated() {
584 let server = MockServer::start().await;
585 let client = client_for(&server);
586
587 let err = client.status().await.unwrap_err();
588 assert!(matches!(err, SyncKitError::NotAuthenticated));
589 }
590
591 #[tokio::test]
592 async fn push_without_auth_returns_not_authenticated() {
593 let server = MockServer::start().await;
594 let client = client_for(&server);
595
596 let err = client.push(Uuid::new_v4(), vec![]).await.unwrap_err();
597 assert!(matches!(err, SyncKitError::NotAuthenticated));
598 }
599
600 // ── Key management ──
601
602 #[tokio::test]
603 async fn has_server_key_true_on_200() {
604 let server = MockServer::start().await;
605
606 Mock::given(method("GET"))
607 .and(path("/api/sync/keys"))
608 .respond_with(
609 ResponseTemplate::new(200)
610 .set_body_json(json!({"encrypted_key": "envelope-data"})),
611 )
612 .mount(&server)
613 .await;
614
615 let client = authed_client(&server);
616 assert!(client.has_server_key().await.unwrap());
617 }
618
619 #[tokio::test]
620 async fn has_server_key_false_on_404() {
621 let server = MockServer::start().await;
622
623 Mock::given(method("GET"))
624 .and(path("/api/sync/keys"))
625 .respond_with(ResponseTemplate::new(404))
626 .mount(&server)
627 .await;
628
629 let client = authed_client(&server);
630 assert!(!client.has_server_key().await.unwrap());
631 }
632
633 #[tokio::test]
634 async fn has_server_key_retries_on_500() {
635 let server = MockServer::start().await;
636
637 Mock::given(method("GET"))
638 .and(path("/api/sync/keys"))
639 .respond_with(ResponseTemplate::new(500).set_body_string("Internal Server Error"))
640 .up_to_n_times(1)
641 .mount(&server)
642 .await;
643
644 Mock::given(method("GET"))
645 .and(path("/api/sync/keys"))
646 .respond_with(
647 ResponseTemplate::new(200)
648 .set_body_json(json!({"encrypted_key": "envelope"})),
649 )
650 .mount(&server)
651 .await;
652
653 let client = authed_client(&server);
654 assert!(client.has_server_key().await.unwrap());
655 }
656
657 // ── Concurrent access ──
658
659 #[tokio::test]
660 async fn concurrent_push_pull_no_panics() {
661 let server = MockServer::start().await;
662
663 Mock::given(method("POST"))
664 .and(path("/api/sync/push"))
665 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"cursor": 1})))
666 .mount(&server)
667 .await;
668
669 let device_id = Uuid::new_v4();
670 Mock::given(method("POST"))
671 .and(path("/api/sync/pull"))
672 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
673 "changes": [],
674 "cursor": 0,
675 "has_more": false,
676 })))
677 .mount(&server)
678 .await;
679
680 let client = Arc::new(authed_client(&server));
681 let key = synckit_client::crypto::generate_master_key();
682 client.set_master_key_raw(key);
683
684 let mut handles = Vec::new();
685 for _ in 0..4 {
686 let c = Arc::clone(&client);
687 let did = device_id;
688 handles.push(tokio::spawn(async move {
689 let _ = c.push(did, vec![]).await;
690 let _ = c.pull(did, 0).await;
691 }));
692 }
693
694 for h in handles {
695 h.await.unwrap(); // No panics
696 }
697 }
698
699 // ── Error classification ──
700
701 #[tokio::test]
702 async fn error_429_is_retried() {
703 let server = MockServer::start().await;
704
705 Mock::given(method("GET"))
706 .and(path("/api/sync/status"))
707 .respond_with(ResponseTemplate::new(429).set_body_string("Too Many Requests"))
708 .up_to_n_times(1)
709 .mount(&server)
710 .await;
711
712 Mock::given(method("GET"))
713 .and(path("/api/sync/status"))
714 .respond_with(
715 ResponseTemplate::new(200)
716 .set_body_json(json!({"total_changes": 10, "latest_cursor": 5})),
717 )
718 .mount(&server)
719 .await;
720
721 let client = authed_client(&server);
722 let status = client.status().await.unwrap();
723 assert_eq!(status.total_changes, 10);
724 }
725
726 #[tokio::test]
727 async fn error_400_not_retried() {
728 let server = MockServer::start().await;
729
730 Mock::given(method("POST"))
731 .and(path("/api/sync/devices"))
732 .respond_with(ResponseTemplate::new(400).set_body_string("Bad Request"))
733 .expect(1)
734 .mount(&server)
735 .await;
736
737 let client = authed_client(&server);
738 let err = client
739 .register_device("Device", "test")
740 .await
741 .unwrap_err();
742 assert!(matches!(err, SyncKitError::Server { status: 400, .. }));
743 }
744
745 // ── Status endpoint ──
746
747 #[tokio::test]
748 async fn status_success() {
749 let server = MockServer::start().await;
750
751 Mock::given(method("GET"))
752 .and(path("/api/sync/status"))
753 .respond_with(
754 ResponseTemplate::new(200)
755 .set_body_json(json!({"total_changes": 42, "latest_cursor": 100})),
756 )
757 .mount(&server)
758 .await;
759
760 let client = authed_client(&server);
761 let status = client.status().await.unwrap();
762 assert_eq!(status.total_changes, 42);
763 assert_eq!(status.latest_cursor, Some(100));
764 }
765
766 #[tokio::test]
767 async fn status_retries_on_transient() {
768 let server = MockServer::start().await;
769
770 Mock::given(method("GET"))
771 .and(path("/api/sync/status"))
772 .respond_with(ResponseTemplate::new(504).set_body_string("Gateway Timeout"))
773 .up_to_n_times(1)
774 .mount(&server)
775 .await;
776
777 Mock::given(method("GET"))
778 .and(path("/api/sync/status"))
779 .respond_with(
780 ResponseTemplate::new(200)
781 .set_body_json(json!({"total_changes": 0, "latest_cursor": null})),
782 )
783 .mount(&server)
784 .await;
785
786 let client = authed_client(&server);
787 let status = client.status().await.unwrap();
788 assert_eq!(status.total_changes, 0);
789 }
790
791 // ── Blob confirm ──
792
793 #[tokio::test]
794 async fn blob_confirm_success() {
795 let server = MockServer::start().await;
796
797 Mock::given(method("POST"))
798 .and(path("/api/sync/blobs/confirm"))
799 .respond_with(ResponseTemplate::new(200))
800 .mount(&server)
801 .await;
802
803 let client = authed_client(&server);
804 client.blob_confirm("sha256-abc", 1024).await.unwrap();
805 }
806
807 // ── Blob download URL ──
808
809 #[tokio::test]
810 async fn blob_download_url_success() {
811 let server = MockServer::start().await;
812
813 Mock::given(method("POST"))
814 .and(path("/api/sync/blobs/download"))
815 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
816 "download_url": "https://s3.example.com/get",
817 })))
818 .mount(&server)
819 .await;
820
821 let client = authed_client(&server);
822 let url = client.blob_download_url("sha256-abc").await.unwrap();
823 assert_eq!(url, "https://s3.example.com/get");
824 }
825
826 // ── change_password: CRITICAL bug fix tests ──
827
828 /// Helper: set up a server that serves a wrapped master key envelope.
829 /// Returns (client, master_key, envelope_json).
830 async fn setup_change_password_test(
831 server: &MockServer,
832 password: &str,
833 ) -> (SyncKitClient, [u8; 32], String) {
834 let client = authed_client(server);
835 let master_key = synckit_client::crypto::generate_master_key();
836 let envelope = synckit_client::crypto::wrap_master_key(&master_key, password).unwrap();
837
838 // Cache the master key in the client (simulating normal logged-in state)
839 client.set_master_key_raw(master_key);
840
841 (client, master_key, envelope)
842 }
843
844 #[tokio::test]
845 async fn change_password_wrong_old_password_with_cached_key_fails() {
846 let server = MockServer::start().await;
847 let (client, _master_key, envelope) =
848 setup_change_password_test(&server, "correct-old-pass").await;
849
850 // Server returns the envelope on GET
851 Mock::given(method("GET"))
852 .and(path("/api/sync/keys"))
853 .respond_with(
854 ResponseTemplate::new(200)
855 .set_body_json(json!({"encrypted_key": envelope})),
856 )
857 .mount(&server)
858 .await;
859
860 // Attempt to change password with wrong old password.
861 // The key IS cached, but the old password must still be validated.
862 let result = client
863 .change_password("wrong-old-pass", "new-pass")
864 .await;
865
866 assert!(
867 result.is_err(),
868 "change_password must fail when old_password is wrong, even with cached key"
869 );
870 assert!(
871 matches!(result.unwrap_err(), SyncKitError::DecryptionFailed),
872 "Should get DecryptionFailed for wrong old password"
873 );
874 }
875
876 #[tokio::test]
877 async fn change_password_correct_old_password_with_cached_key_succeeds() {
878 let server = MockServer::start().await;
879 let (client, master_key, envelope) =
880 setup_change_password_test(&server, "correct-old-pass").await;
881
882 // Server returns the envelope on GET
883 Mock::given(method("GET"))
884 .and(path("/api/sync/keys"))
885 .respond_with(
886 ResponseTemplate::new(200)
887 .set_body_json(json!({"encrypted_key": envelope})),
888 )
889 .mount(&server)
890 .await;
891
892 // Server accepts the new envelope on PUT
893 Mock::given(method("PUT"))
894 .and(path("/api/sync/keys"))
895 .respond_with(ResponseTemplate::new(200))
896 .mount(&server)
897 .await;
898
899 let result = client
900 .change_password("correct-old-pass", "new-pass")
901 .await;
902 assert!(result.is_ok(), "change_password should succeed with correct old password");
903
904 // Verify the PUT request was made (new envelope was uploaded)
905 let requests = server.received_requests().await.unwrap();
906 let put_requests: Vec<_> = requests
907 .iter()
908 .filter(|r| r.method.as_str() == "PUT" && r.url.path() == "/api/sync/keys")
909 .collect();
910 assert_eq!(put_requests.len(), 1, "Should have sent exactly one PUT");
911
912 // Verify the new envelope can be unwrapped with the new password
913 let put_body: serde_json::Value =
914 serde_json::from_slice(&put_requests[0].body).unwrap();
915 let new_envelope = put_body["encrypted_key"].as_str().unwrap();
916 let recovered = synckit_client::crypto::unwrap_master_key(new_envelope, "new-pass").unwrap();
917 assert_eq!(recovered, master_key, "New envelope should unwrap to the same master key");
918 }
919
920 #[tokio::test]
921 async fn change_password_wrong_old_password_without_cached_key_fails() {
922 let server = MockServer::start().await;
923 let master_key = synckit_client::crypto::generate_master_key();
924 let envelope =
925 synckit_client::crypto::wrap_master_key(&master_key, "correct-old-pass").unwrap();
926
927 let client = authed_client(&server);
928 // Deliberately NOT setting master key -- no cached key
929
930 Mock::given(method("GET"))
931 .and(path("/api/sync/keys"))
932 .respond_with(
933 ResponseTemplate::new(200)
934 .set_body_json(json!({"encrypted_key": envelope})),
935 )
936 .mount(&server)
937 .await;
938
939 let result = client
940 .change_password("wrong-old-pass", "new-pass")
941 .await;
942
943 assert!(
944 result.is_err(),
945 "change_password must fail with wrong old password even without cached key"
946 );
947 assert!(matches!(
948 result.unwrap_err(),
949 SyncKitError::DecryptionFailed
950 ));
951 }
952
953 #[tokio::test]
954 async fn change_password_old_envelope_invalid_with_new_password() {
955 let server = MockServer::start().await;
956 let (client, _master_key, envelope) =
957 setup_change_password_test(&server, "old-pass").await;
958
959 Mock::given(method("GET"))
960 .and(path("/api/sync/keys"))
961 .respond_with(
962 ResponseTemplate::new(200)
963 .set_body_json(json!({"encrypted_key": envelope})),
964 )
965 .mount(&server)
966 .await;
967
968 Mock::given(method("PUT"))
969 .and(path("/api/sync/keys"))
970 .respond_with(ResponseTemplate::new(200))
971 .mount(&server)
972 .await;
973
974 client
975 .change_password("old-pass", "new-pass")
976 .await
977 .unwrap();
978
979 // Old password should NOT work on the new envelope
980 let requests = server.received_requests().await.unwrap();
981 let put_req = requests
982 .iter()
983 .find(|r| r.method.as_str() == "PUT" && r.url.path() == "/api/sync/keys")
984 .unwrap();
985 let body: serde_json::Value = serde_json::from_slice(&put_req.body).unwrap();
986 let new_envelope = body["encrypted_key"].as_str().unwrap();
987
988 let result = synckit_client::crypto::unwrap_master_key(new_envelope, "old-pass");
989 assert!(
990 result.is_err(),
991 "Old password must not work on the new envelope"
992 );
993 }
994
995 // ── Empty changelog push ──
996
997 #[tokio::test]
998 async fn push_empty_changes_succeeds() {
999 let server = MockServer::start().await;
1000
1001 Mock::given(method("POST"))
1002 .and(path("/api/sync/push"))
1003 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"cursor": 0})))
1004 .mount(&server)
1005 .await;
1006
1007 let client = authed_client(&server);
1008 let key = synckit_client::crypto::generate_master_key();
1009 client.set_master_key_raw(key);
1010
1011 let cursor = client.push(Uuid::new_v4(), vec![]).await.unwrap();
1012 assert_eq!(cursor, 0);
1013 }
1014
1015 // ── Malformed server responses ──
1016
1017 #[tokio::test]
1018 async fn push_malformed_json_response_handled() {
1019 let server = MockServer::start().await;
1020
1021 Mock::given(method("POST"))
1022 .and(path("/api/sync/push"))
1023 .respond_with(
1024 ResponseTemplate::new(200).set_body_string("not valid json at all"),
1025 )
1026 .mount(&server)
1027 .await;
1028
1029 let client = authed_client(&server);
1030 let key = synckit_client::crypto::generate_master_key();
1031 client.set_master_key_raw(key);
1032
1033 let result = client.push(Uuid::new_v4(), vec![]).await;
1034 assert!(result.is_err(), "Malformed JSON should produce an error");
1035 // Should be a JSON parse error, not a panic
1036 let err = result.unwrap_err();
1037 assert!(
1038 matches!(err, SyncKitError::Http(_) | SyncKitError::Json(_)),
1039 "Expected Http or Json error for malformed response, got: {err:?}"
1040 );
1041 }
1042
1043 #[tokio::test]
1044 async fn pull_malformed_json_response_handled() {
1045 let server = MockServer::start().await;
1046
1047 Mock::given(method("POST"))
1048 .and(path("/api/sync/pull"))
1049 .respond_with(
1050 ResponseTemplate::new(200).set_body_string("{invalid json}"),
1051 )
1052 .mount(&server)
1053 .await;
1054
1055 let client = authed_client(&server);
1056 let key = synckit_client::crypto::generate_master_key();
1057 client.set_master_key_raw(key);
1058
1059 let result = client.pull(Uuid::new_v4(), 0).await;
1060 assert!(result.is_err(), "Malformed JSON should produce an error");
1061 }
1062
1063 #[tokio::test]
1064 async fn status_malformed_json_response_handled() {
1065 let server = MockServer::start().await;
1066
1067 Mock::given(method("GET"))
1068 .and(path("/api/sync/status"))
1069 .respond_with(
1070 ResponseTemplate::new(200).set_body_string("this is not json"),
1071 )
1072 .mount(&server)
1073 .await;
1074
1075 let client = authed_client(&server);
1076 let result = client.status().await;
1077 assert!(result.is_err());
1078 }
1079
1080 // ── Server error messages preserved ──
1081
1082 #[tokio::test]
1083 async fn server_error_message_preserved() {
1084 let server = MockServer::start().await;
1085
1086 Mock::given(method("GET"))
1087 .and(path("/api/sync/status"))
1088 .respond_with(
1089 ResponseTemplate::new(422).set_body_string("Validation failed: missing field"),
1090 )
1091 .mount(&server)
1092 .await;
1093
1094 let client = authed_client(&server);
1095 let err = client.status().await.unwrap_err();
1096 match err {
1097 SyncKitError::Server { status, message } => {
1098 assert_eq!(status, 422);
1099 assert!(
1100 message.contains("Validation failed"),
1101 "Error message should be preserved: {message}"
1102 );
1103 }
1104 other => panic!("Expected Server error, got: {other:?}"),
1105 }
1106 }
1107
1108 // ── Concurrent operations ──
1109
1110 #[tokio::test]
1111 async fn concurrent_push_operations_no_data_corruption() {
1112 let server = MockServer::start().await;
1113
1114 Mock::given(method("POST"))
1115 .and(path("/api/sync/push"))
1116 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"cursor": 1})))
1117 .mount(&server)
1118 .await;
1119
1120 let client = Arc::new(authed_client(&server));
1121 let key = synckit_client::crypto::generate_master_key();
1122 client.set_master_key_raw(key);
1123
1124 let mut handles = Vec::new();
1125 for i in 0..8 {
1126 let c = Arc::clone(&client);
1127 handles.push(tokio::spawn(async move {
1128 let device_id = Uuid::new_v4();
1129 let entry = ChangeEntry {
1130 table: format!("table_{i}"),
1131 op: ChangeOp::Insert,
1132 row_id: format!("row_{i}"),
1133 timestamp: Utc::now(),
1134 data: Some(json!({"index": i})),
1135 };
1136 c.push(device_id, vec![entry]).await
1137 }));
1138 }
1139
1140 for h in handles {
1141 let result = h.await.unwrap();
1142 assert!(result.is_ok(), "Concurrent push should succeed: {result:?}");
1143 }
1144 }
1145
1146 #[tokio::test]
1147 async fn concurrent_push_and_pull_interleaved() {
1148 let server = MockServer::start().await;
1149 let device_id = Uuid::new_v4();
1150
1151 Mock::given(method("POST"))
1152 .and(path("/api/sync/push"))
1153 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"cursor": 10})))
1154 .mount(&server)
1155 .await;
1156
1157 Mock::given(method("POST"))
1158 .and(path("/api/sync/pull"))
1159 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1160 "changes": [],
1161 "cursor": 10,
1162 "has_more": false,
1163 })))
1164 .mount(&server)
1165 .await;
1166
1167 let client = Arc::new(authed_client(&server));
1168 let key = synckit_client::crypto::generate_master_key();
1169 client.set_master_key_raw(key);
1170
1171 let mut handles = Vec::new();
1172 for i in 0..4 {
1173 let c = Arc::clone(&client);
1174 let did = device_id;
1175 handles.push(tokio::spawn(async move {
1176 // Alternate push and pull
1177 if i % 2 == 0 {
1178 c.push(did, vec![]).await.map(|_| ())
1179 } else {
1180 c.pull(did, 0).await.map(|_| ())
1181 }
1182 }));
1183 }
1184
1185 for h in handles {
1186 let result = h.await.unwrap();
1187 assert!(result.is_ok(), "Interleaved push/pull should succeed: {result:?}");
1188 }
1189 }
1190
1191 // ── Large payload handling ──
1192
1193 #[tokio::test]
1194 async fn push_many_changes_succeeds() {
1195 let server = MockServer::start().await;
1196
1197 Mock::given(method("POST"))
1198 .and(path("/api/sync/push"))
1199 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"cursor": 1000})))
1200 .mount(&server)
1201 .await;
1202
1203 let client = authed_client(&server);
1204 let key = synckit_client::crypto::generate_master_key();
1205 client.set_master_key_raw(key);
1206
1207 // Create 1000+ change entries
1208 let changes: Vec<ChangeEntry> = (0..1100)
1209 .map(|i| ChangeEntry {
1210 table: "bulk_table".into(),
1211 op: ChangeOp::Insert,
1212 row_id: format!("row-{i}"),
1213 timestamp: Utc::now(),
1214 data: Some(json!({"index": i, "value": format!("data-{i}")})),
1215 })
1216 .collect();
1217
1218 let cursor = client.push(Uuid::new_v4(), changes).await.unwrap();
1219 assert_eq!(cursor, 1000);
1220 }
1221
1222 // ── Session expiry handling ──
1223
1224 #[tokio::test]
1225 async fn expired_token_detected_before_push() {
1226 let server = MockServer::start().await;
1227 let client = client_for(&server);
1228 let (user_id, app_id) = test_ids();
1229
1230 let expired = fake_jwt(Utc::now().timestamp() - 100);
1231 client.restore_session(&expired, user_id, app_id);
1232 client
1233 .set_master_key_raw(synckit_client::crypto::generate_master_key());
1234
1235 let err = client.push(Uuid::new_v4(), vec![]).await.unwrap_err();
1236 assert!(
1237 matches!(err, SyncKitError::TokenExpired),
1238 "Expired token should be detected pre-flight, got: {err:?}"
1239 );
1240 }
1241
1242 #[tokio::test]
1243 async fn expired_token_detected_before_pull() {
1244 let server = MockServer::start().await;
1245 let client = client_for(&server);
1246 let (user_id, app_id) = test_ids();
1247
1248 let expired = fake_jwt(Utc::now().timestamp() - 100);
1249 client.restore_session(&expired, user_id, app_id);
1250 client
1251 .set_master_key_raw(synckit_client::crypto::generate_master_key());
1252
1253 let err = client.pull(Uuid::new_v4(), 0).await.unwrap_err();
1254 assert!(matches!(err, SyncKitError::TokenExpired));
1255 }
1256
1257 #[tokio::test]
1258 async fn expired_token_detected_before_register_device() {
1259 let server = MockServer::start().await;
1260 let client = client_for(&server);
1261 let (user_id, app_id) = test_ids();
1262
1263 let expired = fake_jwt(Utc::now().timestamp() - 100);
1264 client.restore_session(&expired, user_id, app_id);
1265
1266 let err = client.register_device("Test", "test").await.unwrap_err();
1267 assert!(matches!(err, SyncKitError::TokenExpired));
1268 }
1269
1270 #[tokio::test]
1271 async fn expired_token_detected_before_list_devices() {
1272 let server = MockServer::start().await;
1273 let client = client_for(&server);
1274 let (user_id, app_id) = test_ids();
1275
1276 let expired = fake_jwt(Utc::now().timestamp() - 100);
1277 client.restore_session(&expired, user_id, app_id);
1278
1279 let err = client.list_devices().await.unwrap_err();
1280 assert!(matches!(err, SyncKitError::TokenExpired));
1281 }
1282
1283 // ── Blob edge cases ──
1284
1285 #[tokio::test]
1286 async fn blob_upload_zero_byte_data() {
1287 let server = MockServer::start().await;
1288
1289 let upload_path = "/s3/zero-byte";
1290 Mock::given(method("PUT"))
1291 .and(path(upload_path))
1292 .respond_with(ResponseTemplate::new(200))
1293 .mount(&server)
1294 .await;
1295
1296 let client = authed_client(&server);
1297 let key = synckit_client::crypto::generate_master_key();
1298 client.set_master_key_raw(key);
1299
1300 let presigned = format!("{}{}", server.uri(), upload_path);
1301 let result = client.blob_upload(&presigned, vec![]).await;
1302 assert!(result.is_ok(), "Zero-byte blob upload should succeed");
1303
1304 // Verify the uploaded data has encryption overhead only
1305 let requests = server.received_requests().await.unwrap();
1306 let req = requests
1307 .iter()
1308 .find(|r| r.url.path() == upload_path)
1309 .unwrap();
1310 assert_eq!(
1311 req.body.len(),
1312 synckit_client::crypto::ENCRYPTION_OVERHEAD,
1313 "Empty plaintext should produce exactly overhead bytes"
1314 );
1315 }
1316
1317 #[tokio::test]
1318 async fn blob_upload_download_roundtrip() {
1319 let server = MockServer::start().await;
1320
1321 let client = authed_client(&server);
1322 let key = synckit_client::crypto::generate_master_key();
1323 client.set_master_key_raw(key);
1324
1325 let plaintext = b"roundtrip blob data with special bytes \x00\xFF\x01";
1326
1327 // Upload
1328 let upload_path = "/s3/roundtrip-upload";
1329 Mock::given(method("PUT"))
1330 .and(path(upload_path))
1331 .respond_with(ResponseTemplate::new(200))
1332 .mount(&server)
1333 .await;
1334
1335 client
1336 .blob_upload(
1337 &format!("{}{}", server.uri(), upload_path),
1338 plaintext.to_vec(),
1339 )
1340 .await
1341 .unwrap();
1342
1343 // Capture what was uploaded
1344 let requests = server.received_requests().await.unwrap();
1345 let uploaded_body = &requests
1346 .iter()
1347 .find(|r| r.url.path() == upload_path)
1348 .unwrap()
1349 .body;
1350
1351 // Serve that exact encrypted data back for download
1352 let download_path = "/s3/roundtrip-download";
1353 Mock::given(method("GET"))
1354 .and(path(download_path))
1355 .respond_with(ResponseTemplate::new(200).set_body_bytes(uploaded_body.clone()))
1356 .mount(&server)
1357 .await;
1358
1359 let downloaded = client
1360 .blob_download(&format!("{}{}", server.uri(), download_path))
1361 .await
1362 .unwrap();
1363
1364 assert_eq!(downloaded, plaintext, "Blob roundtrip must preserve data");
1365 }
1366
1367 // ── Push without master key ──
1368
1369 #[tokio::test]
1370 async fn push_with_data_fails_without_master_key() {
1371 let server = MockServer::start().await;
1372 let client = authed_client(&server);
1373 // No master key set
1374
1375 let changes = vec![ChangeEntry {
1376 table: "tasks".into(),
1377 op: ChangeOp::Insert,
1378 row_id: "r1".into(),
1379 timestamp: Utc::now(),
1380 data: Some(json!({"title": "test"})),
1381 }];
1382
1383 let err = client.push(Uuid::new_v4(), changes).await.unwrap_err();
1384 assert!(
1385 matches!(err, SyncKitError::NoMasterKey),
1386 "Push with data should fail without master key: {err:?}"
1387 );
1388 }
1389
1390 #[tokio::test]
1391 async fn push_delete_without_master_key_succeeds() {
1392 let server = MockServer::start().await;
1393
1394 Mock::given(method("POST"))
1395 .and(path("/api/sync/push"))
1396 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"cursor": 1})))
1397 .mount(&server)
1398 .await;
1399
1400 let client = authed_client(&server);
1401 // No master key -- but Delete entries have no data to encrypt
1402
1403 let changes = vec![ChangeEntry {
1404 table: "tasks".into(),
1405 op: ChangeOp::Delete,
1406 row_id: "r1".into(),
1407 timestamp: Utc::now(),
1408 data: None,
1409 }];
1410
1411 let cursor = client.push(Uuid::new_v4(), changes).await.unwrap();
1412 assert_eq!(cursor, 1);
1413 }
1414
1415 // ── Blob operations require auth ──
1416
1417 #[tokio::test]
1418 async fn blob_upload_url_without_auth_fails() {
1419 let server = MockServer::start().await;
1420 let client = client_for(&server);
1421
1422 let result = client.blob_upload_url("hash", 100).await;
1423 match result {
1424 Err(SyncKitError::NotAuthenticated) => {} // expected
1425 Err(other) => panic!("Expected NotAuthenticated, got: {other:?}"),
1426 Ok(_) => panic!("Expected NotAuthenticated error, got Ok"),
1427 }
1428 }
1429
1430 #[tokio::test]
1431 async fn blob_confirm_without_auth_fails() {
1432 let server = MockServer::start().await;
1433 let client = client_for(&server);
1434
1435 let err = client.blob_confirm("hash", 100).await.unwrap_err();
1436 assert!(matches!(err, SyncKitError::NotAuthenticated));
1437 }
1438
1439 #[tokio::test]
1440 async fn blob_download_url_without_auth_fails() {
1441 let server = MockServer::start().await;
1442 let client = client_for(&server);
1443
1444 let err = client.blob_download_url("hash").await.unwrap_err();
1445 assert!(matches!(err, SyncKitError::NotAuthenticated));
1446 }
1447
1448 // ── Double-push same data ──
1449
1450 #[tokio::test]
1451 async fn double_push_same_data_both_succeed() {
1452 let server = MockServer::start().await;
1453
1454 // Server returns incrementing cursors
1455 Mock::given(method("POST"))
1456 .and(path("/api/sync/push"))
1457 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"cursor": 1})))
1458 .up_to_n_times(1)
1459 .mount(&server)
1460 .await;
1461
1462 Mock::given(method("POST"))
1463 .and(path("/api/sync/push"))
1464 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"cursor": 2})))
1465 .mount(&server)
1466 .await;
1467
1468 let client = authed_client(&server);
1469 let key = synckit_client::crypto::generate_master_key();
1470 client.set_master_key_raw(key);
1471
1472 let entry = ChangeEntry {
1473 table: "tasks".into(),
1474 op: ChangeOp::Insert,
1475 row_id: "same-row".into(),
1476 timestamp: Utc::now(),
1477 data: Some(json!({"title": "duplicate push test"})),
1478 };
1479
1480 let cursor1 = client
1481 .push(Uuid::new_v4(), vec![entry.clone()])
1482 .await
1483 .unwrap();
1484 let cursor2 = client.push(Uuid::new_v4(), vec![entry]).await.unwrap();
1485
1486 assert_eq!(cursor1, 1);
1487 assert_eq!(cursor2, 2);
1488 }
1489
1490 // ── Encryption setup ──
1491
1492 #[tokio::test]
1493 async fn setup_encryption_new_stores_key_and_uploads_envelope() {
1494 let server = MockServer::start().await;
1495
1496 Mock::given(method("PUT"))
1497 .and(path("/api/sync/keys"))
1498 .respond_with(ResponseTemplate::new(200))
1499 .expect(1)
1500 .mount(&server)
1501 .await;
1502
1503 let client = authed_client(&server);
1504 assert!(!client.has_master_key());
1505
1506 client.setup_encryption_new("test-password").await.unwrap();
1507
1508 // Master key should now be in memory
1509 assert!(client.has_master_key());
1510
1511 // Verify the PUT body contains a valid envelope unwrappable with the same password
1512 let requests = server.received_requests().await.unwrap();
1513 let put_req = requests
1514 .iter()
1515 .find(|r| r.method.as_str() == "PUT" && r.url.path() == "/api/sync/keys")
1516 .unwrap();
1517 let body: serde_json::Value = serde_json::from_slice(&put_req.body).unwrap();
1518 let envelope_str = body["encrypted_key"].as_str().unwrap();
1519 let recovered =
1520 synckit_client::crypto::unwrap_master_key(envelope_str, "test-password").unwrap();
1521 assert_eq!(recovered.len(), 32);
1522 }
1523
1524 #[tokio::test]
1525 async fn setup_encryption_new_without_auth_fails() {
1526 let server = MockServer::start().await;
1527 let client = client_for(&server);
1528
1529 let err = client
1530 .setup_encryption_new("password")
1531 .await
1532 .unwrap_err();
1533 assert!(matches!(err, SyncKitError::NotAuthenticated));
1534 }
1535
1536 #[tokio::test]
1537 async fn setup_encryption_new_retries_on_server_error() {
1538 let server = MockServer::start().await;
1539
1540 Mock::given(method("PUT"))
1541 .and(path("/api/sync/keys"))
1542 .respond_with(ResponseTemplate::new(500).set_body_string("Internal Server Error"))
1543 .up_to_n_times(1)
1544 .mount(&server)
1545 .await;
1546
1547 Mock::given(method("PUT"))
1548 .and(path("/api/sync/keys"))
1549 .respond_with(ResponseTemplate::new(200))
1550 .mount(&server)
1551 .await;
1552
1553 let client = authed_client(&server);
1554 let result = client.setup_encryption_new("password").await;
1555 assert!(result.is_ok(), "Should succeed after retry: {result:?}");
1556 assert!(client.has_master_key());
1557 }
1558
1559 #[tokio::test]
1560 async fn setup_encryption_existing_recovers_key() {
1561 let server = MockServer::start().await;
1562
1563 let master_key = synckit_client::crypto::generate_master_key();
1564 let envelope =
1565 synckit_client::crypto::wrap_master_key(&master_key, "my-password").unwrap();
1566
1567 Mock::given(method("GET"))
1568 .and(path("/api/sync/keys"))
1569 .respond_with(
1570 ResponseTemplate::new(200)
1571 .set_body_json(json!({"encrypted_key": envelope})),
1572 )
1573 .mount(&server)
1574 .await;
1575
1576 let client = authed_client(&server);
1577 assert!(!client.has_master_key());
1578
1579 client
1580 .setup_encryption_existing("my-password")
1581 .await
1582 .unwrap();
1583
1584 assert!(client.has_master_key());
1585 }
1586
1587 #[tokio::test]
1588 async fn setup_encryption_existing_wrong_password_fails() {
1589 let server = MockServer::start().await;
1590
1591 let master_key = synckit_client::crypto::generate_master_key();
1592 let envelope =
1593 synckit_client::crypto::wrap_master_key(&master_key, "correct-password").unwrap();
1594
1595 Mock::given(method("GET"))
1596 .and(path("/api/sync/keys"))
1597 .respond_with(
1598 ResponseTemplate::new(200)
1599 .set_body_json(json!({"encrypted_key": envelope})),
1600 )
1601 .mount(&server)
1602 .await;
1603
1604 let client = authed_client(&server);
1605 let err = client
1606 .setup_encryption_existing("wrong-password")
1607 .await
1608 .unwrap_err();
1609 assert!(
1610 matches!(err, SyncKitError::DecryptionFailed),
1611 "Wrong password should produce DecryptionFailed: {err:?}"
1612 );
1613 assert!(!client.has_master_key());
1614 }
1615
1616 #[tokio::test]
1617 async fn setup_encryption_existing_without_auth_fails() {
1618 let server = MockServer::start().await;
1619 let client = client_for(&server);
1620
1621 let err = client
1622 .setup_encryption_existing("password")
1623 .await
1624 .unwrap_err();
1625 assert!(matches!(err, SyncKitError::NotAuthenticated));
1626 }
1627
1628 #[tokio::test]
1629 async fn setup_encryption_existing_retries_on_server_error() {
1630 let server = MockServer::start().await;
1631
1632 let master_key = synckit_client::crypto::generate_master_key();
1633 let envelope =
1634 synckit_client::crypto::wrap_master_key(&master_key, "password").unwrap();
1635
1636 Mock::given(method("GET"))
1637 .and(path("/api/sync/keys"))
1638 .respond_with(ResponseTemplate::new(502).set_body_string("Bad Gateway"))
1639 .up_to_n_times(1)
1640 .mount(&server)
1641 .await;
1642
1643 Mock::given(method("GET"))
1644 .and(path("/api/sync/keys"))
1645 .respond_with(
1646 ResponseTemplate::new(200)
1647 .set_body_json(json!({"encrypted_key": envelope})),
1648 )
1649 .mount(&server)
1650 .await;
1651
1652 let client = authed_client(&server);
1653 let result = client.setup_encryption_existing("password").await;
1654 assert!(result.is_ok(), "Should succeed after retry: {result:?}");
1655 assert!(client.has_master_key());
1656 }
1657
1658 #[tokio::test]
1659 async fn setup_encryption_existing_no_server_key_returns_error() {
1660 let server = MockServer::start().await;
1661
1662 Mock::given(method("GET"))
1663 .and(path("/api/sync/keys"))
1664 .respond_with(ResponseTemplate::new(404).set_body_string("Not Found"))
1665 .mount(&server)
1666 .await;
1667
1668 let client = authed_client(&server);
1669 let err = client
1670 .setup_encryption_existing("password")
1671 .await
1672 .unwrap_err();
1673 assert!(
1674 matches!(err, SyncKitError::Server { status: 404, .. }),
1675 "Missing server key should produce 404 error: {err:?}"
1676 );
1677 }
1678
1679 /// Two-device roundtrip: device 1 generates key via setup_encryption_new,
1680 /// device 2 recovers it via setup_encryption_existing. Data encrypted by
1681 /// device 1 must be decryptable by device 2.
1682 #[tokio::test]
1683 async fn encryption_setup_cross_device_roundtrip() {
1684 let server = MockServer::start().await;
1685
1686 // Device 1: setup_encryption_new
1687 Mock::given(method("PUT"))
1688 .and(path("/api/sync/keys"))
1689 .respond_with(ResponseTemplate::new(200))
1690 .mount(&server)
1691 .await;
1692
1693 Mock::given(method("POST"))
1694 .and(path("/api/sync/push"))
1695 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"cursor": 1})))
1696 .mount(&server)
1697 .await;
1698
1699 let client1 = authed_client(&server);
1700 client1
1701 .setup_encryption_new("shared-password")
1702 .await
1703 .unwrap();
1704
1705 // Push encrypted data from device 1
1706 let device_id = Uuid::new_v4();
1707 let original_data = json!({"title": "cross-device test", "secret": true});
1708 client1
1709 .push(
1710 device_id,
1711 vec![ChangeEntry {
1712 table: "tasks".into(),
1713 op: ChangeOp::Insert,
1714 row_id: "cross-r1".into(),
1715 timestamp: Utc::now(),
1716 data: Some(original_data.clone()),
1717 }],
1718 )
1719 .await
1720 .unwrap();
1721
1722 // Capture the envelope and encrypted data
1723 let requests = server.received_requests().await.unwrap();
1724 let put_req = requests
1725 .iter()
1726 .find(|r| r.method.as_str() == "PUT" && r.url.path() == "/api/sync/keys")
1727 .unwrap();
1728 let put_body: serde_json::Value = serde_json::from_slice(&put_req.body).unwrap();
1729 let envelope = put_body["encrypted_key"].as_str().unwrap().to_string();
1730
1731 let push_req = requests
1732 .iter()
1733 .find(|r| r.url.path() == "/api/sync/push")
1734 .unwrap();
1735 let push_body: serde_json::Value = serde_json::from_slice(&push_req.body).unwrap();
1736 let encrypted_data = push_body["changes"][0]["data"].clone();
1737
1738 // Device 2: setup_encryption_existing with same password
1739 server.reset().await;
1740
1741 Mock::given(method("GET"))
1742 .and(path("/api/sync/keys"))
1743 .respond_with(
1744 ResponseTemplate::new(200)
1745 .set_body_json(json!({"encrypted_key": envelope})),
1746 )
1747 .mount(&server)
1748 .await;
1749
1750 Mock::given(method("POST"))
1751 .and(path("/api/sync/pull"))
1752 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1753 "changes": [{
1754 "seq": 1,
1755 "device_id": device_id,
1756 "table": "tasks",
1757 "op": "INSERT",
1758 "row_id": "cross-r1",
1759 "timestamp": "2025-06-01T12:00:00Z",
1760 "data": encrypted_data,
1761 }],
1762 "cursor": 1,
1763 "has_more": false,
1764 })))
1765 .mount(&server)
1766 .await;
1767
1768 let client2 = authed_client(&server);
1769 client2
1770 .setup_encryption_existing("shared-password")
1771 .await
1772 .unwrap();
1773
1774 // Pull and decrypt with device 2's recovered key
1775 let (changes, _, _) = client2.pull(device_id, 0).await.unwrap();
1776 assert_eq!(changes.len(), 1);
1777 assert_eq!(
1778 changes[0].data.as_ref().unwrap(),
1779 &original_data,
1780 "Data encrypted by device 1 must be decryptable by device 2"
1781 );
1782 }
1783
1784 // ── Config persistence (SyncKitConfig serialization) ──
1785
1786 #[tokio::test]
1787 async fn config_serialization_roundtrip() {
1788 let config = SyncKitConfig {
1789 server_url: "https://makenot.work".to_string(),
1790 api_key: "ak_test_12345".to_string(),
1791 };
1792
1793 // SyncKitConfig derives Clone and Debug; verify round trip through Debug
1794 let debug = format!("{:?}", config);
1795 assert!(debug.contains("makenot.work"));
1796 assert!(debug.contains("ak_test_12345"));
1797
1798 let cloned = config.clone();
1799 assert_eq!(cloned.server_url, config.server_url);
1800 assert_eq!(cloned.api_key, config.api_key);
1801 }
1802
1803 // ── API error mapping ──
1804
1805 #[tokio::test]
1806 async fn all_4xx_error_codes_mapped() {
1807 let server = MockServer::start().await;
1808
1809 for status_code in [400, 401, 403, 404, 409, 422] {
1810 // Reset mocks
1811 server.reset().await;
1812
1813 Mock::given(method("GET"))
1814 .and(path("/api/sync/status"))
1815 .respond_with(
1816 ResponseTemplate::new(status_code)
1817 .set_body_string(format!("Error {status_code}")),
1818 )
1819 .mount(&server)
1820 .await;
1821
1822 let client = authed_client(&server);
1823 let err = client.status().await.unwrap_err();
1824
1825 match err {
1826 SyncKitError::Server { status, message } => {
1827 assert_eq!(status, status_code);
1828 assert!(message.contains(&format!("Error {status_code}")));
1829 }
1830 other => panic!(
1831 "Status {status_code} should map to Server error, got: {other:?}"
1832 ),
1833 }
1834 }
1835 }
1836
1837 #[tokio::test]
1838 async fn all_5xx_error_codes_retried() {
1839 for status_code in [500, 502, 503, 504] {
1840 let server = MockServer::start().await;
1841
1842 // First request fails with 5xx
1843 Mock::given(method("GET"))
1844 .and(path("/api/sync/status"))
1845 .respond_with(
1846 ResponseTemplate::new(status_code)
1847 .set_body_string("Server Error"),
1848 )
1849 .up_to_n_times(1)
1850 .mount(&server)
1851 .await;
1852
1853 // Second request succeeds
1854 Mock::given(method("GET"))
1855 .and(path("/api/sync/status"))
1856 .respond_with(
1857 ResponseTemplate::new(200)
1858 .set_body_json(json!({"total_changes": 0, "latest_cursor": null})),
1859 )
1860 .mount(&server)
1861 .await;
1862
1863 let client = authed_client(&server);
1864 let result = client.status().await;
1865 assert!(
1866 result.is_ok(),
1867 "Status {status_code} should be retried and succeed: {result:?}"
1868 );
1869 }
1870 }
1871
1872 // ── Blob download with wrong key ──
1873
1874 #[tokio::test]
1875 async fn blob_download_with_wrong_key_fails() {
1876 let server = MockServer::start().await;
1877
1878 let key1 = synckit_client::crypto::generate_master_key();
1879 let key2 = synckit_client::crypto::generate_master_key();
1880
1881 // Encrypt with key1
1882 let plaintext = b"encrypted with key1";
1883 let encrypted = synckit_client::crypto::encrypt_bytes(plaintext, &key1).unwrap();
1884
1885 let download_path = "/s3/wrong-key";
1886 Mock::given(method("GET"))
1887 .and(path(download_path))
1888 .respond_with(ResponseTemplate::new(200).set_body_bytes(encrypted))
1889 .mount(&server)
1890 .await;
1891
1892 // Client has key2 (wrong key)
1893 let client = authed_client(&server);
1894 client.set_master_key_raw(key2);
1895
1896 let result = client
1897 .blob_download(&format!("{}{}", server.uri(), download_path))
1898 .await;
1899
1900 assert!(
1901 result.is_err(),
1902 "Download with wrong key should fail: {result:?}"
1903 );
1904 assert!(matches!(
1905 result.unwrap_err(),
1906 SyncKitError::DecryptionFailed
1907 ));
1908 }
1909
1910 // ── Pull without auth ──
1911
1912 #[tokio::test]
1913 async fn pull_without_auth_returns_not_authenticated() {
1914 let server = MockServer::start().await;
1915 let client = client_for(&server);
1916
1917 let err = client.pull(Uuid::new_v4(), 0).await.unwrap_err();
1918 assert!(matches!(err, SyncKitError::NotAuthenticated));
1919 }
1920
1921 // ── List devices without auth ──
1922
1923 #[tokio::test]
1924 async fn list_devices_without_auth_returns_not_authenticated() {
1925 let server = MockServer::start().await;
1926 let client = client_for(&server);
1927
1928 let err = client.list_devices().await.unwrap_err();
1929 assert!(matches!(err, SyncKitError::NotAuthenticated));
1930 }
1931
1932 // ── has_server_key without auth ──
1933
1934 #[tokio::test]
1935 async fn has_server_key_without_auth_returns_not_authenticated() {
1936 let server = MockServer::start().await;
1937 let client = client_for(&server);
1938
1939 let err = client.has_server_key().await.unwrap_err();
1940 assert!(matches!(err, SyncKitError::NotAuthenticated));
1941 }
1942
1943 // ── Encryption roundtrip through push/pull (end-to-end) ──
1944
1945 #[tokio::test]
1946 async fn end_to_end_push_pull_encryption_roundtrip() {
1947 let server = MockServer::start().await;
1948
1949 let client = authed_client(&server);
1950 let key = synckit_client::crypto::generate_master_key();
1951 client.set_master_key_raw(key);
1952
1953 let device_id = Uuid::new_v4();
1954 let original_data = json!({
1955 "title": "End-to-end test",
1956 "tags": ["e2e", "encryption"],
1957 "nested": {"key": "value"},
1958 "count": 42
1959 });
1960
1961 // Capture push request to feed back through pull
1962 Mock::given(method("POST"))
1963 .and(path("/api/sync/push"))
1964 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"cursor": 1})))
1965 .mount(&server)
1966 .await;
1967
1968 client
1969 .push(
1970 device_id,
1971 vec![ChangeEntry {
1972 table: "tasks".into(),
1973 op: ChangeOp::Insert,
1974 row_id: "e2e-row".into(),
1975 timestamp: Utc::now(),
1976 data: Some(original_data.clone()),
1977 }],
1978 )
1979 .await
1980 .unwrap();
1981
1982 // Extract the encrypted data that was sent to the server
1983 let requests = server.received_requests().await.unwrap();
1984 let push_body: serde_json::Value = serde_json::from_slice(
1985 &requests
1986 .iter()
1987 .find(|r| r.url.path() == "/api/sync/push")
1988 .unwrap()
1989 .body,
1990 )
1991 .unwrap();
1992
1993 let wire_entry = &push_body["changes"][0];
1994
1995 // Feed encrypted data back through pull
1996 Mock::given(method("POST"))
1997 .and(path("/api/sync/pull"))
1998 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1999 "changes": [{
2000 "seq": 1,
2001 "device_id": device_id,
2002 "table": wire_entry["table"],
2003 "op": wire_entry["op"],
2004 "row_id": wire_entry["row_id"],
2005 "timestamp": wire_entry["timestamp"],
2006 "data": wire_entry["data"],
2007 }],
2008 "cursor": 1,
2009 "has_more": false,
2010 })))
2011 .mount(&server)
2012 .await;
2013
2014 let (changes, _, _) = client.pull(device_id, 0).await.unwrap();
2015 assert_eq!(changes.len(), 1);
2016 assert_eq!(
2017 changes[0].data.as_ref().unwrap(),
2018 &original_data,
2019 "Data must survive push encryption + pull decryption"
2020 );
2021 }
2022
2023 // ── Retry count verification ──
2024
2025 #[tokio::test]
2026 async fn retry_exhausts_all_attempts_on_persistent_503() {
2027 let server = MockServer::start().await;
2028
2029 Mock::given(method("GET"))
2030 .and(path("/api/sync/status"))
2031 .respond_with(ResponseTemplate::new(503).set_body_string("Service Unavailable"))
2032 .mount(&server)
2033 .await;
2034
2035 let client = authed_client(&server);
2036 let err = client.status().await.unwrap_err();
2037 assert!(matches!(err, SyncKitError::Server { status: 503, .. }));
2038
2039 // Should have made exactly 4 requests (1 initial + 3 retries)
2040 let requests = server.received_requests().await.unwrap();
2041 let status_requests: Vec<_> = requests
2042 .iter()
2043 .filter(|r| r.url.path() == "/api/sync/status")
2044 .collect();
2045 assert_eq!(
2046 status_requests.len(),
2047 4,
2048 "Expected 4 total requests (1 + MAX_RETRIES=3), got {}",
2049 status_requests.len()
2050 );
2051 }
2052
2053 #[tokio::test]
2054 async fn retry_not_attempted_on_404() {
2055 let server = MockServer::start().await;
2056
2057 Mock::given(method("GET"))
2058 .and(path("/api/sync/status"))
2059 .respond_with(ResponseTemplate::new(404).set_body_string("Not Found"))
2060 .mount(&server)
2061 .await;
2062
2063 let client = authed_client(&server);
2064 let err = client.status().await.unwrap_err();
2065 assert!(matches!(err, SyncKitError::Server { status: 404, .. }));
2066
2067 let requests = server.received_requests().await.unwrap();
2068 let count = requests.iter().filter(|r| r.url.path() == "/api/sync/status").count();
2069 assert_eq!(count, 1, "404 should not be retried");
2070 }
2071
2072 #[tokio::test]
2073 async fn retry_succeeds_on_third_attempt() {
2074 let server = MockServer::start().await;
2075
2076 Mock::given(method("GET"))
2077 .and(path("/api/sync/status"))
2078 .respond_with(ResponseTemplate::new(503).set_body_string("Service Unavailable"))
2079 .up_to_n_times(2)
2080 .mount(&server)
2081 .await;
2082
2083 Mock::given(method("GET"))
2084 .and(path("/api/sync/status"))
2085 .respond_with(
2086 ResponseTemplate::new(200)
2087 .set_body_json(json!({"total_changes": 7, "latest_cursor": 3})),
2088 )
2089 .mount(&server)
2090 .await;
2091
2092 let client = authed_client(&server);
2093 let status = client.status().await.unwrap();
2094 assert_eq!(status.total_changes, 7);
2095
2096 let requests = server.received_requests().await.unwrap();
2097 let count = requests.iter().filter(|r| r.url.path() == "/api/sync/status").count();
2098 assert_eq!(count, 3, "Should succeed on 3rd attempt");
2099 }
2100
2101 // ── Malformed / unexpected responses ──
2102
2103 #[tokio::test]
2104 async fn authenticate_html_response_returns_error() {
2105 let server = MockServer::start().await;
2106
2107 Mock::given(method("POST"))
2108 .and(path("/api/sync/auth"))
2109 .respond_with(
2110 ResponseTemplate::new(200)
2111 .insert_header("content-type", "text/html")
2112 .set_body_string("<html><body>Not JSON</body></html>"),
2113 )
2114 .mount(&server)
2115 .await;
2116
2117 let client = client_for(&server);
2118 let err = client.authenticate("user@test.com", "pass").await.unwrap_err();
2119 // reqwest .json() fails when body isn't valid JSON
2120 assert!(
2121 matches!(err, SyncKitError::Http(_) | SyncKitError::Json(_)),
2122 "HTML response should produce Http or Json error, got: {err:?}"
2123 );
2124 }
2125
2126 #[tokio::test]
2127 async fn push_empty_response_body_returns_error() {
2128 let server = MockServer::start().await;
2129
2130 Mock::given(method("POST"))
2131 .and(path("/api/sync/push"))
2132 .respond_with(ResponseTemplate::new(200).set_body_string(""))
2133 .mount(&server)
2134 .await;
2135
2136 let client = authed_client(&server);
2137 let key = synckit_client::crypto::generate_master_key();
2138 client.set_master_key_raw(key);
2139
2140 let err = client.push(Uuid::new_v4(), vec![]).await.unwrap_err();
2141 assert!(
2142 matches!(err, SyncKitError::Http(_) | SyncKitError::Json(_)),
2143 "Empty body should produce parse error, got: {err:?}"
2144 );
2145 }
2146
2147 #[tokio::test]
2148 async fn pull_response_missing_has_more_returns_error() {
2149 let server = MockServer::start().await;
2150
2151 Mock::given(method("POST"))
2152 .and(path("/api/sync/pull"))
2153 .respond_with(
2154 ResponseTemplate::new(200).set_body_json(json!({
2155 "changes": [],
2156 "cursor": 0
2157 // missing "has_more"
2158 })),
2159 )
2160 .mount(&server)
2161 .await;
2162
2163 let client = authed_client(&server);
2164 let key = synckit_client::crypto::generate_master_key();
2165 client.set_master_key_raw(key);
2166
2167 let err = client.pull(Uuid::new_v4(), 0).await.unwrap_err();
2168 assert!(
2169 matches!(err, SyncKitError::Http(_) | SyncKitError::Json(_)),
2170 "Missing has_more should produce parse error, got: {err:?}"
2171 );
2172 }
2173
2174 #[tokio::test]
2175 async fn status_response_cursor_wrong_type_returns_error() {
2176 let server = MockServer::start().await;
2177
2178 Mock::given(method("GET"))
2179 .and(path("/api/sync/status"))
2180 .respond_with(
2181 ResponseTemplate::new(200).set_body_json(json!({
2182 "total_changes": 10,
2183 "latest_cursor": "not-a-number"
2184 })),
2185 )
2186 .mount(&server)
2187 .await;
2188
2189 let client = authed_client(&server);
2190 let err = client.status().await.unwrap_err();
2191 assert!(
2192 matches!(err, SyncKitError::Http(_) | SyncKitError::Json(_)),
2193 "Wrong type for cursor should produce parse error, got: {err:?}"
2194 );
2195 }
2196
2197 #[tokio::test]
2198 async fn authenticate_response_missing_app_id_returns_error() {
2199 let server = MockServer::start().await;
2200
2201 Mock::given(method("POST"))
2202 .and(path("/api/sync/auth"))
2203 .respond_with(
2204 ResponseTemplate::new(200).set_body_json(json!({
2205 "token": fresh_token(),
2206 "user_id": "550e8400-e29b-41d4-a716-446655440000"
2207 // missing "app_id"
2208 })),
2209 )
2210 .mount(&server)
2211 .await;
2212
2213 let client = client_for(&server);
2214 let err = client.authenticate("user@test.com", "pass").await.unwrap_err();
2215 assert!(
2216 matches!(err, SyncKitError::Http(_) | SyncKitError::Json(_)),
2217 "Missing app_id should produce parse error, got: {err:?}"
2218 );
2219 }
2220
2221 #[tokio::test]
2222 async fn register_device_extra_fields_ignored() {
2223 let server = MockServer::start().await;
2224
2225 let (user_id, app_id) = test_ids();
2226 let response_with_extras = json!({
2227 "id": Uuid::new_v4(),
2228 "app_id": app_id,
2229 "user_id": user_id,
2230 "device_name": "Test Device",
2231 "platform": "test",
2232 "last_seen_at": "2025-01-01T00:00:00Z",
2233 "created_at": "2025-01-01T00:00:00Z",
2234 "extra_field": "should be ignored",
2235 "unknown_number": 42
2236 });
2237
2238 Mock::given(method("POST"))
2239 .and(path("/api/sync/devices"))
2240 .respond_with(ResponseTemplate::new(200).set_body_json(response_with_extras))
2241 .mount(&server)
2242 .await;
2243
2244 let client = authed_client(&server);
2245 let device = client.register_device("Test", "test").await.unwrap();
2246 assert_eq!(device.device_name, "Test Device");
2247 }
2248
2249 #[tokio::test]
2250 async fn blob_upload_url_response_missing_already_exists_returns_error() {
2251 let server = MockServer::start().await;
2252
2253 Mock::given(method("POST"))
2254 .and(path("/api/sync/blobs/upload"))
2255 .respond_with(
2256 ResponseTemplate::new(200).set_body_json(json!({
2257 "upload_url": "https://s3.example.com/put"
2258 // missing "already_exists"
2259 })),
2260 )
2261 .mount(&server)
2262 .await;
2263
2264 let client = authed_client(&server);
2265 let result = client.blob_upload_url("hash", 100).await;
2266 match result {
2267 Err(err) => assert!(
2268 matches!(err, SyncKitError::Http(_) | SyncKitError::Json(_)),
2269 "Missing already_exists should produce error, got: {err:?}"
2270 ),
2271 Ok(_) => panic!("Expected error for missing already_exists field"),
2272 }
2273 }
2274
2275 #[tokio::test]
2276 async fn server_returns_413_request_entity_too_large() {
2277 let server = MockServer::start().await;
2278
2279 Mock::given(method("POST"))
2280 .and(path("/api/sync/push"))
2281 .respond_with(
2282 ResponseTemplate::new(413).set_body_string("Request entity too large"),
2283 )
2284 .mount(&server)
2285 .await;
2286
2287 let client = authed_client(&server);
2288 let key = synckit_client::crypto::generate_master_key();
2289 client.set_master_key_raw(key);
2290
2291 let err = client.push(Uuid::new_v4(), vec![]).await.unwrap_err();
2292 match err {
2293 SyncKitError::Server { status, message } => {
2294 assert_eq!(status, 413);
2295 assert!(message.contains("too large"));
2296 }
2297 other => panic!("Expected Server error, got: {other:?}"),
2298 }
2299 }
2300
2301 // ── Session edge cases ──
2302
2303 #[tokio::test]
2304 async fn double_authenticate_overwrites_session() {
2305 let server = MockServer::start().await;
2306
2307 let (user_id, app_id) = test_ids();
2308 let second_user_id = Uuid::new_v4();
2309
2310 // First auth response
2311 Mock::given(method("POST"))
2312 .and(path("/api/sync/auth"))
2313 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2314 "token": fresh_token(),
2315 "user_id": user_id,
2316 "app_id": app_id,
2317 })))
2318 .up_to_n_times(1)
2319 .mount(&server)
2320 .await;
2321
2322 // Second auth response with different user_id
2323 Mock::given(method("POST"))
2324 .and(path("/api/sync/auth"))
2325 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2326 "token": fresh_token(),
2327 "user_id": second_user_id,
2328 "app_id": app_id,
2329 })))
2330 .mount(&server)
2331 .await;
2332
2333 let client = client_for(&server);
2334 let (uid1, _) = client.authenticate("user1@test.com", "pass1").await.unwrap();
2335 assert_eq!(uid1, user_id);
2336
2337 let (uid2, _) = client.authenticate("user2@test.com", "pass2").await.unwrap();
2338 assert_eq!(uid2, second_user_id);
2339
2340 // Session should now reflect the second auth
2341 let info = client.session_info().unwrap();
2342 assert_eq!(info.user_id, second_user_id);
2343 }
2344
2345 #[tokio::test]
2346 async fn clear_session_then_authenticate_succeeds() {
2347 let server = MockServer::start().await;
2348
2349 Mock::given(method("POST"))
2350 .and(path("/api/sync/auth"))
2351 .respond_with(ResponseTemplate::new(200).set_body_json(auth_response_json()))
2352 .mount(&server)
2353 .await;
2354
2355 let client = authed_client(&server);
2356 assert!(client.session_info().is_some());
2357
2358 client.clear_session();
2359 assert!(client.session_info().is_none());
2360
2361 // Re-authenticate should work
2362 let result = client.authenticate("user@test.com", "pass").await;
2363 assert!(result.is_ok(), "Should be able to re-authenticate after clear: {result:?}");
2364 assert!(client.session_info().is_some());
2365 }
2366
2367 #[tokio::test]
2368 async fn restore_session_with_expired_token_then_push_returns_token_expired() {
2369 let server = MockServer::start().await;
2370 let client = client_for(&server);
2371 let (user_id, app_id) = test_ids();
2372
2373 let expired = fake_jwt(Utc::now().timestamp() - 3600);
2374 client.restore_session(&expired, user_id, app_id);
2375 client
2376 .set_master_key_raw(synckit_client::crypto::generate_master_key());
2377
2378 let err = client.push(Uuid::new_v4(), vec![]).await.unwrap_err();
2379 assert!(
2380 matches!(err, SyncKitError::TokenExpired),
2381 "Restored expired token should return TokenExpired, got: {err:?}"
2382 );
2383 }
2384
2385 // ── Encryption state edge cases ──
2386
2387 #[tokio::test]
2388 async fn setup_encryption_new_twice_overwrites() {
2389 let server = MockServer::start().await;
2390
2391 Mock::given(method("PUT"))
2392 .and(path("/api/sync/keys"))
2393 .respond_with(ResponseTemplate::new(200))
2394 .mount(&server)
2395 .await;
2396
2397 let client = authed_client(&server);
2398
2399 client.setup_encryption_new("pass1").await.unwrap();
2400 assert!(client.has_master_key());
2401
2402 // Second call overwrites
2403 client.setup_encryption_new("pass2").await.unwrap();
2404 assert!(client.has_master_key());
2405
2406 // Verify the second PUT used a different envelope
2407 let requests = server.received_requests().await.unwrap();
2408 let puts: Vec<_> = requests
2409 .iter()
2410 .filter(|r| r.method.as_str() == "PUT" && r.url.path() == "/api/sync/keys")
2411 .collect();
2412 assert_eq!(puts.len(), 2);
2413 // Different envelopes (different random keys)
2414 assert_ne!(puts[0].body, puts[1].body);
2415 }
2416
2417 // ── Blob edge cases ──
2418
2419 #[tokio::test]
2420 async fn blob_confirm_retries_on_503() {
2421 let server = MockServer::start().await;
2422
2423 Mock::given(method("POST"))
2424 .and(path("/api/sync/blobs/confirm"))
2425 .respond_with(ResponseTemplate::new(503).set_body_string("Service Unavailable"))
2426 .up_to_n_times(1)
2427 .mount(&server)
2428 .await;
2429
2430 Mock::given(method("POST"))
2431 .and(path("/api/sync/blobs/confirm"))
2432 .respond_with(ResponseTemplate::new(200))
2433 .mount(&server)
2434 .await;
2435
2436 let client = authed_client(&server);
2437 let result = client.blob_confirm("sha256-retry", 512).await;
2438 assert!(result.is_ok(), "Should succeed after retry: {result:?}");
2439 }
2440
2441 #[tokio::test]
2442 async fn blob_download_retries_on_503() {
2443 let server = MockServer::start().await;
2444
2445 let client = authed_client(&server);
2446 let key = synckit_client::crypto::generate_master_key();
2447 client.set_master_key_raw(key);
2448
2449 let plaintext = b"retry download test";
2450 let encrypted = synckit_client::crypto::encrypt_bytes(plaintext, &key).unwrap();
2451
2452 let download_path = "/s3/retry-download";
2453 Mock::given(method("GET"))
2454 .and(path(download_path))
2455 .respond_with(ResponseTemplate::new(503))
2456 .up_to_n_times(1)
2457 .mount(&server)
2458 .await;
2459
2460 Mock::given(method("GET"))
2461 .and(path(download_path))
2462 .respond_with(ResponseTemplate::new(200).set_body_bytes(encrypted))
2463 .mount(&server)
2464 .await;
2465
2466 let presigned = format!("{}{}", server.uri(), download_path);
2467 let result = client.blob_download(&presigned).await.unwrap();
2468 assert_eq!(result, plaintext);
2469 }
2470
2471 #[tokio::test]
2472 async fn blob_upload_1mb_with_correct_overhead() {
2473 let server = MockServer::start().await;
2474
2475 let upload_path = "/s3/1mb-upload";
2476 Mock::given(method("PUT"))
2477 .and(path(upload_path))
2478 .respond_with(ResponseTemplate::new(200))
2479 .mount(&server)
2480 .await;
2481
2482 let client = authed_client(&server);
2483 let key = synckit_client::crypto::generate_master_key();
2484 client.set_master_key_raw(key);
2485
2486 let plaintext: Vec<u8> = (0..1_048_576u32).map(|i| (i % 256) as u8).collect();
2487 let presigned = format!("{}{}", server.uri(), upload_path);
2488 client.blob_upload(&presigned, plaintext.clone()).await.unwrap();
2489
2490 let requests = server.received_requests().await.unwrap();
2491 let upload_req = requests.iter().find(|r| r.url.path() == upload_path).unwrap();
2492 assert_eq!(
2493 upload_req.body.len(),
2494 plaintext.len() + synckit_client::crypto::ENCRYPTION_OVERHEAD,
2495 "1MB upload should have exactly ENCRYPTION_OVERHEAD bytes added"
2496 );
2497 }
2498
2499 // ── Device management edge cases ──
2500
2501 #[tokio::test]
2502 async fn register_device_with_empty_name() {
2503 let server = MockServer::start().await;
2504
2505 Mock::given(method("POST"))
2506 .and(path("/api/sync/devices"))
2507 .respond_with(ResponseTemplate::new(200).set_body_json(device_json()))
2508 .mount(&server)
2509 .await;
2510
2511 let client = authed_client(&server);
2512 // Empty name should not panic — server may accept or reject
2513 let result = client.register_device("", "macos").await;
2514 assert!(result.is_ok(), "Empty device name should not panic");
2515 }
2516
2517 #[tokio::test]
2518 async fn register_device_with_unicode_name() {
2519 let server = MockServer::start().await;
2520
2521 let (user_id, app_id) = test_ids();
2522 Mock::given(method("POST"))
2523 .and(path("/api/sync/devices"))
2524 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2525 "id": Uuid::new_v4(),
2526 "app_id": app_id,
2527 "user_id": user_id,
2528 "device_name": "\u{30DE}\u{30C3}\u{30AF}\u{30D6}\u{30C3}\u{30AF}",
2529 "platform": "macos",
2530 "last_seen_at": "2025-01-01T00:00:00Z",
2531 "created_at": "2025-01-01T00:00:00Z",
2532 })))
2533 .mount(&server)
2534 .await;
2535
2536 let client = authed_client(&server);
2537 let device = client
2538 .register_device("\u{30DE}\u{30C3}\u{30AF}\u{30D6}\u{30C3}\u{30AF}", "macos")
2539 .await
2540 .unwrap();
2541 assert_eq!(
2542 device.device_name,
2543 "\u{30DE}\u{30C3}\u{30AF}\u{30D6}\u{30C3}\u{30AF}"
2544 );
2545 }
2546
2547 #[tokio::test]
2548 async fn list_devices_empty_array() {
2549 let server = MockServer::start().await;
2550
2551 Mock::given(method("GET"))
2552 .and(path("/api/sync/devices"))
2553 .respond_with(ResponseTemplate::new(200).set_body_json(json!([])))
2554 .mount(&server)
2555 .await;
2556
2557 let client = authed_client(&server);
2558 let devices = client.list_devices().await.unwrap();
2559 assert!(devices.is_empty());
2560 }
2561
2562 // ── Concurrency stress tests ──
2563
2564 #[tokio::test]
2565 async fn concurrent_session_info_reads() {
2566 let server = MockServer::start().await;
2567 let client = Arc::new(authed_client(&server));
2568
2569 let mut handles = Vec::new();
2570 for _ in 0..50 {
2571 let c = Arc::clone(&client);
2572 handles.push(tokio::spawn(async move { c.session_info() }));
2573 }
2574
2575 for h in handles {
2576 let info = h.await.unwrap();
2577 assert!(info.is_some(), "All concurrent reads should see the session");
2578 }
2579 }
2580
2581 #[tokio::test]
2582 async fn concurrent_has_master_key_reads() {
2583 let server = MockServer::start().await;
2584 let client = Arc::new(authed_client(&server));
2585 client
2586 .set_master_key_raw(synckit_client::crypto::generate_master_key());
2587
2588 let mut handles = Vec::new();
2589 for _ in 0..50 {
2590 let c = Arc::clone(&client);
2591 handles.push(tokio::spawn(async move { c.has_master_key() }));
2592 }
2593
2594 for h in handles {
2595 let has_key = h.await.unwrap();
2596 assert!(has_key, "All concurrent reads should see the master key");
2597 }
2598 }
2599
2600 #[tokio::test]
2601 async fn concurrent_status_checks() {
2602 let server = MockServer::start().await;
2603
2604 Mock::given(method("GET"))
2605 .and(path("/api/sync/status"))
2606 .respond_with(
2607 ResponseTemplate::new(200)
2608 .set_body_json(json!({"total_changes": 5, "latest_cursor": 3})),
2609 )
2610 .mount(&server)
2611 .await;
2612
2613 let client = Arc::new(authed_client(&server));
2614
2615 let mut handles = Vec::new();
2616 for _ in 0..20 {
2617 let c = Arc::clone(&client);
2618 handles.push(tokio::spawn(async move { c.status().await }));
2619 }
2620
2621 for h in handles {
2622 let result = h.await.unwrap();
2623 assert!(result.is_ok(), "All concurrent status checks should succeed: {result:?}");
2624 assert_eq!(result.unwrap().total_changes, 5);
2625 }
2626 }
2627
2628 #[tokio::test]
2629 async fn concurrent_push_100_entries_each() {
2630 let server = MockServer::start().await;
2631
2632 Mock::given(method("POST"))
2633 .and(path("/api/sync/push"))
2634 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"cursor": 1})))
2635 .mount(&server)
2636 .await;
2637
2638 let client = Arc::new(authed_client(&server));
2639 let key = synckit_client::crypto::generate_master_key();
2640 client.set_master_key_raw(key);
2641
2642 let mut handles = Vec::new();
2643 for batch in 0..4 {
2644 let c = Arc::clone(&client);
2645 handles.push(tokio::spawn(async move {
2646 let changes: Vec<ChangeEntry> = (0..100)
2647 .map(|i| ChangeEntry {
2648 table: format!("batch_{batch}"),
2649 op: ChangeOp::Insert,
2650 row_id: format!("row_{i}"),
2651 timestamp: Utc::now(),
2652 data: Some(json!({"index": i})),
2653 })
2654 .collect();
2655 c.push(Uuid::new_v4(), changes).await
2656 }));
2657 }
2658
2659 for h in handles {
2660 let result = h.await.unwrap();
2661 assert!(result.is_ok(), "Concurrent 100-entry push should succeed: {result:?}");
2662 }
2663 }
2664
2665 // ── Timeout tests ──
2666
2667 fn short_timeout_client(server: &MockServer) -> SyncKitClient {
2668 let http = reqwest::Client::builder()
2669 .timeout(Duration::from_millis(100))
2670 .connect_timeout(Duration::from_millis(100))
2671 .build()
2672 .unwrap();
2673 SyncKitClient::with_http_client(
2674 SyncKitConfig {
2675 server_url: server.uri(),
2676 api_key: "test-api-key".to_string(),
2677 },
2678 http,
2679 )
2680 }
2681
2682 fn authed_short_timeout_client(server: &MockServer) -> SyncKitClient {
2683 let client = short_timeout_client(server);
2684 let (user_id, app_id) = test_ids();
2685 client
2686 .restore_session(&fresh_token(), user_id, app_id);
2687 client
2688 }
2689
2690 #[tokio::test]
2691 async fn status_times_out_on_slow_server() {
2692 let server = MockServer::start().await;
2693
2694 Mock::given(method("GET"))
2695 .and(path("/api/sync/status"))
2696 .respond_with(
2697 ResponseTemplate::new(200)
2698 .set_body_json(json!({"total_changes": 0, "latest_cursor": null}))
2699 .set_delay(Duration::from_secs(5)),
2700 )
2701 .mount(&server)
2702 .await;
2703
2704 let client = authed_short_timeout_client(&server);
2705 let err = client.status().await.unwrap_err();
2706 // Timeout triggers Http error, which is transient, so it retries and eventually exhausts
2707 assert!(
2708 matches!(err, SyncKitError::Http(_)),
2709 "Slow server should produce Http (timeout) error, got: {err:?}"
2710 );
2711 }
2712
2713 #[tokio::test]
2714 async fn push_retries_on_timeout_then_succeeds() {
2715 let server = MockServer::start().await;
2716
2717 // First request: slow (will timeout)
2718 Mock::given(method("POST"))
2719 .and(path("/api/sync/push"))
2720 .respond_with(
2721 ResponseTemplate::new(200)
2722 .set_body_json(json!({"cursor": 1}))
2723 .set_delay(Duration::from_secs(5)),
2724 )
2725 .up_to_n_times(1)
2726 .mount(&server)
2727 .await;
2728
2729 // Second request: fast (succeeds)
2730 Mock::given(method("POST"))
2731 .and(path("/api/sync/push"))
2732 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"cursor": 42})))
2733 .mount(&server)
2734 .await;
2735
2736 let client = authed_short_timeout_client(&server);
2737 let key = synckit_client::crypto::generate_master_key();
2738 client.set_master_key_raw(key);
2739
2740 let cursor = client.push(Uuid::new_v4(), vec![]).await.unwrap();
2741 assert_eq!(cursor, 42);
2742 }
2743