//! Transport behavior independent of any one endpoint: error classification, //! retry policy, timeouts, the response body cap, and what the client does with //! a malformed or unexpected response body. use crate::common::*; const STATUS_PATH: &str = "/api/v1/sync/status"; const PUSH_PATH: &str = "/api/v1/sync/push"; const PULL_PATH: &str = "/api/v1/sync/pull"; const AUTH_PATH: &str = "/api/v1/sync/auth"; const DEVICES_PATH: &str = "/api/v1/sync/devices"; /// The status body the retry tests fall through to once the failures are spent. /// They assert on the retry, not on the numbers, so any well-formed body does. fn ok_status() -> serde_json::Value { json!({"total_changes": 0, "latest_cursor": null}) } // ── Response body cap (DoS) ── #[tokio::test] async fn oversized_response_body_is_capped_not_buffered() { // A hostile/buggy server streams a control-plane body far larger than the // 8 MiB cap. The client must reject it (the cap fast-rejects on the honest // Content-Length) instead of buffering it into memory and OOMing. let kit = MockKit::start().await; kit.get(STATUS_PATH) .bytes(vec![b'x'; 9 * 1024 * 1024]) .await; let err = kit.authed().status().await.unwrap_err(); assert!( matches!(err, SyncKitError::Internal(ref m) if m.contains("cap")), "expected the body cap to reject the oversized response, got: {err:?}" ); } // ── Error classification ── #[tokio::test] async fn error_429_is_retried() { let kit = MockKit::start().await; kit.get(STATUS_PATH) .code(429) .once() .text("Too Many Requests") .await; kit.get(STATUS_PATH) .json(json!({"total_changes": 10, "latest_cursor": 5})) .await; let status = kit.authed().status().await.unwrap(); assert_eq!(status.total_changes, 10); } #[tokio::test] async fn error_400_not_retried() { let kit = MockKit::start().await; kit.post(DEVICES_PATH) .code(400) .exactly(1) .text("Bad Request") .await; let err = kit .authed() .register_device("Device", "test") .await .unwrap_err(); assert!(matches!(err, SyncKitError::Server { status: 400, .. })); } // ── Status endpoint ── #[tokio::test] async fn status_success() { let kit = MockKit::start().await; kit.get(STATUS_PATH) .json(json!({"total_changes": 42, "latest_cursor": 100})) .await; let status = kit.authed().status().await.unwrap(); assert_eq!(status.total_changes, 42); assert_eq!(status.latest_cursor, Some(100)); } #[tokio::test] async fn status_retries_on_transient() { let kit = MockKit::start().await; kit.get(STATUS_PATH) .code(504) .once() .text("Gateway Timeout") .await; kit.get(STATUS_PATH).json(ok_status()).await; let status = kit.authed().status().await.unwrap(); assert_eq!(status.total_changes, 0); } // ── Malformed server responses ── #[tokio::test] async fn push_malformed_json_response_handled() { let kit = MockKit::start().await; kit.post(PUSH_PATH).text("not valid json at all").await; let (client, _key) = kit.keyed(); let result = client.push(DeviceId::new(Uuid::new_v4()), vec![]).await; assert!(result.is_err(), "Malformed JSON should produce an error"); // Should be a JSON parse error, not a panic let err = result.unwrap_err(); assert!( matches!(err, SyncKitError::Http(_) | SyncKitError::Json(_)), "Expected Http or Json error for malformed response, got: {err:?}" ); } #[tokio::test] async fn pull_malformed_json_response_handled() { let kit = MockKit::start().await; kit.post(PULL_PATH).text("{invalid json}").await; let (client, _key) = kit.keyed(); let result = client.pull(DeviceId::new(Uuid::new_v4()), 0).await; assert!(result.is_err(), "Malformed JSON should produce an error"); } #[tokio::test] async fn status_malformed_json_response_handled() { let kit = MockKit::start().await; kit.get(STATUS_PATH).text("this is not json").await; let result = kit.authed().status().await; assert!(result.is_err()); } // ── Server error messages preserved ── #[tokio::test] async fn server_error_message_preserved() { let kit = MockKit::start().await; kit.get(STATUS_PATH) .code(422) .text("Validation failed: missing field") .await; let err = kit.authed().status().await.unwrap_err(); match err { SyncKitError::Server { status, message, .. } => { assert_eq!(status, 422); assert!( message.contains("Validation failed"), "Error message should be preserved: {message}" ); } other => panic!("Expected Server error, got: {other:?}"), } } // ── Config persistence (SyncKitConfig serialization) ── #[tokio::test] async fn config_serialization_roundtrip() { let config = SyncKitConfig { server_url: "https://makenot.work".to_string(), api_key: "ak_test_12345".to_string(), }; // SyncKitConfig derives Clone and Debug; verify round trip through Debug let debug = format!("{config:?}"); assert!(debug.contains("makenot.work")); assert!(debug.contains("ak_test_12345")); let cloned = config.clone(); assert_eq!(cloned.server_url, config.server_url); assert_eq!(cloned.api_key, config.api_key); } // ── API error mapping ── #[tokio::test] async fn all_4xx_error_codes_mapped() { let kit = MockKit::start().await; for status_code in [400, 401, 403, 404, 409, 422] { kit.reset().await; kit.get(STATUS_PATH) .code(status_code) .text(format!("Error {status_code}")) .await; let err = kit.authed().status().await.unwrap_err(); match err { SyncKitError::Server { status, message, .. } => { assert_eq!(status, status_code); assert!(message.contains(&format!("Error {status_code}"))); } other => panic!("Status {status_code} should map to Server error, got: {other:?}"), } } } #[tokio::test] async fn all_5xx_error_codes_retried() { for status_code in [500, 502, 503, 504] { let kit = MockKit::start().await; kit.get(STATUS_PATH) .code(status_code) .once() .text("Server Error") .await; kit.get(STATUS_PATH).json(ok_status()).await; let result = kit.authed().status().await; assert!( result.is_ok(), "Status {status_code} should be retried and succeed: {result:?}" ); } } // ── Retry count verification ── #[tokio::test] async fn retry_exhausts_all_attempts_on_persistent_503() { let kit = MockKit::start().await; kit.get(STATUS_PATH) .code(503) .text("Service Unavailable") .await; let err = kit.authed().status().await.unwrap_err(); assert!(matches!(err, SyncKitError::Server { status: 503, .. })); // Should have made exactly 4 requests (1 initial + 3 retries) let attempts = kit.hits(STATUS_PATH).await; assert_eq!( attempts, 4, "Expected 4 total requests (1 + MAX_RETRIES=3), got {attempts}" ); } #[tokio::test] async fn unsafe_op_makes_exactly_one_attempt_on_transient_error() { // create_subscription_checkout is Idempotency::Unsafe (a retry could mint a // second Stripe session), so a transient 503 must NOT be retried. const CHECKOUT_PATH: &str = "/api/v1/sync/subscription/checkout"; let kit = MockKit::start().await; kit.post(CHECKOUT_PATH) .code(503) .text("Service Unavailable") .await; let err = kit .authed() .create_subscription_checkout(1_000_000_000, synckit_client::BillingInterval::Monthly) .await .unwrap_err(); assert!(matches!(err, SyncKitError::Server { status: 503, .. })); let attempts = kit.hits(CHECKOUT_PATH).await; assert_eq!( attempts, 1, "Unsafe op must be attempted exactly once, got {attempts}" ); } #[tokio::test] async fn retry_not_attempted_on_404() { let kit = MockKit::start().await; kit.get(STATUS_PATH).code(404).text("Not Found").await; let err = kit.authed().status().await.unwrap_err(); assert!(matches!(err, SyncKitError::Server { status: 404, .. })); assert_eq!(kit.hits(STATUS_PATH).await, 1, "404 should not be retried"); } #[tokio::test] async fn retry_succeeds_on_third_attempt() { let kit = MockKit::start().await; kit.get(STATUS_PATH) .code(503) .at_most(2) .text("Service Unavailable") .await; kit.get(STATUS_PATH) .json(json!({"total_changes": 7, "latest_cursor": 3})) .await; let status = kit.authed().status().await.unwrap(); assert_eq!(status.total_changes, 7); assert_eq!( kit.hits(STATUS_PATH).await, 3, "Should succeed on 3rd attempt" ); } // ── Malformed / unexpected responses ── #[tokio::test] async fn authenticate_html_response_returns_error() { let kit = MockKit::start().await; kit.post(AUTH_PATH) .reply( ResponseTemplate::new(200) .insert_header("content-type", "text/html") .set_body_string("Not JSON"), ) .await; let err = kit .client() .authenticate("user@test.com", "pass", "test-key") .await .unwrap_err(); // reqwest .json() fails when body isn't valid JSON assert!( matches!(err, SyncKitError::Http(_) | SyncKitError::Json(_)), "HTML response should produce Http or Json error, got: {err:?}" ); } #[tokio::test] async fn push_empty_response_body_returns_error() { let kit = MockKit::start().await; kit.post(PUSH_PATH).text("").await; let (client, _key) = kit.keyed(); let err = client .push(DeviceId::new(Uuid::new_v4()), vec![]) .await .unwrap_err(); assert!( matches!(err, SyncKitError::Http(_) | SyncKitError::Json(_)), "Empty body should produce parse error, got: {err:?}" ); } #[tokio::test] async fn pull_response_missing_has_more_returns_error() { let kit = MockKit::start().await; kit.post(PULL_PATH) .json(json!({ "changes": [], "cursor": 0 // missing "has_more" })) .await; let (client, _key) = kit.keyed(); let err = client .pull(DeviceId::new(Uuid::new_v4()), 0) .await .unwrap_err(); assert!( matches!(err, SyncKitError::Http(_) | SyncKitError::Json(_)), "Missing has_more should produce parse error, got: {err:?}" ); } #[tokio::test] async fn status_response_cursor_wrong_type_returns_error() { let kit = MockKit::start().await; kit.get(STATUS_PATH) .json(json!({ "total_changes": 10, "latest_cursor": "not-a-number" })) .await; let err = kit.authed().status().await.unwrap_err(); assert!( matches!(err, SyncKitError::Http(_) | SyncKitError::Json(_)), "Wrong type for cursor should produce parse error, got: {err:?}" ); } #[tokio::test] async fn authenticate_response_missing_app_id_returns_error() { let kit = MockKit::start().await; kit.post(AUTH_PATH) .json(json!({ "token": fresh_token(), "user_id": "550e8400-e29b-41d4-a716-446655440000" // missing "app_id" })) .await; let err = kit .client() .authenticate("user@test.com", "pass", "test-key") .await .unwrap_err(); assert!( matches!(err, SyncKitError::Http(_) | SyncKitError::Json(_)), "Missing app_id should produce parse error, got: {err:?}" ); } #[tokio::test] async fn register_device_extra_fields_ignored() { let kit = MockKit::start().await; let (user_id, app_id) = test_ids(); kit.post(DEVICES_PATH) .json(json!({ "id": Uuid::new_v4(), "app_id": app_id, "user_id": user_id, "device_name": "Test Device", "platform": "test", "last_seen_at": "2025-01-01T00:00:00Z", "created_at": "2025-01-01T00:00:00Z", "extra_field": "should be ignored", "unknown_number": 42 })) .await; let device = kit.authed().register_device("Test", "test").await.unwrap(); assert_eq!(device.device_name, "Test Device"); } #[tokio::test] async fn blob_upload_url_response_missing_already_exists_returns_error() { let kit = MockKit::start().await; kit.post("/api/v1/sync/blobs/upload") .json(json!({ "upload_url": "https://s3.example.com/put" // missing "already_exists" })) .await; let result = kit.authed().blob_upload_url("hash", 100).await; match result { Err(err) => assert!( matches!(err, SyncKitError::Http(_) | SyncKitError::Json(_)), "Missing already_exists should produce error, got: {err:?}" ), Ok(_) => panic!("Expected error for missing already_exists field"), } } #[tokio::test] async fn server_returns_413_request_entity_too_large() { let kit = MockKit::start().await; kit.post(PUSH_PATH) .code(413) .text("Request entity too large") .await; let (client, _key) = kit.keyed(); let err = client .push(DeviceId::new(Uuid::new_v4()), vec![]) .await .unwrap_err(); match err { SyncKitError::Server { status, message, .. } => { assert_eq!(status, 413); assert!(message.contains("too large")); } other => panic!("Expected Server error, got: {other:?}"), } } // ── Timeout tests ── /// The timeout lives on the reqwest client, so it has to be set at construction /// rather than on the SyncKit client afterwards. fn authed_short_timeout_client(kit: &MockKit) -> SyncKitClient { // Builds a reqwest client directly rather than through SyncKitClient::new, // so the provider has to be installed here. ensure_crypto_provider(); let http = reqwest::Client::builder() .timeout(Duration::from_millis(100)) .connect_timeout(Duration::from_millis(100)) .build() .unwrap(); kit.authed_with_http(http) } #[tokio::test] async fn status_times_out_on_slow_server() { let kit = MockKit::start().await; kit.get(STATUS_PATH) .reply( ResponseTemplate::new(200) .set_body_json(ok_status()) .set_delay(Duration::from_secs(5)), ) .await; let err = authed_short_timeout_client(&kit) .status() .await .unwrap_err(); // Timeout triggers Http error, which is transient, so it retries and eventually exhausts assert!( matches!(err, SyncKitError::Http(_)), "Slow server should produce Http (timeout) error, got: {err:?}" ); } #[tokio::test] async fn push_retries_on_timeout_then_succeeds() { let kit = MockKit::start().await; // First request: slow (will timeout) kit.post(PUSH_PATH) .once() .reply( ResponseTemplate::new(200) .set_body_json(json!({"cursor": 1})) .set_delay(Duration::from_secs(5)), ) .await; // Second request: fast (succeeds) kit.post(PUSH_PATH).json(json!({"cursor": 42})).await; let client = authed_short_timeout_client(&kit); client.set_master_key_raw(synckit_client::crypto::generate_master_key()); let cursor = client .push(DeviceId::new(Uuid::new_v4()), vec![]) .await .unwrap(); assert_eq!(cursor, 42); }