max / synckit
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
14 files changed,
+1106 insertions,
-1585 deletions
| @@ -6,19 +6,16 @@ | |||
| 6 | 6 | ||
| 7 | 7 | use crate::common::*; | |
| 8 | 8 | ||
| 9 | + | const AUTH_PATH: &str = "/api/v1/sync/auth"; | |
| 10 | + | ||
| 9 | 11 | // ── Auth flow ── | |
| 10 | 12 | ||
| 11 | 13 | #[tokio::test] | |
| 12 | 14 | async fn authenticate_success_stores_session() { | |
| 13 | - | let server = MockServer::start().await; | |
| 15 | + | let kit = MockKit::start().await; | |
| 16 | + | kit.post(AUTH_PATH).json(auth_response_json()).await; | |
| 14 | 17 | ||
| 15 | - | Mock::given(method("POST")) | |
| 16 | - | .and(path("/api/v1/sync/auth")) | |
| 17 | - | .respond_with(ResponseTemplate::new(200).set_body_json(auth_response_json())) | |
| 18 | - | .mount(&server) | |
| 19 | - | .await; | |
| 20 | - | ||
| 21 | - | let client = client_for(&server); | |
| 18 | + | let client = kit.client(); | |
| 22 | 19 | let (user_id, app_id) = client | |
| 23 | 20 | .authenticate("user@test.com", "password", "test-key") | |
| 24 | 21 | .await | |
| @@ -31,16 +28,16 @@ | |||
| 31 | 28 | ||
| 32 | 29 | #[tokio::test] | |
| 33 | 30 | async fn authenticate_wrong_password_no_retry() { | |
| 34 | - | let server = MockServer::start().await; | |
| 35 | - | ||
| 36 | - | Mock::given(method("POST")) | |
| 37 | - | .and(path("/api/v1/sync/auth")) | |
| 38 | - | .respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized")) | |
| 39 | - | .expect(1) // Must be called exactly once (no retry) | |
| 40 | - | .mount(&server) | |
| 31 | + | let kit = MockKit::start().await; | |
| 32 | + | // Exactly once: a retry on a rejected credential is a lockout waiting to | |
| 33 | + | // happen, so the count is the assertion. | |
| 34 | + | kit.post(AUTH_PATH) | |
| 35 | + | .code(401) | |
| 36 | + | .exactly(1) | |
| 37 | + | .text("Unauthorized") | |
| 41 | 38 | .await; | |
| 42 | 39 | ||
| 43 | - | let client = client_for(&server); | |
| 40 | + | let client = kit.client(); | |
| 44 | 41 | let err = client | |
| 45 | 42 | .authenticate("user@test.com", "wrong", "test-key") | |
| 46 | 43 | .await | |
| @@ -54,22 +51,15 @@ | |||
| 54 | 51 | ||
| 55 | 52 | #[tokio::test] | |
| 56 | 53 | async fn authenticate_retries_on_503() { | |
| 57 | - | let server = MockServer::start().await; | |
| 58 | - | ||
| 59 | - | Mock::given(method("POST")) | |
| 60 | - | .and(path("/api/v1/sync/auth")) | |
| 61 | - | .respond_with(ResponseTemplate::new(503).set_body_string("Service Unavailable")) | |
| 62 | - | .up_to_n_times(1) | |
| 63 | - | .mount(&server) | |
| 54 | + | let kit = MockKit::start().await; | |
| 55 | + | kit.post(AUTH_PATH) | |
| 56 | + | .code(503) | |
| 57 | + | .once() | |
| 58 | + | .text("Service Unavailable") | |
| 64 | 59 | .await; | |
| 60 | + | kit.post(AUTH_PATH).json(auth_response_json()).await; | |
| 65 | 61 | ||
| 66 | - | Mock::given(method("POST")) | |
| 67 | - | .and(path("/api/v1/sync/auth")) | |
| 68 | - | .respond_with(ResponseTemplate::new(200).set_body_json(auth_response_json())) | |
| 69 | - | .mount(&server) | |
| 70 | - | .await; | |
| 71 | - | ||
| 72 | - | let client = client_for(&server); | |
| 62 | + | let client = kit.client(); | |
| 73 | 63 | let result = client | |
| 74 | 64 | .authenticate("user@test.com", "password", "test-key") | |
| 75 | 65 | .await; | |
| @@ -78,22 +68,20 @@ | |||
| 78 | 68 | ||
| 79 | 69 | #[tokio::test] | |
| 80 | 70 | async fn authenticate_with_code_success() { | |
| 81 | - | let server = MockServer::start().await; | |
| 71 | + | let kit = MockKit::start().await; | |
| 82 | 72 | ||
| 83 | 73 | let (user_id, app_id) = test_ids(); | |
| 84 | - | Mock::given(method("POST")) | |
| 85 | - | .and(path("/oauth/token")) | |
| 86 | - | .respond_with(ResponseTemplate::new(200).set_body_json(json!({ | |
| 74 | + | kit.post("/oauth/token") | |
| 75 | + | .json(json!({ | |
| 87 | 76 | "access_token": fresh_token(), | |
| 88 | 77 | "token_type": "Bearer", | |
| 89 | 78 | "expires_in": 3600, | |
| 90 | 79 | "user_id": user_id, | |
| 91 | 80 | "app_id": app_id, | |
| 92 | - | }))) | |
| 93 | - | .mount(&server) | |
| 81 | + | })) | |
| 94 | 82 | .await; | |
| 95 | 83 | ||
| 96 | - | let client = client_for(&server); | |
| 84 | + | let client = kit.client(); | |
| 97 | 85 | let (uid, aid) = client | |
| 98 | 86 | .authenticate_with_code("auth-code", "verifier", 8080, "test-key") | |
| 99 | 87 | .await | |
| @@ -108,12 +96,8 @@ | |||
| 108 | 96 | ||
| 109 | 97 | #[tokio::test] | |
| 110 | 98 | async fn expired_jwt_returns_token_expired() { | |
| 111 | - | let server = MockServer::start().await; | |
| 112 | - | let client = client_for(&server); | |
| 113 | - | let (user_id, app_id) = test_ids(); | |
| 114 | - | ||
| 115 | - | let expired = fake_jwt(Utc::now().timestamp() - 3600); | |
| 116 | - | client.restore_session(&expired, user_id, app_id); | |
| 99 | + | let kit = MockKit::start().await; | |
| 100 | + | let client = kit.client_with_token(&fake_jwt(Utc::now().timestamp() - 3600)); | |
| 117 | 101 | ||
| 118 | 102 | let err = client.status().await.unwrap_err(); | |
| 119 | 103 | assert!( | |
| @@ -124,13 +108,9 @@ | |||
| 124 | 108 | ||
| 125 | 109 | #[tokio::test] | |
| 126 | 110 | async fn near_expiry_jwt_returns_token_expired() { | |
| 127 | - | let server = MockServer::start().await; | |
| 128 | - | let client = client_for(&server); | |
| 129 | - | let (user_id, app_id) = test_ids(); | |
| 130 | - | ||
| 131 | - | // Token expires in 10 seconds (within 30-second buffer) | |
| 132 | - | let near_expiry = fake_jwt(Utc::now().timestamp() + 10); | |
| 133 | - | client.restore_session(&near_expiry, user_id, app_id); | |
| 111 | + | let kit = MockKit::start().await; | |
| 112 | + | // Expires in 10 seconds, inside the 30-second pre-flight buffer. | |
| 113 | + | let client = kit.client_with_token(&fake_jwt(Utc::now().timestamp() + 10)); | |
| 134 | 114 | ||
| 135 | 115 | let err = client.status().await.unwrap_err(); | |
| 136 | 116 | assert!( | |
| @@ -143,18 +123,12 @@ | |||
| 143 | 123 | ||
| 144 | 124 | #[tokio::test] | |
| 145 | 125 | async fn restore_then_clear_session() { | |
| 146 | - | let server = MockServer::start().await; | |
| 147 | - | ||
| 148 | - | Mock::given(method("GET")) | |
| 149 | - | .and(path("/api/v1/sync/status")) | |
| 150 | - | .respond_with( | |
| 151 | - | ResponseTemplate::new(200) | |
| 152 | - | .set_body_json(json!({"total_changes": 0, "latest_cursor": null})), | |
| 153 | - | ) | |
| 154 | - | .mount(&server) | |
| 126 | + | let kit = MockKit::start().await; | |
| 127 | + | kit.get("/api/v1/sync/status") | |
| 128 | + | .json(json!({"total_changes": 0, "latest_cursor": null})) | |
| 155 | 129 | .await; | |
| 156 | 130 | ||
| 157 | - | let client = authed_client(&server); | |
| 131 | + | let client = kit.authed(); | |
| 158 | 132 | ||
| 159 | 133 | // Should work while authenticated | |
| 160 | 134 | let status = client.status().await.unwrap(); | |
| @@ -170,19 +144,16 @@ | |||
| 170 | 144 | ||
| 171 | 145 | #[tokio::test] | |
| 172 | 146 | async fn status_without_auth_returns_not_authenticated() { | |
| 173 | - | let server = MockServer::start().await; | |
| 174 | - | let client = client_for(&server); | |
| 175 | - | ||
| 176 | - | let err = client.status().await.unwrap_err(); | |
| 147 | + | let kit = MockKit::start().await; | |
| 148 | + | let err = kit.client().status().await.unwrap_err(); | |
| 177 | 149 | assert!(matches!(err, SyncKitError::NotAuthenticated)); | |
| 178 | 150 | } | |
| 179 | 151 | ||
| 180 | 152 | #[tokio::test] | |
| 181 | 153 | async fn push_without_auth_returns_not_authenticated() { | |
| 182 | - | let server = MockServer::start().await; | |
| 183 | - | let client = client_for(&server); | |
| 184 | - | ||
| 185 | - | let err = client | |
| 154 | + | let kit = MockKit::start().await; | |
| 155 | + | let err = kit | |
| 156 | + | .client() | |
| 186 | 157 | .push(DeviceId::new(Uuid::new_v4()), vec![]) | |
| 187 | 158 | .await | |
| 188 | 159 | .unwrap_err(); | |
| @@ -191,17 +162,18 @@ | |||
| 191 | 162 | ||
| 192 | 163 | // ── Session expiry handling ── | |
| 193 | 164 | ||
| 165 | + | /// A client whose restored token expired 100 seconds ago, holding a master key | |
| 166 | + | /// so nothing but the expiry can stop the call under test. | |
| 167 | + | fn expired_keyed_client(kit: &MockKit) -> SyncKitClient { | |
| 168 | + | let client = kit.client_with_token(&fake_jwt(Utc::now().timestamp() - 100)); | |
| 169 | + | client.set_master_key_raw(synckit_client::crypto::generate_master_key()); | |
| 170 | + | client | |
| 171 | + | } | |
| 172 | + | ||
| 194 | 173 | #[tokio::test] | |
| 195 | 174 | async fn expired_token_detected_before_push() { | |
| 196 | - | let server = MockServer::start().await; | |
| 197 | - | let client = client_for(&server); | |
| 198 | - | let (user_id, app_id) = test_ids(); | |
| 199 | - | ||
| 200 | - | let expired = fake_jwt(Utc::now().timestamp() - 100); | |
| 201 | - | client.restore_session(&expired, user_id, app_id); | |
| 202 | - | client.set_master_key_raw(synckit_client::crypto::generate_master_key()); | |
| 203 | - | ||
| 204 | - | let err = client | |
| 175 | + | let kit = MockKit::start().await; | |
| 176 | + | let err = expired_keyed_client(&kit) | |
| 205 | 177 | .push(DeviceId::new(Uuid::new_v4()), vec![]) | |
| 206 | 178 | .await | |
| 207 | 179 | .unwrap_err(); | |
| @@ -213,15 +185,8 @@ | |||
| 213 | 185 | ||
| 214 | 186 | #[tokio::test] | |
| 215 | 187 | async fn expired_token_detected_before_pull() { | |
| 216 | - | let server = MockServer::start().await; | |
| 217 | - | let client = client_for(&server); | |
| 218 | - | let (user_id, app_id) = test_ids(); | |
| 219 | - | ||
| 220 | - | let expired = fake_jwt(Utc::now().timestamp() - 100); | |
| 221 | - | client.restore_session(&expired, user_id, app_id); | |
| 222 | - | client.set_master_key_raw(synckit_client::crypto::generate_master_key()); | |
| 223 | - | ||
| 224 | - | let err = client | |
| 188 | + | let kit = MockKit::start().await; | |
| 189 | + | let err = expired_keyed_client(&kit) | |
| 225 | 190 | .pull(DeviceId::new(Uuid::new_v4()), 0) | |
| 226 | 191 | .await | |
| 227 | 192 | .unwrap_err(); | |
| @@ -230,12 +195,8 @@ | |||
| 230 | 195 | ||
| 231 | 196 | #[tokio::test] | |
| 232 | 197 | async fn expired_token_detected_before_register_device() { | |
| 233 | - | let server = MockServer::start().await; | |
| 234 | - | let client = client_for(&server); | |
| 235 | - | let (user_id, app_id) = test_ids(); | |
| 236 | - | ||
| 237 | - | let expired = fake_jwt(Utc::now().timestamp() - 100); | |
| 238 | - | client.restore_session(&expired, user_id, app_id); | |
| 198 | + | let kit = MockKit::start().await; | |
| 199 | + | let client = kit.client_with_token(&fake_jwt(Utc::now().timestamp() - 100)); | |
| 239 | 200 | ||
| 240 | 201 | let err = client.register_device("Test", "test").await.unwrap_err(); | |
| 241 | 202 | assert!(matches!(err, SyncKitError::TokenExpired)); | |
| @@ -243,12 +204,8 @@ | |||
| 243 | 204 | ||
| 244 | 205 | #[tokio::test] | |
| 245 | 206 | async fn expired_token_detected_before_list_devices() { | |
| 246 | - | let server = MockServer::start().await; | |
| 247 | - | let client = client_for(&server); | |
| 248 | - | let (user_id, app_id) = test_ids(); | |
| 249 | - | ||
| 250 | - | let expired = fake_jwt(Utc::now().timestamp() - 100); | |
| 251 | - | client.restore_session(&expired, user_id, app_id); | |
| 207 | + | let kit = MockKit::start().await; | |
| 208 | + | let client = kit.client_with_token(&fake_jwt(Utc::now().timestamp() - 100)); | |
| 252 | 209 | ||
| 253 | 210 | let err = client.list_devices().await.unwrap_err(); | |
| 254 | 211 | assert!(matches!(err, SyncKitError::TokenExpired)); | |
| @@ -258,35 +215,29 @@ | |||
| 258 | 215 | ||
| 259 | 216 | #[tokio::test] | |
| 260 | 217 | async fn double_authenticate_overwrites_session() { | |
| 261 | - | let server = MockServer::start().await; | |
| 218 | + | let kit = MockKit::start().await; | |
| 262 | 219 | ||
| 263 | 220 | let (user_id, app_id) = test_ids(); | |
| 264 | 221 | let second_user_id = UserId::new(Uuid::new_v4()); | |
| 265 | 222 | ||
| 266 | - | // First auth response | |
| 267 | - | Mock::given(method("POST")) | |
| 268 | - | .and(path("/api/v1/sync/auth")) | |
| 269 | - | .respond_with(ResponseTemplate::new(200).set_body_json(json!({ | |
| 223 | + | kit.post(AUTH_PATH) | |
| 224 | + | .once() | |
| 225 | + | .json(json!({ | |
| 270 | 226 | "token": fresh_token(), | |
| 271 | 227 | "user_id": user_id, | |
| 272 | 228 | "app_id": app_id, | |
| 273 | - | }))) | |
| 274 | - | .up_to_n_times(1) | |
| 275 | - | .mount(&server) | |
| 229 | + | })) | |
| 276 | 230 | .await; | |
| 277 | - | ||
| 278 | - | // Second auth response with different user_id | |
| 279 | - | Mock::given(method("POST")) | |
| 280 | - | .and(path("/api/v1/sync/auth")) | |
| 281 | - | .respond_with(ResponseTemplate::new(200).set_body_json(json!({ | |
| 231 | + | // Second auth answers with a different user_id. | |
| 232 | + | kit.post(AUTH_PATH) | |
| 233 | + | .json(json!({ | |
| 282 | 234 | "token": fresh_token(), | |
| 283 | 235 | "user_id": second_user_id, | |
| 284 | 236 | "app_id": app_id, | |
| 285 | - | }))) | |
| 286 | - | .mount(&server) | |
| 237 | + | })) | |
| 287 | 238 | .await; | |
| 288 | 239 | ||
| 289 | - | let client = client_for(&server); | |
| 240 | + | let client = kit.client(); | |
| 290 | 241 | let (uid1, _) = client | |
| 291 | 242 | .authenticate("user1@test.com", "pass1", "test-key") | |
| 292 | 243 | .await | |
| @@ -306,15 +257,10 @@ | |||
| 306 | 257 | ||
| 307 | 258 | #[tokio::test] | |
| 308 | 259 | async fn clear_session_then_authenticate_succeeds() { | |
| 309 | - | let server = MockServer::start().await; | |
| 260 | + | let kit = MockKit::start().await; | |
| 261 | + | kit.post(AUTH_PATH).json(auth_response_json()).await; | |
| 310 | 262 | ||
| 311 | - | Mock::given(method("POST")) | |
| 312 | - | .and(path("/api/v1/sync/auth")) | |
| 313 | - | .respond_with(ResponseTemplate::new(200).set_body_json(auth_response_json())) | |
| 314 | - | .mount(&server) | |
| 315 | - | .await; | |
| 316 | - | ||
| 317 | - | let client = authed_client(&server); | |
| 263 | + | let client = kit.authed(); | |
| 318 | 264 | assert!(client.session_info().is_some()); | |
| 319 | 265 | ||
| 320 | 266 | client.clear_session(); | |
| @@ -333,12 +279,8 @@ | |||
| 333 | 279 | ||
| 334 | 280 | #[tokio::test] | |
| 335 | 281 | async fn restore_session_with_expired_token_then_push_returns_token_expired() { | |
| 336 | - | let server = MockServer::start().await; | |
| 337 | - | let client = client_for(&server); | |
| 338 | - | let (user_id, app_id) = test_ids(); | |
| 339 | - | ||
| 340 | - | let expired = fake_jwt(Utc::now().timestamp() - 3600); | |
| 341 | - | client.restore_session(&expired, user_id, app_id); | |
| 282 | + | let kit = MockKit::start().await; | |
| 283 | + | let client = kit.client_with_token(&fake_jwt(Utc::now().timestamp() - 3600)); | |
| 342 | 284 | client.set_master_key_raw(synckit_client::crypto::generate_master_key()); | |
| 343 | 285 | ||
| 344 | 286 | let err = client |
| @@ -3,23 +3,26 @@ | |||
| 3 | 3 | ||
| 4 | 4 | use crate::common::*; | |
| 5 | 5 | ||
| 6 | + | const UPLOAD_URL_PATH: &str = "/api/v1/sync/blobs/upload"; | |
| 7 | + | const CONFIRM_PATH: &str = "/api/v1/sync/blobs/confirm"; | |
| 8 | + | ||
| 6 | 9 | // ── Blob operations ── | |
| 7 | 10 | ||
| 8 | 11 | #[tokio::test] | |
| 9 | 12 | async fn blob_upload_url_success() { | |
| 10 | - | let server = MockServer::start().await; | |
| 11 | - | ||
| 12 | - | Mock::given(method("POST")) | |
| 13 | - | .and(path("/api/v1/sync/blobs/upload")) | |
| 14 | - | .respond_with(ResponseTemplate::new(200).set_body_json(json!({ | |
| 13 | + | let kit = MockKit::start().await; | |
| 14 | + | kit.post(UPLOAD_URL_PATH) | |
| 15 | + | .json(json!({ | |
| 15 | 16 | "upload_url": "https://s3.example.com/put", | |
| 16 | 17 | "already_exists": false, | |
| 17 | - | }))) | |
| 18 | - | .mount(&server) | |
| 18 | + | })) | |
| 19 | 19 | .await; | |
| 20 | 20 | ||
| 21 | - | let client = authed_client(&server); | |
| 22 | - | let resp = client.blob_upload_url("sha256-abc", 1024).await.unwrap(); | |
| 21 | + | let resp = kit | |
| 22 | + | .authed() | |
| 23 | + | .blob_upload_url("sha256-abc", 1024) | |
| 24 | + | .await | |
| 25 | + | .unwrap(); | |
| 23 | 26 | assert_eq!(resp.upload_url, "https://s3.example.com/put"); | |
| 24 | 27 | assert!(!resp.already_exists); | |
| 25 | 28 | } | |
| @@ -33,25 +36,18 @@ | |||
| 33 | 36 | // halves together, which is the only place the mismatch would show up: | |
| 34 | 37 | // wiremock does not verify signatures, and the server's own tests use an | |
| 35 | 38 | // in-memory backend that does not sign at all. | |
| 36 | - | let server = MockServer::start().await; | |
| 39 | + | let kit = MockKit::start().await; | |
| 37 | 40 | ||
| 38 | 41 | let upload_path = "/s3/sized-upload"; | |
| 39 | - | Mock::given(method("POST")) | |
| 40 | - | .and(path("/api/v1/sync/blobs/upload")) | |
| 41 | - | .respond_with(ResponseTemplate::new(200).set_body_json(json!({ | |
| 42 | - | "upload_url": format!("{}{upload_path}", server.uri()), | |
| 42 | + | kit.post(UPLOAD_URL_PATH) | |
| 43 | + | .json(json!({ | |
| 44 | + | "upload_url": kit.url(upload_path), | |
| 43 | 45 | "already_exists": false, | |
| 44 | - | }))) | |
| 45 | - | .mount(&server) | |
| 46 | - | .await; | |
| 47 | - | Mock::given(method("PUT")) | |
| 48 | - | .and(path(upload_path)) | |
| 49 | - | .respond_with(ResponseTemplate::new(200)) | |
| 50 | - | .mount(&server) | |
| 46 | + | })) | |
| 51 | 47 | .await; | |
| 48 | + | kit.put(upload_path).empty().await; | |
| 52 | 49 | ||
| 53 | - | let client = authed_client(&server); | |
| 54 | - | client.set_master_key_raw(synckit_client::crypto::generate_master_key()); | |
| 50 | + | let (client, _key) = kit.keyed(); | |
| 55 | 51 | ||
| 56 | 52 | // Spans two chunks, so the framing overhead is more than a single chunk's. | |
| 57 | 53 | let plaintext: Vec<u8> = (0..(synckit_client::crypto::BLOB_CHUNK_SIZE + 500)) | |
| @@ -68,19 +64,8 @@ | |||
| 68 | 64 | .await | |
| 69 | 65 | .unwrap(); | |
| 70 | 66 | ||
| 71 | - | let reqs = server.received_requests().await.unwrap(); | |
| 72 | - | let declared: serde_json::Value = reqs | |
| 73 | - | .iter() | |
| 74 | - | .find(|r| r.url.path() == "/api/v1/sync/blobs/upload") | |
| 75 | - | .unwrap() | |
| 76 | - | .body_json() | |
| 77 | - | .unwrap(); | |
| 78 | - | let put_len = reqs | |
| 79 | - | .iter() | |
| 80 | - | .find(|r| r.url.path() == upload_path) | |
| 81 | - | .unwrap() | |
| 82 | - | .body | |
| 83 | - | .len(); | |
| 67 | + | let declared = kit.body(UPLOAD_URL_PATH).await; | |
| 68 | + | let put_len = kit.raw_body(upload_path).await.len(); | |
| 84 | 69 | ||
| 85 | 70 | assert_eq!( | |
| 86 | 71 | declared["size_bytes"].as_u64().unwrap(), | |
| @@ -95,50 +80,33 @@ | |||
| 95 | 80 | ||
| 96 | 81 | #[tokio::test] | |
| 97 | 82 | async fn blob_upload_encrypts_data() { | |
| 98 | - | let server = MockServer::start().await; | |
| 83 | + | let kit = MockKit::start().await; | |
| 99 | 84 | ||
| 100 | 85 | let upload_path = "/s3/upload"; | |
| 101 | - | Mock::given(method("PUT")) | |
| 102 | - | .and(path(upload_path)) | |
| 103 | - | .respond_with(ResponseTemplate::new(200)) | |
| 104 | - | .mount(&server) | |
| 105 | - | .await; | |
| 86 | + | kit.put(upload_path).empty().await; | |
| 106 | 87 | ||
| 107 | - | let client = authed_client(&server); | |
| 108 | - | let key = synckit_client::crypto::generate_master_key(); | |
| 109 | - | client.set_master_key_raw(key); | |
| 88 | + | let (client, _key) = kit.keyed(); | |
| 110 | 89 | ||
| 111 | 90 | let plaintext = b"hello blob data"; | |
| 112 | - | let presigned = format!("{}{}", server.uri(), upload_path); | |
| 113 | 91 | client | |
| 114 | - | .blob_upload("sha256-test", &presigned, plaintext.to_vec()) | |
| 92 | + | .blob_upload("sha256-test", &kit.url(upload_path), plaintext.to_vec()) | |
| 115 | 93 | .await | |
| 116 | 94 | .unwrap(); | |
| 117 | 95 | ||
| 118 | 96 | // Verify uploaded body is encrypted (not plaintext) | |
| 119 | - | let requests = server.received_requests().await.unwrap(); | |
| 120 | - | let upload_req = requests | |
| 121 | - | .iter() | |
| 122 | - | .find(|r| r.url.path() == upload_path) | |
| 123 | - | .unwrap(); | |
| 97 | + | let uploaded = kit.raw_body(upload_path).await; | |
| 124 | 98 | assert!( | |
| 125 | - | !upload_req | |
| 126 | - | .body | |
| 127 | - | .windows(plaintext.len()) | |
| 128 | - | .any(|w| w == plaintext), | |
| 99 | + | !uploaded.windows(plaintext.len()).any(|w| w == plaintext), | |
| 129 | 100 | "Plaintext should not appear in uploaded body" | |
| 130 | 101 | ); | |
| 131 | 102 | // Encrypted blob should be larger due to nonce + tag overhead | |
| 132 | - | assert!(upload_req.body.len() > plaintext.len()); | |
| 103 | + | assert!(uploaded.len() > plaintext.len()); | |
| 133 | 104 | } | |
| 134 | 105 | ||
| 135 | 106 | #[tokio::test] | |
| 136 | 107 | async fn blob_download_decrypts_data() { | |
| 137 | - | let server = MockServer::start().await; | |
| 138 | - | ||
| 139 | - | let client = authed_client(&server); | |
| 140 | - | let key = synckit_client::crypto::generate_master_key(); | |
| 141 | - | client.set_master_key_raw(key); | |
| 108 | + | let kit = MockKit::start().await; | |
| 109 | + | let (client, key) = kit.keyed(); | |
| 142 | 110 | ||
| 143 | 111 | // Encrypt data to simulate what S3 would return. A legacy (untagged) blob | |
| 144 | 112 | // still decrypts through the AAD-aware reader and must pass the hash check. | |
| @@ -147,42 +115,26 @@ | |||
| 147 | 115 | let encrypted = synckit_client::crypto::encrypt_bytes(plaintext, &key).unwrap(); | |
| 148 | 116 | ||
| 149 | 117 | let download_path = "/s3/download"; | |
| 150 | - | Mock::given(method("GET")) | |
| 151 | - | .and(path(download_path)) | |
| 152 | - | .respond_with(ResponseTemplate::new(200).set_body_bytes(encrypted)) | |
| 153 | - | .mount(&server) | |
| 154 | - | .await; | |
| 118 | + | kit.get(download_path).bytes(encrypted).await; | |
| 155 | 119 | ||
| 156 | - | let presigned = format!("{}{}", server.uri(), download_path); | |
| 157 | - | let result = client.blob_download(&hash, &presigned).await.unwrap(); | |
| 120 | + | let result = client | |
| 121 | + | .blob_download(&hash, &kit.url(download_path)) | |
| 122 | + | .await | |
| 123 | + | .unwrap(); | |
| 158 | 124 | assert_eq!(result, plaintext); | |
| 159 | 125 | } | |
| 160 | 126 | ||
| 161 | 127 | #[tokio::test] | |
| 162 | 128 | async fn blob_upload_retries_on_503() { | |
| 163 | - | let server = MockServer::start().await; | |
| 129 | + | let kit = MockKit::start().await; | |
| 164 | 130 | ||
| 165 | 131 | let upload_path = "/s3/retry-upload"; | |
| 166 | - | Mock::given(method("PUT")) | |
| 167 | - | .and(path(upload_path)) | |
| 168 | - | .respond_with(ResponseTemplate::new(503)) | |
| 169 | - | .up_to_n_times(1) | |
| 170 | - | .mount(&server) | |
| 171 | - | .await; | |
| 132 | + | kit.put(upload_path).code(503).once().empty().await; | |
| 133 | + | kit.put(upload_path).empty().await; | |
| 172 | 134 | ||
| 173 | - | Mock::given(method("PUT")) | |
| 174 | - | .and(path(upload_path)) | |
| 175 | - | .respond_with(ResponseTemplate::new(200)) | |
| 176 | - | .mount(&server) | |
| 177 | - | .await; | |
| 178 | - | ||
| 179 | - | let client = authed_client(&server); | |
| 180 | - | let key = synckit_client::crypto::generate_master_key(); | |
| 181 | - | client.set_master_key_raw(key); | |
| 182 | - | ||
| 183 | - | let presigned = format!("{}{}", server.uri(), upload_path); | |
| 135 | + | let (client, _key) = kit.keyed(); | |
| 184 | 136 | let result = client | |
| 185 | - | .blob_upload("sha256-x", &presigned, b"data".to_vec()) | |
| 137 | + | .blob_upload("sha256-x", &kit.url(upload_path), b"data".to_vec()) | |
| 186 | 138 | .await; | |
| 187 | 139 | assert!(result.is_ok(), "Should succeed after retry: {result:?}"); | |
| 188 | 140 | } | |
| @@ -191,34 +143,24 @@ | |||
| 191 | 143 | ||
| 192 | 144 | #[tokio::test] | |
| 193 | 145 | async fn blob_confirm_success() { | |
| 194 | - | let server = MockServer::start().await; | |
| 146 | + | let kit = MockKit::start().await; | |
| 147 | + | kit.post(CONFIRM_PATH).empty().await; | |
| 195 | 148 | ||
| 196 | - | Mock::given(method("POST")) | |
| 197 | - | .and(path("/api/v1/sync/blobs/confirm")) | |
| 198 | - | .respond_with(ResponseTemplate::new(200)) | |
| 199 | - | .mount(&server) | |
| 200 | - | .await; | |
| 201 | - | ||
| 202 | - | let client = authed_client(&server); | |
| 203 | - | client.blob_confirm("sha256-abc", 1024).await.unwrap(); | |
| 149 | + | kit.authed().blob_confirm("sha256-abc", 1024).await.unwrap(); | |
| 204 | 150 | } | |
| 205 | 151 | ||
| 206 | 152 | // ── Blob download URL ── | |
| 207 | 153 | ||
| 208 | 154 | #[tokio::test] | |
| 209 | 155 | async fn blob_download_url_success() { | |
| 210 | - | let server = MockServer::start().await; | |
| 211 | - | ||
| 212 | - | Mock::given(method("POST")) | |
| 213 | - | .and(path("/api/v1/sync/blobs/download")) | |
| 214 | - | .respond_with(ResponseTemplate::new(200).set_body_json(json!({ | |
| 156 | + | let kit = MockKit::start().await; | |
| 157 | + | kit.post("/api/v1/sync/blobs/download") | |
| 158 | + | .json(json!({ | |
| 215 | 159 | "download_url": "https://s3.example.com/get", | |
| 216 | - | }))) | |
| 217 | - | .mount(&server) | |
| 160 | + | })) | |
| 218 | 161 | .await; | |
| 219 | 162 | ||
| 220 | - | let client = authed_client(&server); | |
| 221 | - | let url = client.blob_download_url("sha256-abc").await.unwrap(); | |
| 163 | + | let url = kit.authed().blob_download_url("sha256-abc").await.unwrap(); | |
| 222 | 164 | assert_eq!(url, "https://s3.example.com/get"); | |
| 223 | 165 | } | |
| 224 | 166 | ||
| @@ -226,31 +168,20 @@ | |||
| 226 | 168 | ||
| 227 | 169 | #[tokio::test] | |
| 228 | 170 | async fn blob_upload_zero_byte_data() { | |
| 229 | - | let server = MockServer::start().await; | |
| 171 | + | let kit = MockKit::start().await; | |
| 230 | 172 | ||
| 231 | 173 | let upload_path = "/s3/zero-byte"; | |
| 232 | - | Mock::given(method("PUT")) | |
| 233 | - | .and(path(upload_path)) | |
| 234 | - | .respond_with(ResponseTemplate::new(200)) | |
| 235 | - | .mount(&server) | |
| 174 | + | kit.put(upload_path).empty().await; | |
| 175 | + | ||
| 176 | + | let (client, _key) = kit.keyed(); | |
| 177 | + | let result = client | |
| 178 | + | .blob_upload("sha256-empty", &kit.url(upload_path), vec![]) | |
| 236 | 179 | .await; | |
| 237 | - | ||
| 238 | - | let client = authed_client(&server); | |
| 239 | - | let key = synckit_client::crypto::generate_master_key(); | |
| 240 | - | client.set_master_key_raw(key); | |
| 241 | - | ||
| 242 | - | let presigned = format!("{}{}", server.uri(), upload_path); | |
| 243 | - | let result = client.blob_upload("sha256-empty", &presigned, vec![]).await; | |
| 244 | 180 | assert!(result.is_ok(), "Zero-byte blob upload should succeed"); | |
| 245 | 181 | ||
| 246 | 182 | // Verify the uploaded data is the v3 chunked framing over an empty blob. | |
| 247 | - | let requests = server.received_requests().await.unwrap(); | |
| 248 | - | let req = requests | |
| 249 | - | .iter() | |
| 250 | - | .find(|r| r.url.path() == upload_path) | |
| 251 | - | .unwrap(); | |
| 252 | 183 | assert_eq!( | |
| 253 | - | req.body.len(), | |
| 184 | + | kit.raw_body(upload_path).await.len(), | |
| 254 | 185 | synckit_client::crypto::chunked_blob_overhead(0), | |
| 255 | 186 | "Empty plaintext should produce exactly the chunked overhead bytes" | |
| 256 | 187 | ); | |
| @@ -258,50 +189,29 @@ | |||
| 258 | 189 | ||
| 259 | 190 | #[tokio::test] | |
| 260 | 191 | async fn blob_upload_download_roundtrip() { | |
| 261 | - | let server = MockServer::start().await; | |
| 262 | - | ||
| 263 | - | let client = authed_client(&server); | |
| 264 | - | let key = synckit_client::crypto::generate_master_key(); | |
| 265 | - | client.set_master_key_raw(key); | |
| 192 | + | let kit = MockKit::start().await; | |
| 193 | + | let (client, _key) = kit.keyed(); | |
| 266 | 194 | ||
| 267 | 195 | let plaintext = b"roundtrip blob data with special bytes \x00\xFF\x01"; | |
| 268 | 196 | let hash = hex::encode(sha2::Sha256::digest(plaintext)); | |
| 269 | 197 | ||
| 270 | 198 | // Upload | |
| 271 | 199 | let upload_path = "/s3/roundtrip-upload"; | |
| 272 | - | Mock::given(method("PUT")) | |
| 273 | - | .and(path(upload_path)) | |
| 274 | - | .respond_with(ResponseTemplate::new(200)) | |
| 275 | - | .mount(&server) | |
| 276 | - | .await; | |
| 200 | + | kit.put(upload_path).empty().await; | |
| 277 | 201 | ||
| 278 | 202 | client | |
| 279 | - | .blob_upload( | |
| 280 | - | &hash, | |
| 281 | - | &format!("{}{}", server.uri(), upload_path), | |
| 282 | - | plaintext.to_vec(), | |
| 283 | - | ) | |
| 203 | + | .blob_upload(&hash, &kit.url(upload_path), plaintext.to_vec()) | |
| 284 | 204 | .await | |
| 285 | 205 | .unwrap(); | |
| 286 | 206 | ||
| 287 | - | // Capture what was uploaded | |
| 288 | - | let requests = server.received_requests().await.unwrap(); | |
| 289 | - | let uploaded_body = &requests | |
| 290 | - | .iter() | |
| 291 | - | .find(|r| r.url.path() == upload_path) | |
| 292 | - | .unwrap() | |
| 293 | - | .body; | |
| 294 | - | ||
| 295 | - | // Serve that exact encrypted data back for download | |
| 207 | + | // Serve back exactly what was uploaded | |
| 296 | 208 | let download_path = "/s3/roundtrip-download"; | |
| 297 | - | Mock::given(method("GET")) | |
| 298 | - | .and(path(download_path)) | |
| 299 | - | .respond_with(ResponseTemplate::new(200).set_body_bytes(uploaded_body.clone())) | |
| 300 | - | .mount(&server) | |
| 209 | + | kit.get(download_path) | |
| 210 | + | .bytes(kit.raw_body(upload_path).await) | |
| 301 | 211 | .await; | |
| 302 | 212 | ||
| 303 | 213 | let downloaded = client | |
| 304 | - | .blob_download(&hash, &format!("{}{}", server.uri(), download_path)) | |
| 214 | + | .blob_download(&hash, &kit.url(download_path)) | |
| 305 | 215 | .await | |
| 306 | 216 | .unwrap(); | |
| 307 | 217 | ||
| @@ -312,10 +222,9 @@ | |||
| 312 | 222 | ||
| 313 | 223 | #[tokio::test] | |
| 314 | 224 | async fn blob_upload_url_without_auth_fails() { | |
| 315 | - | let server = MockServer::start().await; | |
| 316 | - | let client = client_for(&server); | |
| 225 | + | let kit = MockKit::start().await; | |
| 317 | 226 | ||
| 318 | - | let result = client.blob_upload_url("hash", 100).await; | |
| 227 | + | let result = kit.client().blob_upload_url("hash", 100).await; | |
| 319 | 228 | match result { | |
| 320 | 229 | Err(SyncKitError::NotAuthenticated) => {} // expected | |
| 321 | 230 | Err(other) => panic!("Expected NotAuthenticated, got: {other:?}"), | |
| @@ -325,19 +234,15 @@ | |||
| 325 | 234 | ||
| 326 | 235 | #[tokio::test] | |
| 327 | 236 | async fn blob_confirm_without_auth_fails() { | |
| 328 | - | let server = MockServer::start().await; | |
| 329 | - | let client = client_for(&server); | |
| 330 | - | ||
| 331 | - | let err = client.blob_confirm("hash", 100).await.unwrap_err(); | |
| 237 | + | let kit = MockKit::start().await; | |
| 238 | + | let err = kit.client().blob_confirm("hash", 100).await.unwrap_err(); | |
| 332 | 239 | assert!(matches!(err, SyncKitError::NotAuthenticated)); | |
| 333 | 240 | } | |
| 334 | 241 | ||
| 335 | 242 | #[tokio::test] | |
| 336 | 243 | async fn blob_download_url_without_auth_fails() { | |
| 337 | - | let server = MockServer::start().await; | |
| 338 | - | let client = client_for(&server); | |
| 339 | - | ||
| 340 | - | let err = client.blob_download_url("hash").await.unwrap_err(); | |
| 244 | + | let kit = MockKit::start().await; | |
| 245 | + | let err = kit.client().blob_download_url("hash").await.unwrap_err(); | |
| 341 | 246 | assert!(matches!(err, SyncKitError::NotAuthenticated)); | |
| 342 | 247 | } | |
| 343 | 248 | ||
| @@ -345,28 +250,26 @@ | |||
| 345 | 250 | ||
| 346 | 251 | #[tokio::test] | |
| 347 | 252 | async fn blob_download_with_wrong_key_fails() { | |
| 348 | - | let server = MockServer::start().await; | |
| 253 | + | let kit = MockKit::start().await; | |
| 349 | 254 | ||
| 350 | 255 | let key1 = synckit_client::crypto::generate_master_key(); | |
| 351 | - | let key2 = synckit_client::crypto::generate_master_key(); | |
| 352 | 256 | ||
| 353 | 257 | // Encrypt with key1 | |
| 354 | 258 | let plaintext = b"encrypted with key1"; | |
| 355 | 259 | let encrypted = synckit_client::crypto::encrypt_bytes(plaintext, &key1).unwrap(); | |
| 356 | 260 | ||
| 357 | 261 | let download_path = "/s3/wrong-key"; | |
| 358 | - | Mock::given(method("GET")) | |
| 359 | - | .and(path(download_path)) | |
| 360 | - | .respond_with(ResponseTemplate::new(200).set_body_bytes(encrypted)) | |
| 361 | - | .mount(&server) | |
| 362 | - | .await; | |
| 262 | + | kit.get(download_path).bytes(encrypted).await; | |
| 363 | 263 | ||
| 364 | - | // Client has key2 (wrong key) | |
| 365 | - | let client = authed_client(&server); | |
| 366 | - | client.set_master_key_raw(key2); | |
| 264 | + | // The client holds a different key. | |
| 265 | + | let (client, key2) = kit.keyed(); | |
| 266 | + | assert_ne!( | |
| 267 | + | key1, key2, | |
| 268 | + | "the two keys must differ for this to test anything" | |
| 269 | + | ); | |
| 367 | 270 | ||
| 368 | 271 | let result = client | |
| 369 | - | .blob_download("sha256-x", &format!("{}{}", server.uri(), download_path)) | |
| 272 | + | .blob_download("sha256-x", &kit.url(download_path)) | |
| 370 | 273 | .await; | |
| 371 | 274 | ||
| 372 | 275 | assert!( | |
| @@ -383,86 +286,55 @@ | |||
| 383 | 286 | ||
| 384 | 287 | #[tokio::test] | |
| 385 | 288 | async fn blob_confirm_retries_on_503() { | |
| 386 | - | let server = MockServer::start().await; | |
| 387 | - | ||
| 388 | - | Mock::given(method("POST")) | |
| 389 | - | .and(path("/api/v1/sync/blobs/confirm")) | |
| 390 | - | .respond_with(ResponseTemplate::new(503).set_body_string("Service Unavailable")) | |
| 391 | - | .up_to_n_times(1) | |
| 392 | - | .mount(&server) | |
| 289 | + | let kit = MockKit::start().await; | |
| 290 | + | kit.post(CONFIRM_PATH) | |
| 291 | + | .code(503) | |
| 292 | + | .once() | |
| 293 | + | .text("Service Unavailable") | |
| 393 | 294 | .await; | |
| 295 | + | kit.post(CONFIRM_PATH).empty().await; | |
| 394 | 296 | ||
| 395 | - | Mock::given(method("POST")) | |
| 396 | - | .and(path("/api/v1/sync/blobs/confirm")) | |
| 397 | - | .respond_with(ResponseTemplate::new(200)) | |
| 398 | - | .mount(&server) | |
| 399 | - | .await; | |
| 400 | - | ||
| 401 | - | let client = authed_client(&server); | |
| 402 | - | let result = client.blob_confirm("sha256-retry", 512).await; | |
| 297 | + | let result = kit.authed().blob_confirm("sha256-retry", 512).await; | |
| 403 | 298 | assert!(result.is_ok(), "Should succeed after retry: {result:?}"); | |
| 404 | 299 | } | |
| 405 | 300 | ||
| 406 | 301 | #[tokio::test] | |
| 407 | 302 | async fn blob_download_retries_on_503() { | |
| 408 | - | let server = MockServer::start().await; | |
| 409 | - | ||
| 410 | - | let client = authed_client(&server); | |
| 411 | - | let key = synckit_client::crypto::generate_master_key(); | |
| 412 | - | client.set_master_key_raw(key); | |
| 303 | + | let kit = MockKit::start().await; | |
| 304 | + | let (client, key) = kit.keyed(); | |
| 413 | 305 | ||
| 414 | 306 | let plaintext = b"retry download test"; | |
| 415 | 307 | let hash = hex::encode(sha2::Sha256::digest(plaintext)); | |
| 416 | 308 | let encrypted = synckit_client::crypto::encrypt_bytes(plaintext, &key).unwrap(); | |
| 417 | 309 | ||
| 418 | 310 | let download_path = "/s3/retry-download"; | |
| 419 | - | Mock::given(method("GET")) | |
| 420 | - | .and(path(download_path)) | |
| 421 | - | .respond_with(ResponseTemplate::new(503)) | |
| 422 | - | .up_to_n_times(1) | |
| 423 | - | .mount(&server) | |
| 424 | - | .await; | |
| 311 | + | kit.get(download_path).code(503).once().empty().await; | |
| 312 | + | kit.get(download_path).bytes(encrypted).await; | |
| 425 | 313 | ||
| 426 | - | Mock::given(method("GET")) | |
| 427 | - | .and(path(download_path)) | |
| 428 | - | .respond_with(ResponseTemplate::new(200).set_body_bytes(encrypted)) | |
| 429 | - | .mount(&server) | |
| 430 | - | .await; | |
| 431 | - | ||
| 432 | - | let presigned = format!("{}{}", server.uri(), download_path); | |
| 433 | - | let result = client.blob_download(&hash, &presigned).await.unwrap(); | |
| 314 | + | let result = client | |
| 315 | + | .blob_download(&hash, &kit.url(download_path)) | |
| 316 | + | .await | |
| 317 | + | .unwrap(); | |
| 434 | 318 | assert_eq!(result, plaintext); | |
| 435 | 319 | } | |
| 436 | 320 | ||
| 437 | 321 | #[tokio::test] | |
| 438 | 322 | async fn blob_upload_1mb_with_correct_overhead() { | |
| 439 | - | let server = MockServer::start().await; | |
| 323 | + | let kit = MockKit::start().await; | |
| 440 | 324 | ||
| 441 | 325 | let upload_path = "/s3/1mb-upload"; | |
| 442 | - | Mock::given(method("PUT")) | |
| 443 | - | .and(path(upload_path)) | |
| 444 | - | .respond_with(ResponseTemplate::new(200)) | |
| 445 | - | .mount(&server) | |
| 446 | - | .await; | |
| 326 | + | kit.put(upload_path).empty().await; | |
| 447 | 327 | ||
| 448 | - | let client = authed_client(&server); | |
| 449 | - | let key = synckit_client::crypto::generate_master_key(); | |
| 450 | - | client.set_master_key_raw(key); | |
| 328 | + | let (client, _key) = kit.keyed(); | |
| 451 | 329 | ||
| 452 | 330 | let plaintext: Vec<u8> = (0..1_048_576u32).map(|i| (i % 256) as u8).collect(); | |
| 453 | - | let presigned = format!("{}{}", server.uri(), upload_path); | |
| 454 | 331 | client | |
| 455 | - | .blob_upload("sha256-1mb", &presigned, plaintext.clone()) |
Lines truncated
| @@ -66,52 +66,36 @@ | |||
| 66 | 66 | ||
| 67 | 67 | /// Mount the whole session: start (with the given plan), part-URL minting, | |
| 68 | 68 | /// the PUT target, and complete. | |
| 69 | - | async fn mount_session(server: &MockServer, cipher_len: usize, part_size: usize) -> u32 { | |
| 69 | + | async fn mount_session(kit: &MockKit, cipher_len: usize, part_size: usize) -> u32 { | |
| 70 | 70 | let part_count = cipher_len.div_ceil(part_size) as u32; | |
| 71 | 71 | ||
| 72 | - | Mock::given(method("POST")) | |
| 73 | - | .and(path(START_PATH)) | |
| 74 | - | .respond_with(ResponseTemplate::new(200).set_body_json(json!({ | |
| 72 | + | kit.post(START_PATH) | |
| 73 | + | .json(json!({ | |
| 75 | 74 | "upload_id": "test-upload-id", | |
| 76 | 75 | "part_size": part_size, | |
| 77 | 76 | "part_count": part_count, | |
| 78 | 77 | "already_exists": false, | |
| 79 | - | }))) | |
| 80 | - | .mount(server) | |
| 78 | + | })) | |
| 81 | 79 | .await; | |
| 82 | - | Mock::given(method("POST")) | |
| 83 | - | .and(path(PARTS_PATH)) | |
| 84 | - | .respond_with(PartsResponder { | |
| 80 | + | kit.post(PARTS_PATH) | |
| 81 | + | .responder(PartsResponder { | |
| 85 | 82 | cipher_len, | |
| 86 | 83 | part_size, | |
| 87 | - | base: server.uri(), | |
| 84 | + | base: kit.uri(), | |
| 88 | 85 | }) | |
| 89 | - | .mount(server) | |
| 90 | 86 | .await; | |
| 91 | - | Mock::given(method("PUT")) | |
| 92 | - | .and(path(PART_PUT_PATH)) | |
| 93 | - | .respond_with(ResponseTemplate::new(200).append_header("ETag", "\"part-etag\"")) | |
| 94 | - | .mount(server) | |
| 95 | - | .await; | |
| 96 | - | Mock::given(method("POST")) | |
| 97 | - | .and(path(COMPLETE_PATH)) | |
| 98 | - | .respond_with(ResponseTemplate::new(204)) | |
| 99 | - | .mount(server) | |
| 87 | + | kit.put(PART_PUT_PATH) | |
| 88 | + | .reply(ResponseTemplate::new(200).append_header("ETag", "\"part-etag\"")) | |
| 100 | 89 | .await; | |
| 90 | + | kit.post(COMPLETE_PATH).code(204).empty().await; | |
| 101 | 91 | ||
| 102 | 92 | part_count | |
| 103 | 93 | } | |
| 104 | 94 | ||
| 105 | - | fn hits(reqs: &[wiremock::Request], p: &str) -> usize { | |
| 106 | - | reqs.iter().filter(|r| r.url.path() == p).count() | |
| 107 | - | } | |
| 108 | - | ||
| 109 | 95 | #[tokio::test] | |
| 110 | 96 | async fn streaming_upload_tiles_the_parts_into_a_valid_blob() { | |
| 111 | - | let server = MockServer::start().await; | |
| 112 | - | let client = authed_client(&server); | |
| 113 | - | let key = synckit_client::crypto::generate_master_key(); | |
| 114 | - | client.set_master_key_raw(key); | |
| 97 | + | let kit = MockKit::start().await; | |
| 98 | + | let (client, key) = kit.keyed(); | |
| 115 | 99 | ||
| 116 | 100 | // Spans four 1 MiB chunks (three full plus a remainder), so sealed | |
| 117 | 101 | // chunks straddle part boundaries rather than lining up with them. | |
| @@ -123,28 +107,18 @@ | |||
| 123 | 107 | ||
| 124 | 108 | let cipher_len = synckit_client::crypto::blob_encrypted_len(plaintext.len()); | |
| 125 | 109 | let part_size = 1024 * 1024; | |
| 126 | - | let part_count = mount_session(&server, cipher_len, part_size).await; | |
| 110 | + | let part_count = mount_session(&kit, cipher_len, part_size).await; | |
| 127 | 111 | assert!(part_count > 1, "the fixture must actually be multipart"); | |
| 128 | 112 | ||
| 129 | 113 | client.blob_upload_streaming(&hash, &file).await.unwrap(); | |
| 130 | 114 | ||
| 131 | - | let reqs = server.received_requests().await.unwrap(); | |
| 132 | - | ||
| 133 | 115 | // The session was sized in ciphertext, predicted from the plaintext. | |
| 134 | - | let start: serde_json::Value = reqs | |
| 135 | - | .iter() | |
| 136 | - | .find(|r| r.url.path() == START_PATH) | |
| 137 | - | .unwrap() | |
| 138 | - | .body_json() | |
| 139 | - | .unwrap(); | |
| 116 | + | let start = kit.body(START_PATH).await; | |
| 140 | 117 | assert_eq!(start["size_bytes"].as_u64().unwrap(), cipher_len as u64); | |
| 141 | 118 | assert_eq!(start["hash"].as_str().unwrap(), hash); | |
| 142 | 119 | ||
| 143 | 120 | // Every part carried exactly the length the server signed for it. | |
| 144 | - | let puts: Vec<&wiremock::Request> = reqs | |
| 145 | - | .iter() | |
| 146 | - | .filter(|r| r.url.path() == PART_PUT_PATH) | |
| 147 | - | .collect(); | |
| 121 | + | let puts = kit.requests_to(PART_PUT_PATH).await; | |
| 148 | 122 | assert_eq!(puts.len() as u32, part_count, "one PUT per planned part"); | |
| 149 | 123 | for (i, put) in puts.iter().enumerate() { | |
| 150 | 124 | let expected = if i as u32 == part_count - 1 { | |
| @@ -159,15 +133,13 @@ | |||
| 159 | 133 | // part then carried, which is what S3 rehashes against at write time. | |
| 160 | 134 | // The pairing is what matters: a checksum bound to the wrong part is | |
| 161 | 135 | // worse than none, since it would reject a correct upload. | |
| 162 | - | let part_reqs: Vec<&wiremock::Request> = | |
| 163 | - | reqs.iter().filter(|r| r.url.path() == PARTS_PATH).collect(); | |
| 136 | + | let part_reqs = kit.bodies("POST", PARTS_PATH).await; | |
| 164 | 137 | assert_eq!( | |
| 165 | 138 | part_reqs.len() as u32, | |
| 166 | 139 | part_count, | |
| 167 | 140 | "one URL request per part: a digest exists only once the part is sealed" | |
| 168 | 141 | ); | |
| 169 | - | for (i, req) in part_reqs.iter().enumerate() { | |
| 170 | - | let body: serde_json::Value = req.body_json().unwrap(); | |
| 142 | + | for (i, body) in part_reqs.iter().enumerate() { | |
| 171 | 143 | assert_eq!(body["first_part"].as_u64().unwrap(), i as u64 + 1); | |
| 172 | 144 | assert_eq!(body["count"].as_u64().unwrap(), 1); | |
| 173 | 145 | let declared = body["checksums"][0].as_str().unwrap(); | |
| @@ -201,29 +173,26 @@ | |||
| 201 | 173 | assert_eq!(decrypted, plaintext, "streamed blob must round-trip"); | |
| 202 | 174 | ||
| 203 | 175 | // Complete named every part, in order, with the ETag S3 returned. | |
| 204 | - | let complete: serde_json::Value = reqs | |
| 205 | - | .iter() | |
| 206 | - | .find(|r| r.url.path() == COMPLETE_PATH) | |
| 207 | - | .unwrap() | |
| 208 | - | .body_json() | |
| 209 | - | .unwrap(); | |
| 176 | + | let complete = kit.body(COMPLETE_PATH).await; | |
| 210 | 177 | let named = complete["parts"].as_array().unwrap(); | |
| 211 | 178 | assert_eq!(named.len() as u32, part_count); | |
| 212 | 179 | for (i, part) in named.iter().enumerate() { | |
| 213 | 180 | assert_eq!(part["part_number"].as_u64().unwrap(), i as u64 + 1); | |
| 214 | 181 | assert_eq!(part["etag"].as_str().unwrap(), "\"part-etag\""); | |
| 215 | 182 | } | |
| 216 | - | assert_eq!(hits(&reqs, ABORT_PATH), 0, "a clean upload must not abort"); | |
| 183 | + | assert_eq!( | |
| 184 | + | kit.hits(ABORT_PATH).await, | |
| 185 | + | 0, | |
| 186 | + | "a clean upload must not abort" | |
| 187 | + | ); | |
| 217 | 188 | ||
| 218 | 189 | std::fs::remove_file(&file).ok(); | |
| 219 | 190 | } | |
| 220 | 191 | ||
| 221 | 192 | #[tokio::test] | |
| 222 | 193 | async fn streaming_upload_rejects_a_server_part_plan_that_lies_about_geometry() { | |
| 223 | - | let server = MockServer::start().await; | |
| 224 | - | let client = authed_client(&server); | |
| 225 | - | let key = synckit_client::crypto::generate_master_key(); | |
| 226 | - | client.set_master_key_raw(key); | |
| 194 | + | let kit = MockKit::start().await; | |
| 195 | + | let (client, _key) = kit.keyed(); | |
| 227 | 196 | ||
| 228 | 197 | // A blob that genuinely spans several 1 MiB parts. | |
| 229 | 198 | let plaintext: Vec<u8> = (0..(synckit_client::crypto::BLOB_CHUNK_SIZE * 3 + 7)) | |
| @@ -235,15 +204,13 @@ | |||
| 235 | 204 | // Hostile server: claims the whole multi-part blob fits in ONE part. | |
| 236 | 205 | // Trusting it would defeat the one-part-in-memory bound, so the client | |
| 237 | 206 | // must refuse before minting or PUTting anything. | |
| 238 | - | Mock::given(method("POST")) | |
| 239 | - | .and(path(START_PATH)) | |
| 240 | - | .respond_with(ResponseTemplate::new(200).set_body_json(json!({ | |
| 207 | + | kit.post(START_PATH) | |
| 208 | + | .json(json!({ | |
| 241 | 209 | "upload_id": "test-upload-id", | |
| 242 | 210 | "part_size": 1024 * 1024, | |
| 243 | 211 | "part_count": 1, | |
| 244 | 212 | "already_exists": false, | |
| 245 | - | }))) | |
| 246 | - | .mount(&server) | |
| 213 | + | })) | |
| 247 | 214 | .await; | |
| 248 | 215 | ||
| 249 | 216 | let err = client | |
| @@ -254,9 +221,8 @@ | |||
| 254 | 221 | matches!(err, SyncKitError::Internal(ref m) if m.contains("does not match")), | |
| 255 | 222 | "expected a geometry-mismatch rejection, got {err:?}" | |
| 256 | 223 | ); | |
| 257 | - | let reqs = server.received_requests().await.unwrap(); | |
| 258 | 224 | assert_eq!( | |
| 259 | - | hits(&reqs, PART_PUT_PATH), | |
| 225 | + | kit.hits(PART_PUT_PATH).await, | |
| 260 | 226 | 0, | |
| 261 | 227 | "no part may be uploaded once the plan is rejected" | |
| 262 | 228 | ); | |
| @@ -266,27 +232,20 @@ | |||
| 266 | 232 | ||
| 267 | 233 | #[tokio::test] | |
| 268 | 234 | async fn streaming_upload_handles_an_empty_file() { | |
| 269 | - | let server = MockServer::start().await; | |
| 270 | - | let client = authed_client(&server); | |
| 271 | - | let key = synckit_client::crypto::generate_master_key(); | |
| 272 | - | client.set_master_key_raw(key); | |
| 235 | + | let kit = MockKit::start().await; | |
| 236 | + | let (client, key) = kit.keyed(); | |
| 273 | 237 | ||
| 274 | 238 | let hash = hex::encode(sha2::Sha256::digest(b"")); | |
| 275 | 239 | let file = temp_blob("empty.bin", b""); | |
| 276 | 240 | let cipher_len = synckit_client::crypto::blob_encrypted_len(0); | |
| 277 | - | mount_session(&server, cipher_len, 1024 * 1024).await; | |
| 241 | + | mount_session(&kit, cipher_len, 1024 * 1024).await; | |
| 278 | 242 | ||
| 279 | 243 | client.blob_upload_streaming(&hash, &file).await.unwrap(); | |
| 280 | 244 | ||
| 281 | - | let reqs = server.received_requests().await.unwrap(); | |
| 282 | - | let put = reqs.iter().find(|r| r.url.path() == PART_PUT_PATH).unwrap(); | |
| 245 | + | let put = kit.raw_body(PART_PUT_PATH).await; | |
| 246 | + | assert_eq!(put.len(), cipher_len, "one part carries the whole blob"); | |
| 283 | 247 | assert_eq!( | |
| 284 | - | put.body.len(), | |
| 285 | - | cipher_len, | |
| 286 | - | "one part carries the whole blob" | |
| 287 | - | ); | |
| 288 | - | assert_eq!( | |
| 289 | - | synckit_client::crypto::decrypt_blob_chunked(&put.body, &key, &hash).unwrap(), | |
| 248 | + | synckit_client::crypto::decrypt_blob_chunked(&put, &key, &hash).unwrap(), | |
| 290 | 249 | Vec::<u8>::new(), | |
| 291 | 250 | "an empty blob is still an authenticated single chunk" | |
| 292 | 251 | ); | |
| @@ -296,19 +255,16 @@ | |||
| 296 | 255 | ||
| 297 | 256 | #[tokio::test] | |
| 298 | 257 | async fn streaming_upload_skips_when_the_server_already_has_the_content() { | |
| 299 | - | let server = MockServer::start().await; | |
| 300 | - | let client = authed_client(&server); | |
| 301 | - | client.set_master_key_raw(synckit_client::crypto::generate_master_key()); | |
| 258 | + | let kit = MockKit::start().await; | |
| 259 | + | let (client, _key) = kit.keyed(); | |
| 302 | 260 | ||
| 303 | - | Mock::given(method("POST")) | |
| 304 | - | .and(path(START_PATH)) | |
| 305 | - | .respond_with(ResponseTemplate::new(200).set_body_json(json!({ | |
| 261 | + | kit.post(START_PATH) | |
| 262 | + | .json(json!({ | |
| 306 | 263 | "upload_id": "", | |
| 307 | 264 | "part_size": 0, | |
| 308 | 265 | "part_count": 0, | |
| 309 | 266 | "already_exists": true, | |
| 310 | - | }))) | |
| 311 | - | .mount(&server) | |
| 267 | + | })) | |
| 312 | 268 | .await; | |
| 313 | 269 | ||
| 314 | 270 | let plaintext = b"content the server already holds"; | |
| @@ -319,10 +275,13 @@ | |||
| 319 | 275 | ||
| 320 | 276 | // Dedup must cost nothing: no file bytes read out to the wire, no | |
| 321 | 277 | // session to clean up. | |
| 322 | - | let reqs = server.received_requests().await.unwrap(); | |
| 323 | - | assert_eq!(hits(&reqs, PART_PUT_PATH), 0, "dedup must not upload parts"); | |
| 324 | - | assert_eq!(hits(&reqs, COMPLETE_PATH), 0); | |
| 325 | - | assert_eq!(hits(&reqs, ABORT_PATH), 0); | |
| 278 | + | assert_eq!( | |
| 279 | + | kit.hits(PART_PUT_PATH).await, | |
| 280 | + | 0, | |
| 281 | + | "dedup must not upload parts" | |
| 282 | + | ); | |
| 283 | + | assert_eq!(kit.hits(COMPLETE_PATH).await, 0); | |
| 284 | + | assert_eq!(kit.hits(ABORT_PATH).await, 0); | |
| 326 | 285 | ||
| 327 | 286 | std::fs::remove_file(&file).ok(); | |
| 328 | 287 | } | |
| @@ -333,21 +292,16 @@ | |||
| 333 | 292 | // storing it under the stale content address would poison the address: | |
| 334 | 293 | // every later download would re-hash and reject it. Fail here instead, | |
| 335 | 294 | // and release the parts. | |
| 336 | - | let server = MockServer::start().await; | |
| 337 | - | let client = authed_client(&server); | |
| 338 | - | client.set_master_key_raw(synckit_client::crypto::generate_master_key()); | |
| 295 | + | let kit = MockKit::start().await; | |
| 296 | + | let (client, _key) = kit.keyed(); | |
| 339 | 297 | ||
| 340 | 298 | let plaintext = b"the bytes actually on disk"; | |
| 341 | 299 | let stale_hash = hex::encode(sha2::Sha256::digest(b"what the caller hashed earlier")); | |
| 342 | 300 | let file = temp_blob("changed.bin", plaintext); | |
| 343 | 301 | ||
| 344 | 302 | let cipher_len = synckit_client::crypto::blob_encrypted_len(plaintext.len()); | |
| 345 | - | mount_session(&server, cipher_len, 1024 * 1024).await; | |
| 346 | - | Mock::given(method("POST")) | |
| 347 | - | .and(path(ABORT_PATH)) | |
| 348 | - | .respond_with(ResponseTemplate::new(204)) | |
| 349 | - | .mount(&server) | |
| 350 | - | .await; | |
| 303 | + | mount_session(&kit, cipher_len, 1024 * 1024).await; | |
| 304 | + | kit.post(ABORT_PATH).code(204).empty().await; | |
| 351 | 305 | ||
| 352 | 306 | let err = client | |
| 353 | 307 | .blob_upload_streaming(&stale_hash, &file) | |
| @@ -358,13 +312,16 @@ | |||
| 358 | 312 | "expected IntegrityFailed, got {err:?}" | |
| 359 | 313 | ); | |
| 360 | 314 | ||
| 361 | - | let reqs = server.received_requests().await.unwrap(); | |
| 362 | 315 | assert_eq!( | |
| 363 | - | hits(&reqs, COMPLETE_PATH), | |
| 316 | + | kit.hits(COMPLETE_PATH).await, | |
| 364 | 317 | 0, | |
| 365 | 318 | "a mismatched blob must not be assembled" | |
| 366 | 319 | ); | |
| 367 | - | assert_eq!(hits(&reqs, ABORT_PATH), 1, "the session must be released"); | |
| 320 | + | assert_eq!( | |
| 321 | + | kit.hits(ABORT_PATH).await, | |
| 322 | + | 1, | |
| 323 | + | "the session must be released" | |
| 324 | + | ); | |
| 368 | 325 | ||
| 369 | 326 | std::fs::remove_file(&file).ok(); | |
| 370 | 327 | } | |
| @@ -373,46 +330,33 @@ | |||
| 373 | 330 | async fn streaming_upload_aborts_when_a_part_upload_fails() { | |
| 374 | 331 | // Parts already sent are billed until the session is aborted, so any | |
| 375 | 332 | // failure past `start` has to release it. | |
| 376 | - | let server = MockServer::start().await; | |
| 377 | - | let client = authed_client(&server); | |
| 378 | - | client.set_master_key_raw(synckit_client::crypto::generate_master_key()); | |
| 333 | + | let kit = MockKit::start().await; | |
| 334 | + | let (client, _key) = kit.keyed(); | |
| 379 | 335 | ||
| 380 | 336 | let plaintext = b"a blob whose part upload will fail"; | |
| 381 | 337 | let hash = hex::encode(sha2::Sha256::digest(plaintext)); | |
| 382 | 338 | let file = temp_blob("failing.bin", plaintext); | |
| 383 | 339 | let cipher_len = synckit_client::crypto::blob_encrypted_len(plaintext.len()); | |
| 384 | 340 | ||
| 385 | - | Mock::given(method("POST")) | |
| 386 | - | .and(path(START_PATH)) | |
| 387 | - | .respond_with(ResponseTemplate::new(200).set_body_json(json!({ | |
| 341 | + | kit.post(START_PATH) | |
| 342 | + | .json(json!({ | |
| 388 | 343 | "upload_id": "test-upload-id", | |
| 389 | 344 | "part_size": cipher_len, | |
| 390 | 345 | "part_count": 1, | |
| 391 | 346 | "already_exists": false, | |
| 392 | - | }))) | |
| 393 | - | .mount(&server) | |
| 347 | + | })) | |
| 394 | 348 | .await; | |
| 395 | - | Mock::given(method("POST")) | |
| 396 | - | .and(path(PARTS_PATH)) | |
| 397 | - | .respond_with(ResponseTemplate::new(200).set_body_json(json!({ | |
| 349 | + | kit.post(PARTS_PATH) | |
| 350 | + | .json(json!({ | |
| 398 | 351 | "parts": [{ | |
| 399 | 352 | "part_number": 1, | |
| 400 | 353 | "content_length": cipher_len, | |
| 401 | - | "url": format!("{}{PART_PUT_PATH}", server.uri()), | |
| 354 | + | "url": kit.url(PART_PUT_PATH), | |
| 402 | 355 | }] | |
| 403 | - | }))) | |
| 404 | - | .mount(&server) | |
| 405 | - | .await; | |
| 406 | - | Mock::given(method("PUT")) | |
| 407 | - | .and(path(PART_PUT_PATH)) | |
| 408 | - | .respond_with(ResponseTemplate::new(403)) | |
| 409 | - | .mount(&server) | |
| 410 | - | .await; | |
| 411 | - | Mock::given(method("POST")) | |
| 412 | - | .and(path(ABORT_PATH)) | |
| 413 | - | .respond_with(ResponseTemplate::new(204)) | |
| 414 | - | .mount(&server) | |
| 356 | + | })) | |
| 415 | 357 | .await; | |
| 358 | + | kit.put(PART_PUT_PATH).code(403).empty().await; | |
| 359 | + | kit.post(ABORT_PATH).code(204).empty().await; | |
| 416 | 360 | ||
| 417 | 361 | let err = client | |
| 418 | 362 | .blob_upload_streaming(&hash, &file) | |
| @@ -423,10 +367,9 @@ | |||
| 423 | 367 | "got {err:?}" | |
| 424 | 368 | ); | |
| 425 | 369 | ||
| 426 | - | let reqs = server.received_requests().await.unwrap(); | |
| 427 | - | assert_eq!(hits(&reqs, COMPLETE_PATH), 0); | |
| 370 | + | assert_eq!(kit.hits(COMPLETE_PATH).await, 0); | |
| 428 | 371 | assert_eq!( | |
| 429 | - | hits(&reqs, ABORT_PATH), | |
| 372 | + | kit.hits(ABORT_PATH).await, | |
| 430 | 373 | 1, | |
| 431 | 374 | "a failed transfer must release its parts" | |
| 432 | 375 | ); |
| @@ -1,9 +1,12 @@ | |||
| 1 | - | //! Shared fixtures for the integration suite: the wiremock server, a client | |
| 2 | - | //! wired to it, and the JSON bodies the SyncKit server would return. | |
| 1 | + | //! Shared fixtures for the integration suite: the JSON bodies the SyncKit server | |
| 2 | + | //! would return, and the imports every module needs. | |
| 3 | 3 | //! | |
| 4 | 4 | //! Every test module imports this with `use crate::common::*;`. A helper earns a | |
| 5 | 5 | //! place here when a second module wants it; a fixture only one module uses stays | |
| 6 | 6 | //! in that module. | |
| 7 | + | //! | |
| 8 | + | //! The server and the clients live next door in [`mockkit`](crate::mockkit), | |
| 9 | + | //! re-exported below so the one glob import still reaches everything. | |
| 7 | 10 | ||
| 8 | 11 | pub(crate) use base64::Engine as _; | |
| 9 | 12 | pub(crate) use chrono::Utc; | |
| @@ -12,8 +15,9 @@ | |||
| 12 | 15 | pub(crate) use std::sync::Arc; | |
| 13 | 16 | pub(crate) use std::time::Duration; | |
| 14 | 17 | pub(crate) use uuid::Uuid; | |
| 15 | - | pub(crate) use wiremock::matchers::{method, path}; | |
| 16 | - | pub(crate) use wiremock::{Mock, MockServer, ResponseTemplate}; | |
| 18 | + | pub(crate) use wiremock::ResponseTemplate; | |
| 19 | + | ||
| 20 | + | pub(crate) use crate::mockkit::MockKit; | |
| 17 | 21 | ||
| 18 | 22 | pub(crate) use synckit_client::{ | |
| 19 | 23 | AppId, ChangeEntry, ChangeOp, DeviceId, Hlc, SyncKitClient, SyncKitConfig, SyncKitError, UserId, | |
| @@ -56,21 +60,6 @@ | |||
| 56 | 60 | }); | |
| 57 | 61 | } | |
| 58 | 62 | ||
| 59 | - | pub(crate) fn client_for(server: &MockServer) -> SyncKitClient { | |
| 60 | - | ensure_crypto_provider(); | |
| 61 | - | SyncKitClient::new(SyncKitConfig { | |
| 62 | - | server_url: server.uri(), | |
| 63 | - | api_key: "test-api-key".to_string(), | |
| 64 | - | }) | |
| 65 | - | } | |
| 66 | - | ||
| 67 | - | pub(crate) fn authed_client(server: &MockServer) -> SyncKitClient { | |
| 68 | - | let client = client_for(server); | |
| 69 | - | let (user_id, app_id) = test_ids(); | |
| 70 | - | client.restore_session(&fresh_token(), user_id, app_id); | |
| 71 | - | client | |
| 72 | - | } | |
| 73 | - | ||
| 74 | 63 | pub(crate) fn auth_response_json() -> serde_json::Value { | |
| 75 | 64 | let (user_id, app_id) = test_ids(); | |
| 76 | 65 | json!({ |
| @@ -4,32 +4,26 @@ | |||
| 4 | 4 | ||
| 5 | 5 | use crate::common::*; | |
| 6 | 6 | ||
| 7 | + | const PUSH_PATH: &str = "/api/v1/sync/push"; | |
| 8 | + | const PULL_PATH: &str = "/api/v1/sync/pull"; | |
| 9 | + | ||
| 7 | 10 | // ── Concurrent access ── | |
| 8 | 11 | ||
| 9 | 12 | #[tokio::test] | |
| 10 | 13 | async fn concurrent_push_pull_no_panics() { | |
| 11 | - | let server = MockServer::start().await; | |
| 12 | - | ||
| 13 | - | Mock::given(method("POST")) | |
| 14 | - | .and(path("/api/v1/sync/push")) | |
| 15 | - | .respond_with(ResponseTemplate::new(200).set_body_json(json!({"cursor": 1}))) | |
| 16 | - | .mount(&server) | |
| 17 | - | .await; | |
| 18 | - | ||
| 19 | - | let device_id = DeviceId::new(Uuid::new_v4()); | |
| 20 | - | Mock::given(method("POST")) | |
| 21 | - | .and(path("/api/v1/sync/pull")) | |
| 22 | - | .respond_with(ResponseTemplate::new(200).set_body_json(json!({ | |
| 14 | + | let kit = MockKit::start().await; | |
| 15 | + | kit.post(PUSH_PATH).json(json!({"cursor": 1})).await; | |
| 16 | + | kit.post(PULL_PATH) | |
| 17 | + | .json(json!({ | |
| 23 | 18 | "changes": [], | |
| 24 | 19 | "cursor": 0, | |
| 25 | 20 | "has_more": false, | |
| 26 | - | }))) | |
| 27 | - | .mount(&server) | |
| 21 | + | })) | |
| 28 | 22 | .await; | |
| 29 | 23 | ||
| 30 | - | let client = Arc::new(authed_client(&server)); | |
| 31 | - | let key = synckit_client::crypto::generate_master_key(); | |
| 32 | - | client.set_master_key_raw(key); | |
| 24 | + | let device_id = DeviceId::new(Uuid::new_v4()); | |
| 25 | + | let (client, _key) = kit.keyed(); | |
| 26 | + | let client = Arc::new(client); | |
| 33 | 27 | ||
| 34 | 28 | let mut handles = Vec::new(); | |
| 35 | 29 | for _ in 0..4 { | |
| @@ -50,17 +44,11 @@ | |||
| 50 | 44 | ||
| 51 | 45 | #[tokio::test] | |
| 52 | 46 | async fn concurrent_push_operations_no_data_corruption() { | |
| 53 | - | let server = MockServer::start().await; | |
| 47 | + | let kit = MockKit::start().await; | |
| 48 | + | kit.post(PUSH_PATH).json(json!({"cursor": 1})).await; | |
| 54 | 49 | ||
| 55 | - | Mock::given(method("POST")) | |
| 56 | - | .and(path("/api/v1/sync/push")) | |
| 57 | - | .respond_with(ResponseTemplate::new(200).set_body_json(json!({"cursor": 1}))) | |
| 58 | - | .mount(&server) | |
| 59 | - | .await; | |
| 60 | - | ||
| 61 | - | let client = Arc::new(authed_client(&server)); | |
| 62 | - | let key = synckit_client::crypto::generate_master_key(); | |
| 63 | - | client.set_master_key_raw(key); | |
| 50 | + | let (client, _key) = kit.keyed(); | |
| 51 | + | let client = Arc::new(client); | |
| 64 | 52 | ||
| 65 | 53 | let mut handles = Vec::new(); | |
| 66 | 54 | for i in 0..8 { | |
| @@ -88,28 +76,20 @@ | |||
| 88 | 76 | ||
| 89 | 77 | #[tokio::test] | |
| 90 | 78 | async fn concurrent_push_and_pull_interleaved() { | |
| 91 | - | let server = MockServer::start().await; | |
| 79 | + | let kit = MockKit::start().await; | |
| 92 | 80 | let device_id = DeviceId::new(Uuid::new_v4()); | |
| 93 | 81 | ||
| 94 | - | Mock::given(method("POST")) | |
| 95 | - | .and(path("/api/v1/sync/push")) | |
| 96 | - | .respond_with(ResponseTemplate::new(200).set_body_json(json!({"cursor": 10}))) | |
| 97 | - | .mount(&server) | |
| 98 | - | .await; | |
| 99 | - | ||
| 100 | - | Mock::given(method("POST")) | |
| 101 | - | .and(path("/api/v1/sync/pull")) | |
| 102 | - | .respond_with(ResponseTemplate::new(200).set_body_json(json!({ | |
| 82 | + | kit.post(PUSH_PATH).json(json!({"cursor": 10})).await; | |
| 83 | + | kit.post(PULL_PATH) | |
| 84 | + | .json(json!({ | |
| 103 | 85 | "changes": [], | |
| 104 | 86 | "cursor": 10, | |
| 105 | 87 | "has_more": false, | |
| 106 | - | }))) | |
| 107 | - | .mount(&server) | |
| 88 | + | })) | |
| 108 | 89 | .await; | |
| 109 | 90 | ||
| 110 | - | let client = Arc::new(authed_client(&server)); | |
| 111 | - | let key = synckit_client::crypto::generate_master_key(); | |
| 112 | - | client.set_master_key_raw(key); | |
| 91 | + | let (client, _key) = kit.keyed(); | |
| 92 | + | let client = Arc::new(client); | |
| 113 | 93 | ||
| 114 | 94 | let mut handles = Vec::new(); | |
| 115 | 95 | for i in 0..4 { | |
| @@ -138,8 +118,8 @@ | |||
| 138 | 118 | ||
| 139 | 119 | #[tokio::test] | |
| 140 | 120 | async fn concurrent_session_info_reads() { | |
| 141 | - | let server = MockServer::start().await; | |
| 142 | - | let client = Arc::new(authed_client(&server)); | |
| 121 | + | let kit = MockKit::start().await; | |
| 122 | + | let client = Arc::new(kit.authed()); | |
| 143 | 123 | ||
| 144 | 124 | let mut handles = Vec::new(); | |
| 145 | 125 | for _ in 0..50 { | |
| @@ -158,9 +138,9 @@ | |||
| 158 | 138 | ||
| 159 | 139 | #[tokio::test] | |
| 160 | 140 | async fn concurrent_has_master_key_reads() { | |
| 161 | - | let server = MockServer::start().await; | |
| 162 | - | let client = Arc::new(authed_client(&server)); | |
| 163 | - | client.set_master_key_raw(synckit_client::crypto::generate_master_key()); | |
| 141 | + | let kit = MockKit::start().await; | |
| 142 | + | let (client, _key) = kit.keyed(); | |
| 143 | + | let client = Arc::new(client); | |
| 164 | 144 | ||
| 165 | 145 | let mut handles = Vec::new(); | |
| 166 | 146 | for _ in 0..50 { | |
| @@ -176,18 +156,12 @@ | |||
| 176 | 156 | ||
| 177 | 157 | #[tokio::test] | |
| 178 | 158 | async fn concurrent_status_checks() { | |
| 179 | - | let server = MockServer::start().await; | |
| 180 | - | ||
| 181 | - | Mock::given(method("GET")) | |
| 182 | - | .and(path("/api/v1/sync/status")) | |
| 183 | - | .respond_with( | |
| 184 | - | ResponseTemplate::new(200) | |
| 185 | - | .set_body_json(json!({"total_changes": 5, "latest_cursor": 3})), | |
| 186 | - | ) | |
| 187 | - | .mount(&server) | |
| 159 | + | let kit = MockKit::start().await; | |
| 160 | + | kit.get("/api/v1/sync/status") | |
| 161 | + | .json(json!({"total_changes": 5, "latest_cursor": 3})) | |
| 188 | 162 | .await; | |
| 189 | 163 | ||
| 190 | - | let client = Arc::new(authed_client(&server)); | |
| 164 | + | let client = Arc::new(kit.authed()); | |
| 191 | 165 | ||
| 192 | 166 | let mut handles = Vec::new(); | |
| 193 | 167 | for _ in 0..20 { | |
| @@ -207,17 +181,11 @@ | |||
| 207 | 181 | ||
| 208 | 182 | #[tokio::test] | |
| 209 | 183 | async fn concurrent_push_100_entries_each() { | |
| 210 | - | let server = MockServer::start().await; | |
| 184 | + | let kit = MockKit::start().await; | |
| 185 | + | kit.post(PUSH_PATH).json(json!({"cursor": 1})).await; | |
| 211 | 186 | ||
| 212 | - | Mock::given(method("POST")) | |
| 213 | - | .and(path("/api/v1/sync/push")) | |
| 214 | - | .respond_with(ResponseTemplate::new(200).set_body_json(json!({"cursor": 1}))) | |
| 215 | - | .mount(&server) | |
| 216 | - | .await; | |
| 217 | - | ||
| 218 | - | let client = Arc::new(authed_client(&server)); | |
| 219 | - | let key = synckit_client::crypto::generate_master_key(); | |
| 220 | - | client.set_master_key_raw(key); | |
| 187 | + | let (client, _key) = kit.keyed(); | |
| 188 | + | let client = Arc::new(client); | |
| 221 | 189 | ||
| 222 | 190 | let mut handles = Vec::new(); | |
| 223 | 191 | for batch in 0..4 { |
| @@ -3,57 +3,43 @@ | |||
| 3 | 3 | ||
| 4 | 4 | use crate::common::*; | |
| 5 | 5 | ||
| 6 | + | const DEVICES_PATH: &str = "/api/v1/sync/devices"; | |
| 7 | + | ||
| 6 | 8 | // ── Device management ── | |
| 7 | 9 | ||
| 8 | 10 | #[tokio::test] | |
| 9 | 11 | async fn register_device_success() { | |
| 10 | - | let server = MockServer::start().await; | |
| 12 | + | let kit = MockKit::start().await; | |
| 13 | + | kit.post(DEVICES_PATH).json(device_json()).await; | |
| 11 | 14 | ||
| 12 | - | Mock::given(method("POST")) | |
| 13 | - | .and(path("/api/v1/sync/devices")) | |
| 14 | - | .respond_with(ResponseTemplate::new(200).set_body_json(device_json())) | |
| 15 | - | .mount(&server) | |
| 16 | - | .await; | |
| 17 | - | ||
| 18 | - | let client = authed_client(&server); | |
| 19 | - | let device = client.register_device("MacBook", "macos").await.unwrap(); | |
| 15 | + | let device = kit | |
| 16 | + | .authed() | |
| 17 | + | .register_device("MacBook", "macos") | |
| 18 | + | .await | |
| 19 | + | .unwrap(); | |
| 20 | 20 | assert_eq!(device.device_name, "Test Device"); | |
| 21 | 21 | } | |
| 22 | 22 | ||
| 23 | 23 | #[tokio::test] | |
| 24 | 24 | async fn register_device_retries_on_transient() { | |
| 25 | - | let server = MockServer::start().await; | |
| 26 | - | ||
| 27 | - | Mock::given(method("POST")) | |
| 28 | - | .and(path("/api/v1/sync/devices")) | |
| 29 | - | .respond_with(ResponseTemplate::new(502).set_body_string("Bad Gateway")) | |
| 30 | - | .up_to_n_times(1) | |
| 31 | - | .mount(&server) | |
| 25 | + | let kit = MockKit::start().await; | |
| 26 | + | kit.post(DEVICES_PATH) | |
| 27 | + | .code(502) | |
| 28 | + | .once() | |
| 29 | + | .text("Bad Gateway") | |
| 32 | 30 | .await; | |
| 31 | + | kit.post(DEVICES_PATH).json(device_json()).await; | |
| 33 | 32 | ||
| 34 | - | Mock::given(method("POST")) | |
| 35 | - | .and(path("/api/v1/sync/devices")) | |
| 36 | - | .respond_with(ResponseTemplate::new(200).set_body_json(device_json())) | |
| 37 | - | .mount(&server) | |
| 38 | - | .await; | |
| 39 | - | ||
| 40 | - | let client = authed_client(&server); | |
| 41 | - | let result = client.register_device("MacBook", "macos").await; | |
| 33 | + | let result = kit.authed().register_device("MacBook", "macos").await; | |
| 42 | 34 | assert!(result.is_ok(), "Should succeed after retry: {result:?}"); | |
| 43 | 35 | } | |
| 44 | 36 | ||
| 45 | 37 | #[tokio::test] | |
| 46 | 38 | async fn list_devices_success() { | |
| 47 | - | let server = MockServer::start().await; | |
| 39 | + | let kit = MockKit::start().await; | |
| 40 | + | kit.get(DEVICES_PATH).json(json!([device_json()])).await; | |
| 48 | 41 | ||
| 49 | - | Mock::given(method("GET")) | |
| 50 | - | .and(path("/api/v1/sync/devices")) | |
| 51 | - | .respond_with(ResponseTemplate::new(200).set_body_json(json!([device_json()]))) | |
| 52 | - | .mount(&server) | |
| 53 | - | .await; | |
| 54 | - | ||
| 55 | - | let client = authed_client(&server); | |
| 56 | - | let devices = client.list_devices().await.unwrap(); | |
| 42 | + | let devices = kit.authed().list_devices().await.unwrap(); | |
| 57 | 43 | assert_eq!(devices.len(), 1); | |
| 58 | 44 | assert_eq!(devices[0].device_name, "Test Device"); | |
| 59 | 45 | } | |
| @@ -62,10 +48,8 @@ | |||
| 62 | 48 | ||
| 63 | 49 | #[tokio::test] | |
| 64 | 50 | async fn list_devices_without_auth_returns_not_authenticated() { | |
| 65 | - | let server = MockServer::start().await; | |
| 66 | - | let client = client_for(&server); | |
| 67 | - | ||
| 68 | - | let err = client.list_devices().await.unwrap_err(); | |
| 51 | + | let kit = MockKit::start().await; | |
| 52 | + | let err = kit.client().list_devices().await.unwrap_err(); | |
| 69 | 53 | assert!(matches!(err, SyncKitError::NotAuthenticated)); | |
| 70 | 54 | } | |
| 71 | 55 | ||
| @@ -73,28 +57,21 @@ | |||
| 73 | 57 | ||
| 74 | 58 | #[tokio::test] | |
| 75 | 59 | async fn register_device_with_empty_name() { | |
| 76 | - | let server = MockServer::start().await; | |
| 60 | + | let kit = MockKit::start().await; | |
| 61 | + | kit.post(DEVICES_PATH).json(device_json()).await; | |
| 77 | 62 | ||
| 78 | - | Mock::given(method("POST")) | |
| 79 | - | .and(path("/api/v1/sync/devices")) | |
| 80 | - | .respond_with(ResponseTemplate::new(200).set_body_json(device_json())) | |
| 81 | - | .mount(&server) | |
| 82 | - | .await; | |
| 83 | - | ||
| 84 | - | let client = authed_client(&server); | |
| 85 | 63 | // Empty name should not panic; server may accept or reject | |
| 86 | - | let result = client.register_device("", "macos").await; | |
| 64 | + | let result = kit.authed().register_device("", "macos").await; | |
| 87 | 65 | assert!(result.is_ok(), "Empty device name should not panic"); | |
| 88 | 66 | } | |
| 89 | 67 | ||
| 90 | 68 | #[tokio::test] | |
| 91 | 69 | async fn register_device_with_unicode_name() { | |
| 92 | - | let server = MockServer::start().await; | |
| 70 | + | let kit = MockKit::start().await; | |
| 93 | 71 | ||
| 94 | 72 | let (user_id, app_id) = test_ids(); | |
| 95 | - | Mock::given(method("POST")) | |
| 96 | - | .and(path("/api/v1/sync/devices")) | |
| 97 | - | .respond_with(ResponseTemplate::new(200).set_body_json(json!({ | |
| 73 | + | kit.post(DEVICES_PATH) | |
| 74 | + | .json(json!({ | |
| 98 | 75 | "id": Uuid::new_v4(), | |
| 99 | 76 | "app_id": app_id, | |
| 100 | 77 | "user_id": user_id, | |
| @@ -102,12 +79,11 @@ | |||
| 102 | 79 | "platform": "macos", | |
| 103 | 80 | "last_seen_at": "2025-01-01T00:00:00Z", | |
| 104 | 81 | "created_at": "2025-01-01T00:00:00Z", | |
| 105 | - | }))) | |
| 106 | - | .mount(&server) | |
| 82 | + | })) | |
| 107 | 83 | .await; | |
| 108 | 84 | ||
| 109 | - | let client = authed_client(&server); | |
| 110 | - | let device = client | |
| 85 | + | let device = kit | |
| 86 | + | .authed() | |
| 111 | 87 | .register_device("\u{30DE}\u{30C3}\u{30AF}\u{30D6}\u{30C3}\u{30AF}", "macos") | |
| 112 | 88 | .await | |
| 113 | 89 | .unwrap(); | |
| @@ -119,15 +95,9 @@ | |||
| 119 | 95 | ||
| 120 | 96 | #[tokio::test] | |
| 121 | 97 | async fn list_devices_empty_array() { | |
| 122 | - | let server = MockServer::start().await; | |
| 98 | + | let kit = MockKit::start().await; | |
| 99 | + | kit.get(DEVICES_PATH).json(json!([])).await; | |
| 123 | 100 | ||
| 124 | - | Mock::given(method("GET")) | |
| 125 | - | .and(path("/api/v1/sync/devices")) | |
| 126 | - | .respond_with(ResponseTemplate::new(200).set_body_json(json!([]))) | |
| 127 | - | .mount(&server) | |
| 128 | - | .await; | |
| 129 | - | ||
| 130 | - | let client = authed_client(&server); | |
| 131 | - | let devices = client.list_devices().await.unwrap(); | |
| 101 | + | let devices = kit.authed().list_devices().await.unwrap(); | |
| 132 | 102 | assert!(devices.is_empty()); | |
| 133 | 103 | } |
| @@ -6,90 +6,74 @@ | |||
| 6 | 6 | ||
| 7 | 7 | use crate::common::*; | |
| 8 | 8 | ||
| 9 | + | const KEYS_PATH: &str = "/api/v1/sync/keys"; | |
| 10 | + | ||
| 11 | + | /// The `GET /keys` body: a master key wrapped under `password`. | |
| 12 | + | fn envelope_body(envelope: &str) -> serde_json::Value { | |
| 13 | + | json!({ "encrypted_key": envelope }) | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | /// The envelope from the `n`th `PUT /keys` the client sent. | |
| 17 | + | async fn uploaded_envelope(kit: &MockKit, n: usize) -> String { | |
| 18 | + | let bodies = kit.bodies("PUT", KEYS_PATH).await; | |
| 19 | + | let body = bodies | |
| 20 | + | .get(n) | |
| 21 | + | .unwrap_or_else(|| panic!("expected at least {} PUTs to {KEYS_PATH}", n + 1)); | |
| 22 | + | body["encrypted_key"] | |
| 23 | + | .as_str() | |
| 24 | + | .expect("the PUT body carries an encrypted_key") | |
| 25 | + | .to_string() | |
| 26 | + | } | |
| 27 | + | ||
| 9 | 28 | // ── Key management ── | |
| 10 | 29 | ||
| 11 | 30 | #[tokio::test] | |
| 12 | 31 | async fn has_server_key_true_on_200() { | |
| 13 | - | let server = MockServer::start().await; | |
| 14 | - | ||
| 15 | - | Mock::given(method("GET")) | |
| 16 | - | .and(path("/api/v1/sync/keys")) | |
| 17 | - | .respond_with( | |
| 18 | - | ResponseTemplate::new(200).set_body_json(json!({"encrypted_key": "envelope-data"})), | |
| 19 | - | ) | |
| 20 | - | .mount(&server) | |
| 32 | + | let kit = MockKit::start().await; | |
| 33 | + | kit.get(KEYS_PATH) | |
| 34 | + | .json(envelope_body("envelope-data")) | |
| 21 | 35 | .await; | |
| 22 | 36 | ||
| 23 | - | let client = authed_client(&server); | |
| 24 | - | assert!(client.has_server_key().await.unwrap()); | |
| 37 | + | assert!(kit.authed().has_server_key().await.unwrap()); | |
| 25 | 38 | } | |
| 26 | 39 | ||
| 27 | 40 | #[tokio::test] | |
| 28 | 41 | async fn has_server_key_false_on_404() { | |
| 29 | - | let server = MockServer::start().await; | |
| 42 | + | let kit = MockKit::start().await; | |
| 43 | + | kit.get(KEYS_PATH).code(404).empty().await; | |
| 30 | 44 | ||
| 31 | - | Mock::given(method("GET")) | |
| 32 | - | .and(path("/api/v1/sync/keys")) | |
| 33 | - | .respond_with(ResponseTemplate::new(404)) | |
| 34 | - | .mount(&server) | |
| 35 | - | .await; | |
| 36 | - | ||
| 37 | - | let client = authed_client(&server); | |
| 38 | - | assert!(!client.has_server_key().await.unwrap()); | |
| 45 | + | assert!(!kit.authed().has_server_key().await.unwrap()); | |
| 39 | 46 | } | |
| 40 | 47 | ||
| 41 | 48 | #[tokio::test] | |
| 42 | 49 | async fn has_server_key_retries_on_500() { | |
| 43 | - | let server = MockServer::start().await; | |
| 44 | - | ||
| 45 | - | Mock::given(method("GET")) | |
| 46 | - | .and(path("/api/v1/sync/keys")) | |
| 47 | - | .respond_with(ResponseTemplate::new(500).set_body_string("Internal Server Error")) | |
| 48 | - | .up_to_n_times(1) | |
| 49 | - | .mount(&server) | |
| 50 | + | let kit = MockKit::start().await; | |
| 51 | + | kit.get(KEYS_PATH) | |
| 52 | + | .code(500) | |
| 53 | + | .once() | |
| 54 | + | .text("Internal Server Error") | |
| 50 | 55 | .await; | |
| 56 | + | kit.get(KEYS_PATH).json(envelope_body("envelope")).await; | |
| 51 | 57 | ||
| 52 | - | Mock::given(method("GET")) | |
| 53 | - | .and(path("/api/v1/sync/keys")) | |
| 54 | - | .respond_with( | |
| 55 | - | ResponseTemplate::new(200).set_body_json(json!({"encrypted_key": "envelope"})), | |
| 56 | - | ) | |
| 57 | - | .mount(&server) | |
| 58 | - | .await; | |
| 59 | - | ||
| 60 | - | let client = authed_client(&server); | |
| 61 | - | assert!(client.has_server_key().await.unwrap()); | |
| 58 | + | assert!(kit.authed().has_server_key().await.unwrap()); | |
| 62 | 59 | } | |
| 63 | 60 | ||
| 64 | 61 | // ── change_password: CRITICAL bug fix tests ── | |
| 65 | 62 | ||
| 66 | - | /// Helper: set up a server that serves a wrapped master key envelope. | |
| 67 | - | /// Returns (client, master_key, envelope_json). | |
| 68 | - | fn setup_change_password_test( | |
| 69 | - | server: &MockServer, | |
| 70 | - | password: &str, | |
| 71 | - | ) -> (SyncKitClient, [u8; 32], String) { | |
| 72 | - | let client = authed_client(server); | |
| 73 | - | let master_key = synckit_client::crypto::generate_master_key(); | |
| 63 | + | /// A logged-in client holding its master key, plus that key and the envelope | |
| 64 | + | /// wrapping it under `password`. The state a real device is in when the user | |
| 65 | + | /// changes their password. | |
| 66 | + | fn cached_key_client(kit: &MockKit, password: &str) -> (SyncKitClient, [u8; 32], String) { | |
| 67 | + | let (client, master_key) = kit.keyed(); | |
| 74 | 68 | let envelope = synckit_client::crypto::wrap_master_key(&master_key, password).unwrap(); | |
| 75 | - | ||
| 76 | - | // Cache the master key in the client (simulating normal logged-in state) | |
| 77 | - | client.set_master_key_raw(master_key); | |
| 78 | - | ||
| 79 | 69 | (client, master_key, envelope) | |
| 80 | 70 | } | |
| 81 | 71 | ||
| 82 | 72 | #[tokio::test] | |
| 83 | 73 | async fn change_password_wrong_old_password_with_cached_key_fails() { | |
| 84 | - | let server = MockServer::start().await; | |
| 85 | - | let (client, _master_key, envelope) = setup_change_password_test(&server, "correct-old-pass"); | |
| 86 | - | ||
| 87 | - | // Server returns the envelope on GET | |
| 88 | - | Mock::given(method("GET")) | |
| 89 | - | .and(path("/api/v1/sync/keys")) | |
| 90 | - | .respond_with(ResponseTemplate::new(200).set_body_json(json!({"encrypted_key": envelope}))) | |
| 91 | - | .mount(&server) | |
| 92 | - | .await; | |
| 74 | + | let kit = MockKit::start().await; | |
| 75 | + | let (client, _master_key, envelope) = cached_key_client(&kit, "correct-old-pass"); | |
| 76 | + | kit.get(KEYS_PATH).json(envelope_body(&envelope)).await; | |
| 93 | 77 | ||
| 94 | 78 | // Attempt to change password with wrong old password. | |
| 95 | 79 | // The key IS cached, but the old password must still be validated. | |
| @@ -107,22 +91,10 @@ | |||
| 107 | 91 | ||
| 108 | 92 | #[tokio::test] | |
| 109 | 93 | async fn change_password_correct_old_password_with_cached_key_succeeds() { | |
| 110 | - | let server = MockServer::start().await; | |
| 111 | - | let (client, master_key, envelope) = setup_change_password_test(&server, "correct-old-pass"); | |
| 112 | - | ||
| 113 | - | // Server returns the envelope on GET | |
| 114 | - | Mock::given(method("GET")) | |
| 115 | - | .and(path("/api/v1/sync/keys")) | |
| 116 | - | .respond_with(ResponseTemplate::new(200).set_body_json(json!({"encrypted_key": envelope}))) | |
| 117 | - | .mount(&server) | |
| 118 | - | .await; | |
| 119 | - | ||
| 120 | - | // Server accepts the new envelope on PUT | |
| 121 | - | Mock::given(method("PUT")) | |
| 122 | - | .and(path("/api/v1/sync/keys")) | |
| 123 | - | .respond_with(ResponseTemplate::new(200)) | |
| 124 | - | .mount(&server) | |
| 125 | - | .await; | |
| 94 | + | let kit = MockKit::start().await; | |
| 95 | + | let (client, master_key, envelope) = cached_key_client(&kit, "correct-old-pass"); | |
| 96 | + | kit.get(KEYS_PATH).json(envelope_body(&envelope)).await; | |
| 97 | + | kit.put(KEYS_PATH).empty().await; | |
| 126 | 98 | ||
| 127 | 99 | let result = client.change_password("correct-old-pass", "new-pass").await; | |
| 128 | 100 | assert!( | |
| @@ -130,18 +102,13 @@ | |||
| 130 | 102 | "change_password should succeed with correct old password" | |
| 131 | 103 | ); | |
| 132 | 104 | ||
| 133 | - | // Verify the PUT request was made (new envelope was uploaded) | |
| 134 | - | let requests = server.received_requests().await.unwrap(); | |
| 135 | - | let put_requests: Vec<_> = requests | |
| 136 | - | .iter() | |
| 137 | - | .filter(|r| r.method.as_str() == "PUT" && r.url.path() == "/api/v1/sync/keys") | |
| 138 | - | .collect(); | |
| 139 | - | assert_eq!(put_requests.len(), 1, "Should have sent exactly one PUT"); | |
| 105 | + | // Exactly one PUT: the new envelope was uploaded, once. | |
| 106 | + | let puts = kit.bodies("PUT", KEYS_PATH).await; | |
| 107 | + | assert_eq!(puts.len(), 1, "Should have sent exactly one PUT"); | |
| 140 | 108 | ||
| 141 | 109 | // Verify the new envelope can be unwrapped with the new password | |
| 142 | - | let put_body: serde_json::Value = serde_json::from_slice(&put_requests[0].body).unwrap(); | |
| 143 | - | let new_envelope = put_body["encrypted_key"].as_str().unwrap(); | |
| 144 | - | let recovered = synckit_client::crypto::unwrap_master_key(new_envelope, "new-pass").unwrap(); | |
| 110 | + | let new_envelope = uploaded_envelope(&kit, 0).await; | |
| 111 | + | let recovered = synckit_client::crypto::unwrap_master_key(&new_envelope, "new-pass").unwrap(); | |
| 145 | 112 | assert_eq!( | |
| 146 | 113 | recovered, master_key, | |
| 147 | 114 | "New envelope should unwrap to the same master key" | |
| @@ -150,19 +117,14 @@ | |||
| 150 | 117 | ||
| 151 | 118 | #[tokio::test] | |
| 152 | 119 | async fn change_password_wrong_old_password_without_cached_key_fails() { | |
| 153 | - | let server = MockServer::start().await; | |
| 120 | + | let kit = MockKit::start().await; | |
| 154 | 121 | let master_key = synckit_client::crypto::generate_master_key(); | |
| 155 | 122 | let envelope = | |
| 156 | 123 | synckit_client::crypto::wrap_master_key(&master_key, "correct-old-pass").unwrap(); | |
| 157 | 124 | ||
| 158 | - | let client = authed_client(&server); | |
| 159 | - | // Deliberately NOT setting master key: no cached key | |
| 160 | - | ||
| 161 | - | Mock::given(method("GET")) | |
| 162 | - | .and(path("/api/v1/sync/keys")) | |
| 163 | - | .respond_with(ResponseTemplate::new(200).set_body_json(json!({"encrypted_key": envelope}))) | |
| 164 | - | .mount(&server) | |
| 165 | - | .await; | |
| 125 | + | // Deliberately NOT setting a master key: no cached key | |
| 126 | + | let client = kit.authed(); | |
| 127 | + | kit.get(KEYS_PATH).json(envelope_body(&envelope)).await; | |
| 166 | 128 | ||
| 167 | 129 | let result = client.change_password("wrong-old-pass", "new-pass").await; | |
| 168 | 130 | ||
| @@ -178,20 +140,10 @@ | |||
| 178 | 140 | ||
| 179 | 141 | #[tokio::test] | |
| 180 | 142 | async fn change_password_old_envelope_invalid_with_new_password() { | |
| 181 | - | let server = MockServer::start().await; | |
| 182 | - | let (client, _master_key, envelope) = setup_change_password_test(&server, "old-pass"); | |
| 183 | - | ||
| 184 | - | Mock::given(method("GET")) | |
| 185 | - | .and(path("/api/v1/sync/keys")) | |
| 186 | - | .respond_with(ResponseTemplate::new(200).set_body_json(json!({"encrypted_key": envelope}))) | |
| 187 | - | .mount(&server) | |
| 188 | - | .await; | |
| 189 | - | ||
| 190 | - | Mock::given(method("PUT")) | |
| 191 | - | .and(path("/api/v1/sync/keys")) | |
| 192 | - | .respond_with(ResponseTemplate::new(200)) | |
| 193 | - | .mount(&server) | |
| 194 | - | .await; | |
| 143 | + | let kit = MockKit::start().await; | |
| 144 | + | let (client, _master_key, envelope) = cached_key_client(&kit, "old-pass"); | |
| 145 | + | kit.get(KEYS_PATH).json(envelope_body(&envelope)).await; | |
| 146 | + | kit.put(KEYS_PATH).empty().await; | |
| 195 | 147 | ||
| 196 | 148 | client | |
| 197 | 149 | .change_password("old-pass", "new-pass") | |
| @@ -199,15 +151,8 @@ | |||
| 199 | 151 | .unwrap(); | |
| 200 | 152 | ||
| 201 | 153 | // Old password should NOT work on the new envelope | |
| 202 | - | let requests = server.received_requests().await.unwrap(); | |
| 203 | - | let put_req = requests | |
| 204 | - | .iter() | |
| 205 | - | .find(|r| r.method.as_str() == "PUT" && r.url.path() == "/api/v1/sync/keys") | |
| 206 | - | .unwrap(); | |
| 207 | - | let body: serde_json::Value = serde_json::from_slice(&put_req.body).unwrap(); | |
| 208 | - | let new_envelope = body["encrypted_key"].as_str().unwrap(); | |
| 209 | - | ||
| 210 | - | let result = synckit_client::crypto::unwrap_master_key(new_envelope, "old-pass"); | |
| 154 | + | let new_envelope = uploaded_envelope(&kit, 0).await; | |
| 155 | + | let result = synckit_client::crypto::unwrap_master_key(&new_envelope, "old-pass"); | |
| 211 | 156 | assert!( | |
| 212 | 157 | result.is_err(), | |
| 213 | 158 | "Old password must not work on the new envelope" | |
| @@ -218,16 +163,10 @@ | |||
| 218 | 163 | ||
| 219 | 164 | #[tokio::test] | |
| 220 | 165 | async fn setup_encryption_new_stores_key_and_uploads_envelope() { | |
| 221 | - | let server = MockServer::start().await; | |
| 166 | + | let kit = MockKit::start().await; | |
| 167 | + | kit.put(KEYS_PATH).exactly(1).empty().await; | |
| 222 | 168 | ||
| 223 | - | Mock::given(method("PUT")) | |
| 224 | - | .and(path("/api/v1/sync/keys")) | |
| 225 | - | .respond_with(ResponseTemplate::new(200)) | |
| 226 | - | .expect(1) | |
| 227 | - | .mount(&server) | |
| 228 | - | .await; | |
| 229 | - | ||
| 230 | - | let client = authed_client(&server); | |
| 169 | + | let client = kit.authed(); | |
| 231 | 170 | assert!(!client.has_master_key()); | |
| 232 | 171 | ||
| 233 | 172 | client.setup_encryption_new("test-password").await.unwrap(); | |
| @@ -236,45 +175,33 @@ | |||
| 236 | 175 | assert!(client.has_master_key()); | |
| 237 | 176 | ||
| 238 | 177 | // Verify the PUT body contains a valid envelope unwrappable with the same password | |
| 239 | - | let requests = server.received_requests().await.unwrap(); | |
| 240 | - | let put_req = requests | |
| 241 | - | .iter() | |
| 242 | - | .find(|r| r.method.as_str() == "PUT" && r.url.path() == "/api/v1/sync/keys") | |
| 243 | - | .unwrap(); | |
| 244 | - | let body: serde_json::Value = serde_json::from_slice(&put_req.body).unwrap(); | |
| 245 | - | let envelope_str = body["encrypted_key"].as_str().unwrap(); | |
| 246 | - | let recovered = | |
| 247 | - | synckit_client::crypto::unwrap_master_key(envelope_str, "test-password").unwrap(); | |
| 178 | + | let envelope = uploaded_envelope(&kit, 0).await; | |
| 179 | + | let recovered = synckit_client::crypto::unwrap_master_key(&envelope, "test-password").unwrap(); | |
| 248 | 180 | assert_eq!(recovered.len(), 32); | |
| 249 | 181 | } | |
| 250 | 182 | ||
| 251 | 183 | #[tokio::test] | |
| 252 | 184 | async fn setup_encryption_new_without_auth_fails() { | |
| 253 | - | let server = MockServer::start().await; | |
| 254 | - | let client = client_for(&server); | |
| 255 | - | ||
| 256 | - | let err = client.setup_encryption_new("password").await.unwrap_err(); | |
| 185 | + | let kit = MockKit::start().await; | |
| 186 | + | let err = kit | |
| 187 | + | .client() | |
| 188 | + | .setup_encryption_new("password") | |
| 189 | + | .await | |
| 190 | + | .unwrap_err(); | |
| 257 | 191 | assert!(matches!(err, SyncKitError::NotAuthenticated)); | |
| 258 | 192 | } | |
| 259 | 193 | ||
| 260 | 194 | #[tokio::test] | |
| 261 | 195 | async fn setup_encryption_new_retries_on_server_error() { | |
| 262 | - | let server = MockServer::start().await; | |
| 263 | - | ||
| 264 | - | Mock::given(method("PUT")) | |
| 265 | - | .and(path("/api/v1/sync/keys")) | |
| 266 | - | .respond_with(ResponseTemplate::new(500).set_body_string("Internal Server Error")) | |
| 267 | - | .up_to_n_times(1) | |
| 268 | - | .mount(&server) | |
| 196 | + | let kit = MockKit::start().await; | |
| 197 | + | kit.put(KEYS_PATH) | |
| 198 | + | .code(500) | |
| 199 | + | .once() | |
| 200 | + | .text("Internal Server Error") | |
| 269 | 201 | .await; | |
| 202 | + | kit.put(KEYS_PATH).empty().await; | |
| 270 | 203 | ||
| 271 | - | Mock::given(method("PUT")) | |
| 272 | - | .and(path("/api/v1/sync/keys")) | |
| 273 | - | .respond_with(ResponseTemplate::new(200)) | |
| 274 | - | .mount(&server) | |
| 275 | - | .await; | |
| 276 | - | ||
| 277 | - | let client = authed_client(&server); | |
| 204 | + | let client = kit.authed(); | |
| 278 | 205 | let result = client.setup_encryption_new("password").await; | |
| 279 | 206 | assert!(result.is_ok(), "Should succeed after retry: {result:?}"); | |
| 280 | 207 | assert!(client.has_master_key()); | |
| @@ -282,18 +209,13 @@ | |||
| 282 | 209 | ||
| 283 | 210 | #[tokio::test] | |
| 284 | 211 | async fn setup_encryption_existing_recovers_key() { | |
| 285 | - | let server = MockServer::start().await; | |
| 212 | + | let kit = MockKit::start().await; | |
| 286 | 213 | ||
| 287 | 214 | let master_key = synckit_client::crypto::generate_master_key(); | |
| 288 | 215 | let envelope = synckit_client::crypto::wrap_master_key(&master_key, "my-password").unwrap(); | |
| 216 | + | kit.get(KEYS_PATH).json(envelope_body(&envelope)).await; | |
| 289 | 217 | ||
| 290 | - | Mock::given(method("GET")) | |
| 291 | - | .and(path("/api/v1/sync/keys")) | |
| 292 | - | .respond_with(ResponseTemplate::new(200).set_body_json(json!({"encrypted_key": envelope}))) | |
| 293 | - | .mount(&server) | |
| 294 | - | .await; | |
| 295 | - | ||
| 296 | - | let client = authed_client(&server); | |
| 218 | + | let client = kit.authed(); | |
| 297 | 219 | assert!(!client.has_master_key()); | |
| 298 | 220 | ||
| 299 | 221 | client | |
| @@ -306,19 +228,14 @@ | |||
| 306 | 228 | ||
| 307 | 229 | #[tokio::test] | |
| 308 | 230 | async fn setup_encryption_existing_wrong_password_fails() { | |
| 309 | - | let server = MockServer::start().await; | |
| 231 | + | let kit = MockKit::start().await; | |
| 310 | 232 | ||
| 311 | 233 | let master_key = synckit_client::crypto::generate_master_key(); | |
| 312 | 234 | let envelope = | |
| 313 | 235 | synckit_client::crypto::wrap_master_key(&master_key, "correct-password").unwrap(); | |
| 236 | + | kit.get(KEYS_PATH).json(envelope_body(&envelope)).await; | |
| 314 | 237 | ||
| 315 | - | Mock::given(method("GET")) | |
| 316 | - | .and(path("/api/v1/sync/keys")) | |
| 317 | - | .respond_with(ResponseTemplate::new(200).set_body_json(json!({"encrypted_key": envelope}))) | |
| 318 | - | .mount(&server) | |
| 319 | - | .await; | |
| 320 | - | ||
| 321 | - | let client = authed_client(&server); | |
| 238 | + | let client = kit.authed(); | |
| 322 | 239 | let err = client | |
| 323 | 240 | .setup_encryption_existing("wrong-password") | |
| 324 | 241 | .await | |
| @@ -332,10 +249,9 @@ | |||
| 332 | 249 | ||
| 333 | 250 | #[tokio::test] | |
| 334 | 251 | async fn setup_encryption_existing_without_auth_fails() { | |
| 335 | - | let server = MockServer::start().await; | |
| 336 | - | let client = client_for(&server); | |
| 337 | - | ||
| 338 | - | let err = client | |
| 252 | + | let kit = MockKit::start().await; | |
| 253 | + | let err = kit | |
| 254 | + | .client() | |
| 339 | 255 | .setup_encryption_existing("password") | |
| 340 | 256 | .await | |
| 341 | 257 | .unwrap_err(); | |
| @@ -344,25 +260,19 @@ | |||
| 344 | 260 | ||
| 345 | 261 | #[tokio::test] | |
| 346 | 262 | async fn setup_encryption_existing_retries_on_server_error() { | |
| 347 | - | let server = MockServer::start().await; | |
| 263 | + | let kit = MockKit::start().await; | |
| 348 | 264 | ||
| 349 | 265 | let master_key = synckit_client::crypto::generate_master_key(); | |
| 350 | 266 | let envelope = synckit_client::crypto::wrap_master_key(&master_key, "password").unwrap(); | |
| 351 | 267 | ||
| 352 | - | Mock::given(method("GET")) | |
| 353 | - | .and(path("/api/v1/sync/keys")) | |
| 354 | - | .respond_with(ResponseTemplate::new(502).set_body_string("Bad Gateway")) | |
| 355 | - | .up_to_n_times(1) | |
| 356 | - | .mount(&server) | |
| 268 | + | kit.get(KEYS_PATH) | |
| 269 | + | .code(502) | |
| 270 | + | .once() | |
| 271 | + | .text("Bad Gateway") | |
| 357 | 272 | .await; | |
| 273 | + | kit.get(KEYS_PATH).json(envelope_body(&envelope)).await; | |
| 358 | 274 | ||
| 359 | - | Mock::given(method("GET")) | |
| 360 | - | .and(path("/api/v1/sync/keys")) | |
| 361 | - | .respond_with(ResponseTemplate::new(200).set_body_json(json!({"encrypted_key": envelope}))) | |
| 362 | - | .mount(&server) | |
| 363 | - | .await; | |
| 364 | - | ||
| 365 | - | let client = authed_client(&server); | |
| 275 | + | let client = kit.authed(); | |
| 366 | 276 | let result = client.setup_encryption_existing("password").await; | |
| 367 | 277 | assert!(result.is_ok(), "Should succeed after retry: {result:?}"); | |
| 368 | 278 | assert!(client.has_master_key()); | |
| @@ -370,16 +280,11 @@ | |||
| 370 | 280 | ||
| 371 | 281 | #[tokio::test] | |
| 372 | 282 | async fn setup_encryption_existing_no_server_key_returns_error() { | |
| 373 | - | let server = MockServer::start().await; | |
| 283 | + | let kit = MockKit::start().await; | |
| 284 | + | kit.get(KEYS_PATH).code(404).text("Not Found").await; | |
| 374 | 285 | ||
| 375 | - | Mock::given(method("GET")) | |
| 376 | - | .and(path("/api/v1/sync/keys")) | |
| 377 | - | .respond_with(ResponseTemplate::new(404).set_body_string("Not Found")) | |
| 378 | - | .mount(&server) | |
| 379 | - | .await; | |
| 380 | - | ||
| 381 | - | let client = authed_client(&server); | |
| 382 | - | let err = client | |
| 286 | + | let err = kit | |
| 287 | + | .authed() | |
| 383 | 288 | .setup_encryption_existing("password") | |
| 384 | 289 | .await | |
| 385 | 290 | .unwrap_err(); | |
| @@ -394,22 +299,15 @@ | |||
| 394 | 299 | /// device 1 must be decryptable by device 2. | |
| 395 | 300 | #[tokio::test] | |
| 396 | 301 | async fn encryption_setup_cross_device_roundtrip() { | |
| 397 | - | let server = MockServer::start().await; | |
| 302 | + | let kit = MockKit::start().await; | |
| 398 | 303 | ||
| 399 | 304 | // Device 1: setup_encryption_new | |
| 400 | - | Mock::given(method("PUT")) | |
| 401 | - | .and(path("/api/v1/sync/keys")) | |
| 402 | - | .respond_with(ResponseTemplate::new(200)) | |
| 403 | - | .mount(&server) | |
| 305 | + | kit.put(KEYS_PATH).empty().await; | |
| 306 | + | kit.post("/api/v1/sync/push") | |
| 307 | + | .json(json!({"cursor": 1})) | |
| 404 | 308 | .await; | |
| 405 | 309 | ||
| 406 | - | Mock::given(method("POST")) | |
| 407 | - | .and(path("/api/v1/sync/push")) | |
| 408 | - | .respond_with(ResponseTemplate::new(200).set_body_json(json!({"cursor": 1}))) | |
| 409 | - | .mount(&server) | |
| 410 | - | .await; | |
| 411 | - | ||
| 412 | - | let client1 = authed_client(&server); | |
| 310 | + | let client1 = kit.authed(); | |
| 413 | 311 | client1 | |
| 414 | 312 | .setup_encryption_new("shared-password") | |
| 415 | 313 | .await | |
| @@ -435,33 +333,16 @@ | |||
| 435 | 333 | .unwrap(); | |
| 436 | 334 | ||
| 437 | 335 | // Capture the envelope and encrypted data | |
| 438 | - | let requests = server.received_requests().await.unwrap(); | |
| 439 | - | let put_req = requests | |
| 440 | - | .iter() | |
| 441 | - | .find(|r| r.method.as_str() == "PUT" && r.url.path() == "/api/v1/sync/keys") | |
| 442 | - | .unwrap(); | |
| 443 | - | let put_body: serde_json::Value = serde_json::from_slice(&put_req.body).unwrap(); | |
| 444 | - | let envelope = put_body["encrypted_key"].as_str().unwrap().to_string(); | |
| 445 | - | ||
| 446 | - | let push_req = requests | |
| 447 | - | .iter() | |
| 448 | - | .find(|r| r.url.path() == "/api/v1/sync/push") | |
| 449 | - | .unwrap(); | |
| 450 | - | let push_body: serde_json::Value = serde_json::from_slice(&push_req.body).unwrap(); | |
| 336 | + | let envelope = uploaded_envelope(&kit, 0).await; | |
| 337 | + | let push_body = kit.body("/api/v1/sync/push").await; | |
| 451 | 338 | let encrypted_data = push_body["changes"][0]["data"].clone(); | |
| 452 | 339 | ||
| 453 | 340 | // Device 2: setup_encryption_existing with same password | |
| 454 | - | server.reset().await; | |
| 341 | + | kit.reset().await; | |
| 455 | 342 | ||
| 456 | - | Mock::given(method("GET")) | |
| 457 | - | .and(path("/api/v1/sync/keys")) | |
| 458 | - | .respond_with(ResponseTemplate::new(200).set_body_json(json!({"encrypted_key": envelope}))) | |
| 459 | - | .mount(&server) | |
| 460 | - | .await; | |
| 461 | - | ||
| 462 | - | Mock::given(method("POST")) | |
| 463 | - | .and(path("/api/v1/sync/pull")) | |
| 464 | - | .respond_with(ResponseTemplate::new(200).set_body_json(json!({ | |
| 343 | + | kit.get(KEYS_PATH).json(envelope_body(&envelope)).await; | |
| 344 | + | kit.post("/api/v1/sync/pull") | |
| 345 | + | .json(json!({ | |
| 465 | 346 | "changes": [{ | |
| 466 | 347 | "seq": 1, | |
| 467 | 348 | "device_id": device_id, | |
| @@ -473,11 +354,10 @@ | |||
| 473 | 354 | }], | |
| 474 | 355 | "cursor": 1, | |
| 475 | 356 | "has_more": false, |
Lines truncated
| @@ -37,18 +37,16 @@ | |||
| 37 | 37 | } | |
| 38 | 38 | ||
| 39 | 39 | /// The client whose master key seeds the admin identity, plus that identity. | |
| 40 | - | fn admin_client(server: &MockServer) -> (SyncKitClient, IdentityKeypair) { | |
| 41 | - | let client = authed_client(server); | |
| 42 | - | let master = synckit_client::crypto::generate_master_key(); | |
| 40 | + | fn admin_client(kit: &MockKit) -> (SyncKitClient, IdentityKeypair) { | |
| 41 | + | let (client, master) = kit.keyed(); | |
| 43 | 42 | let identity = IdentityKeypair::from_master_key(&master); | |
| 44 | - | client.set_master_key_raw(master); | |
| 45 | 43 | (client, identity) | |
| 46 | 44 | } | |
| 47 | 45 | ||
| 48 | 46 | /// Mount the two reads a rotation makes: the admin's own grant (for the | |
| 49 | 47 | /// current generation) and the member pubkey list (the re-seal inputs). | |
| 50 | 48 | async fn mount_reads( | |
| 51 | - | server: &MockServer, | |
| 49 | + | kit: &MockKit, | |
| 52 | 50 | group_id: GroupId, | |
| 53 | 51 | gck: &[u8; 32], | |
| 54 | 52 | admin: &IdentityKeypair, | |
| @@ -58,13 +56,11 @@ | |||
| 58 | 56 | ) { | |
| 59 | 57 | let sealed = seal_gck_to_member(gck, &admin.public_key(), &group_id.to_string(), version) | |
| 60 | 58 | .expect("seal admin grant"); | |
| 61 | - | Mock::given(method("GET")) | |
| 62 | - | .and(path(format!("/api/v1/sync/groups/{group_id}/grant"))) | |
| 63 | - | .respond_with(ResponseTemplate::new(200).set_body_json(json!({ | |
| 59 | + | kit.get(&format!("/api/v1/sync/groups/{group_id}/grant")) | |
| 60 | + | .json(json!({ | |
| 64 | 61 | "sealed_gck": sealed, | |
| 65 | 62 | "gck_version": version, | |
| 66 | - | }))) | |
| 67 | - | .mount(server) | |
| 63 | + | })) | |
| 68 | 64 | .await; | |
| 69 | 65 | ||
| 70 | 66 | let mut pubkeys = vec![json!({ | |
| @@ -72,35 +68,28 @@ | |||
| 72 | 68 | "pubkey": admin.public_key().to_base64(), | |
| 73 | 69 | })]; | |
| 74 | 70 | pubkeys.extend(members.iter().map(|m| m.pubkey_json())); | |
| 75 | - | Mock::given(method("GET")) | |
| 76 | - | .and(path(format!("/api/v1/sync/groups/{group_id}/pubkeys"))) | |
| 77 | - | .respond_with(ResponseTemplate::new(200).set_body_json(pubkeys)) | |
| 78 | - | .mount(server) | |
| 71 | + | kit.get(&format!("/api/v1/sync/groups/{group_id}/pubkeys")) | |
| 72 | + | .json(pubkeys) | |
| 79 | 73 | .await; | |
| 80 | 74 | } | |
| 81 | 75 | ||
| 82 | - | async fn mount_rotate(server: &MockServer) { | |
| 83 | - | Mock::given(method("POST")) | |
| 84 | - | .and(path_regex(r"^/api/v1/sync/groups/[^/]+/rotate$")) | |
| 85 | - | .respond_with(ResponseTemplate::new(204)) | |
| 86 | - | .mount(server) | |
| 76 | + | async fn mount_rotate(kit: &MockKit) { | |
| 77 | + | kit.matching("POST", path_regex(r"^/api/v1/sync/groups/[^/]+/rotate$")) | |
| 78 | + | .code(204) | |
| 79 | + | .empty() | |
| 87 | 80 | .await; | |
| 88 | 81 | } | |
| 89 | 82 | ||
| 90 | 83 | /// The body the client POSTed to `/rotate`. | |
| 91 | - | async fn posted_batch(server: &MockServer) -> serde_json::Value { | |
| 92 | - | let reqs = server.received_requests().await.expect("requests"); | |
| 93 | - | let rotate = reqs | |
| 94 | - | .iter() | |
| 95 | - | .find(|r| r.url.path().ends_with("/rotate")) | |
| 96 | - | .expect("a rotate request was sent"); | |
| 97 | - | serde_json::from_slice(&rotate.body).expect("rotate body is JSON") | |
| 84 | + | async fn posted_batch(kit: &MockKit, group_id: GroupId) -> serde_json::Value { | |
| 85 | + | kit.body(&format!("/api/v1/sync/groups/{group_id}/rotate")) | |
| 86 | + | .await | |
| 98 | 87 | } | |
| 99 | 88 | ||
| 100 | 89 | #[tokio::test] | |
| 101 | 90 | async fn removing_a_member_rekeys_and_reseals_to_everyone_who_stays() { | |
| 102 | - | let server = MockServer::start().await; | |
| 103 | - | let (client, admin_identity) = admin_client(&server); | |
| 91 | + | let kit = MockKit::start().await; | |
| 92 | + | let (client, admin_identity) = admin_client(&kit); | |
| 104 | 93 | let (admin_id, _) = test_ids(); | |
| 105 | 94 | let group_id = GroupId::new(Uuid::new_v4()); | |
| 106 | 95 | let old_gck = generate_group_key(); | |
| @@ -108,7 +97,7 @@ | |||
| 108 | 97 | let bob = Member::new(); | |
| 109 | 98 | let carol = Member::new(); | |
| 110 | 99 | mount_reads( | |
| 111 | - | &server, | |
| 100 | + | &kit, | |
| 112 | 101 | group_id, | |
| 113 | 102 | &old_gck, | |
| 114 | 103 | &admin_identity, | |
| @@ -117,14 +106,14 @@ | |||
| 117 | 106 | &[&bob, &carol], | |
| 118 | 107 | ) | |
| 119 | 108 | .await; | |
| 120 | - | mount_rotate(&server).await; | |
| 109 | + | mount_rotate(&kit).await; | |
| 121 | 110 | ||
| 122 | 111 | client | |
| 123 | 112 | .remove_member(group_id, carol.user_id) | |
| 124 | 113 | .await | |
| 125 | 114 | .expect("remove member"); | |
| 126 | 115 | ||
| 127 | - | let batch = posted_batch(&server).await; | |
| 116 | + | let batch = posted_batch(&kit, group_id).await; | |
| 128 | 117 | assert_eq!( | |
| 129 | 118 | batch["gck_version"], 8, | |
| 130 | 119 | "the generation must advance past the one our grant reports" | |
| @@ -178,15 +167,15 @@ | |||
| 178 | 167 | ||
| 179 | 168 | #[tokio::test] | |
| 180 | 169 | async fn a_grant_cannot_be_opened_by_the_member_it_was_not_sealed_to() { | |
| 181 | - | let server = MockServer::start().await; | |
| 182 | - | let (client, admin_identity) = admin_client(&server); | |
| 170 | + | let kit = MockKit::start().await; | |
| 171 | + | let (client, admin_identity) = admin_client(&kit); | |
| 183 | 172 | let (admin_id, _) = test_ids(); | |
| 184 | 173 | let group_id = GroupId::new(Uuid::new_v4()); | |
| 185 | 174 | ||
| 186 | 175 | let bob = Member::new(); | |
| 187 | 176 | let carol = Member::new(); | |
| 188 | 177 | mount_reads( | |
| 189 | - | &server, | |
| 178 | + | &kit, | |
| 190 | 179 | group_id, | |
| 191 | 180 | &generate_group_key(), | |
| 192 | 181 | &admin_identity, | |
| @@ -195,14 +184,14 @@ | |||
| 195 | 184 | &[&bob, &carol], | |
| 196 | 185 | ) | |
| 197 | 186 | .await; | |
| 198 | - | mount_rotate(&server).await; | |
| 187 | + | mount_rotate(&kit).await; | |
| 199 | 188 | ||
| 200 | 189 | client | |
| 201 | 190 | .rotate_group_key(group_id, &[]) | |
| 202 | 191 | .await | |
| 203 | 192 | .expect("rotate without removing anyone"); | |
| 204 | 193 | ||
| 205 | - | let batch = posted_batch(&server).await; | |
| 194 | + | let batch = posted_batch(&kit, group_id).await; | |
| 206 | 195 | let bobs = batch["grants"] | |
| 207 | 196 | .as_array() | |
| 208 | 197 | .expect("grants") | |
| @@ -221,14 +210,14 @@ | |||
| 221 | 210 | ||
| 222 | 211 | #[tokio::test] | |
| 223 | 212 | async fn an_empty_removal_set_rekeys_without_dropping_anyone() { | |
| 224 | - | let server = MockServer::start().await; | |
| 225 | - | let (client, admin_identity) = admin_client(&server); | |
| 213 | + | let kit = MockKit::start().await; | |
| 214 | + | let (client, admin_identity) = admin_client(&kit); | |
| 226 | 215 | let (admin_id, _) = test_ids(); | |
| 227 | 216 | let group_id = GroupId::new(Uuid::new_v4()); | |
| 228 | 217 | ||
| 229 | 218 | let bob = Member::new(); | |
| 230 | 219 | mount_reads( | |
| 231 | - | &server, | |
| 220 | + | &kit, | |
| 232 | 221 | group_id, | |
| 233 | 222 | &generate_group_key(), | |
| 234 | 223 | &admin_identity, | |
| @@ -237,14 +226,14 @@ | |||
| 237 | 226 | &[&bob], | |
| 238 | 227 | ) | |
| 239 | 228 | .await; | |
| 240 | - | mount_rotate(&server).await; | |
| 229 | + | mount_rotate(&kit).await; | |
| 241 | 230 | ||
| 242 | 231 | client | |
| 243 | 232 | .rotate_group_key(group_id, &[]) | |
| 244 | 233 | .await | |
| 245 | 234 | .expect("rekey after a suspected compromise"); | |
| 246 | 235 | ||
| 247 | - | let batch = posted_batch(&server).await; | |
| 236 | + | let batch = posted_batch(&kit, group_id).await; | |
| 248 | 237 | assert_eq!(batch["gck_version"], 4); | |
| 249 | 238 | assert_eq!( | |
| 250 | 239 | batch["grants"].as_array().expect("grants").len(), | |
| @@ -255,8 +244,8 @@ | |||
| 255 | 244 | ||
| 256 | 245 | #[tokio::test] | |
| 257 | 246 | async fn a_member_pubkey_the_client_cannot_parse_aborts_the_rotation() { | |
| 258 | - | let server = MockServer::start().await; | |
| 259 | - | let (client, admin_identity) = admin_client(&server); | |
| 247 | + | let kit = MockKit::start().await; | |
| 248 | + | let (client, admin_identity) = admin_client(&kit); | |
| 260 | 249 | let (admin_id, _) = test_ids(); | |
| 261 | 250 | let group_id = GroupId::new(Uuid::new_v4()); | |
| 262 | 251 | ||
| @@ -267,34 +256,28 @@ | |||
| 267 | 256 | 1, | |
| 268 | 257 | ) | |
| 269 | 258 | .expect("seal admin grant"); | |
| 270 | - | Mock::given(method("GET")) | |
| 271 | - | .and(path(format!("/api/v1/sync/groups/{group_id}/grant"))) | |
| 272 | - | .respond_with(ResponseTemplate::new(200).set_body_json(json!({ | |
| 259 | + | kit.get(&format!("/api/v1/sync/groups/{group_id}/grant")) | |
| 260 | + | .json(json!({ | |
| 273 | 261 | "sealed_gck": sealed, | |
| 274 | 262 | "gck_version": 1, | |
| 275 | - | }))) | |
| 276 | - | .mount(&server) | |
| 263 | + | })) | |
| 277 | 264 | .await; | |
| 278 | - | Mock::given(method("GET")) | |
| 279 | - | .and(path(format!("/api/v1/sync/groups/{group_id}/pubkeys"))) | |
| 280 | - | .respond_with(ResponseTemplate::new(200).set_body_json(json!([ | |
| 265 | + | kit.get(&format!("/api/v1/sync/groups/{group_id}/pubkeys")) | |
| 266 | + | .json(json!([ | |
| 281 | 267 | { "user_id": admin_id, "pubkey": admin_identity.public_key().to_base64() }, | |
| 282 | 268 | { "user_id": Uuid::new_v4(), "pubkey": "not-a-key" }, | |
| 283 | - | ]))) | |
| 284 | - | .mount(&server) | |
| 269 | + | ])) | |
| 285 | 270 | .await; | |
| 286 | - | mount_rotate(&server).await; | |
| 271 | + | mount_rotate(&kit).await; | |
| 287 | 272 | ||
| 288 | 273 | client | |
| 289 | 274 | .rotate_group_key(group_id, &[]) | |
| 290 | 275 | .await | |
| 291 | 276 | .expect_err("an unreadable member key must not produce a partial rotation"); | |
| 292 | 277 | ||
| 293 | - | let reqs = server.received_requests().await.expect("requests"); | |
| 294 | 278 | assert_eq!( | |
| 295 | - | reqs.iter() | |
| 296 | - | .filter(|r| r.url.path().ends_with("/rotate")) | |
| 297 | - | .count(), | |
| 279 | + | kit.hits(&format!("/api/v1/sync/groups/{group_id}/rotate")) | |
| 280 | + | .await, | |
| 298 | 281 | 0, | |
| 299 | 282 | "nothing may be posted when the batch could not be built in full" | |
| 300 | 283 | ); | |
| @@ -339,34 +322,31 @@ | |||
| 339 | 322 | /// binding to drift from the one the client uses. | |
| 340 | 323 | async fn sealed_entry( | |
| 341 | 324 | client: &SyncKitClient, | |
| 342 | - | server: &MockServer, | |
| 325 | + | kit: &MockKit, | |
| 343 | 326 | group_id: GroupId, | |
| 344 | 327 | gck: &[u8; 32], | |
| 345 | 328 | device: DeviceId, | |
| 346 | 329 | row: &str, | |
| 347 | 330 | title: &str, | |
| 348 | 331 | ) -> serde_json::Value { | |
| 349 | - | let before = server.received_requests().await.expect("requests").len(); | |
| 332 | + | let push_path = format!("/api/v1/sync/groups/{group_id}/push"); | |
| 333 | + | let before = kit.hits(&push_path).await; | |
| 350 | 334 | client | |
| 351 | 335 | .group_push(group_id, gck, device, vec![change(row, title)]) | |
| 352 | 336 | .await | |
| 353 | 337 | .expect("group push"); | |
| 354 | - | let reqs = server.received_requests().await.expect("requests"); | |
| 355 | - | let pushed = reqs[before..] | |
| 356 | - | .iter() | |
| 357 | - | .find(|r| r.url.path().ends_with("/push")) | |
| 358 | - | .expect("a push was sent"); | |
| 359 | - | let body: serde_json::Value = serde_json::from_slice(&pushed.body).expect("push body"); | |
| 338 | + | let pushes = kit.requests_to(&push_path).await; | |
| 339 | + | assert!(pushes.len() > before, "a push was sent"); | |
| 340 | + | let body: serde_json::Value = | |
| 341 | + | serde_json::from_slice(&pushes[before].body).expect("push body"); | |
| 360 | 342 | body["changes"][0].clone() | |
| 361 | 343 | } | |
| 362 | 344 | ||
| 363 | 345 | #[tokio::test] | |
| 364 | 346 | async fn a_pull_spanning_two_generations_opens_each_under_its_own_key() { | |
| 365 | - | let server = MockServer::start().await; | |
| 366 | - | let client = authed_client(&server); | |
| 367 | - | let master = synckit_client::crypto::generate_master_key(); | |
| 347 | + | let kit = MockKit::start().await; | |
| 348 | + | let (client, master) = kit.keyed(); | |
| 368 | 349 | let identity = IdentityKeypair::from_master_key(&master); | |
| 369 | - | client.set_master_key_raw(master); | |
| 370 | 350 | ||
| 371 | 351 | let group_id = GroupId::new(Uuid::new_v4()); | |
| 372 | 352 | let device = DeviceId::new(Uuid::new_v4()); | |
| @@ -374,15 +354,13 @@ | |||
| 374 | 354 | let gck_v1 = generate_group_key(); | |
| 375 | 355 | let gck_v2 = generate_group_key(); | |
| 376 | 356 | ||
| 377 | - | Mock::given(method("POST")) | |
| 378 | - | .and(path_regex(r"^/api/v1/sync/groups/[^/]+/push$")) | |
| 379 | - | .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "cursor": 1 }))) | |
| 380 | - | .mount(&server) | |
| 357 | + | kit.matching("POST", path_regex(r"^/api/v1/sync/groups/[^/]+/push$")) | |
| 358 | + | .json(json!({ "cursor": 1 })) | |
| 381 | 359 | .await; | |
| 382 | 360 | ||
| 383 | 361 | let old = sealed_entry( | |
| 384 | 362 | &client, | |
| 385 | - | &server, | |
| 363 | + | &kit, | |
| 386 | 364 | group_id, | |
| 387 | 365 | &gck_v1, | |
| 388 | 366 | device, | |
| @@ -392,7 +370,7 @@ | |||
| 392 | 370 | .await; | |
| 393 | 371 | let new = sealed_entry( | |
| 394 | 372 | &client, | |
| 395 | - | &server, | |
| 373 | + | &kit, | |
| 396 | 374 | group_id, | |
| 397 | 375 | &gck_v2, | |
| 398 | 376 | device, | |
| @@ -407,20 +385,17 @@ | |||
| 407 | 385 | for (version, gck) in [(1, &gck_v1), (2, &gck_v2)] { | |
| 408 | 386 | let sealed = | |
| 409 | 387 | seal_gck_to_member(gck, &identity.public_key(), &group_ref, version).expect("seal"); | |
| 410 | - | Mock::given(method("GET")) | |
| 411 | - | .and(path(format!("/api/v1/sync/groups/{group_id}/grant"))) | |
| 388 | + | kit.get(&format!("/api/v1/sync/groups/{group_id}/grant")) | |
| 412 | 389 | .and(query_param("version", version.to_string())) | |
| 413 | - | .respond_with(ResponseTemplate::new(200).set_body_json(json!({ | |
| 390 | + | .json(json!({ | |
| 414 | 391 | "sealed_gck": sealed, | |
| 415 | 392 | "gck_version": version, | |
| 416 | - | }))) | |
| 417 | - | .mount(&server) | |
| 393 | + | })) | |
| 418 | 394 | .await; | |
| 419 | 395 | } | |
| 420 | 396 | ||
| 421 | - | Mock::given(method("POST")) | |
| 422 | - | .and(path_regex(r"^/api/v1/sync/groups/[^/]+/pull$")) | |
| 423 | - | .respond_with(ResponseTemplate::new(200).set_body_json(json!({ | |
| 397 | + | kit.matching("POST", path_regex(r"^/api/v1/sync/groups/[^/]+/pull$")) | |
| 398 | + | .json(json!({ | |
| 424 | 399 | "changes": [ | |
| 425 | 400 | { | |
| 426 | 401 | "seq": 1, | |
| @@ -445,8 +420,7 @@ | |||
| 445 | 420 | ], | |
| 446 | 421 | "cursor": 2, | |
| 447 | 422 | "has_more": false, | |
| 448 | - | }))) | |
| 449 | - | .mount(&server) | |
| 423 | + | })) | |
| 450 | 424 | .await; | |
| 451 | 425 | ||
| 452 | 426 | let (changes, cursor, has_more) = client | |
| @@ -474,24 +448,20 @@ | |||
| 474 | 448 | /// server keeps working rather than failing every pull. | |
| 475 | 449 | #[tokio::test] | |
| 476 | 450 | async fn an_entry_without_a_generation_falls_back_to_the_current_one() { | |
| 477 | - | let server = MockServer::start().await; | |
| 478 | - | let client = authed_client(&server); | |
| 479 | - | let master = synckit_client::crypto::generate_master_key(); | |
| 451 | + | let kit = MockKit::start().await; | |
| 452 | + | let (client, master) = kit.keyed(); | |
| 480 | 453 | let identity = IdentityKeypair::from_master_key(&master); | |
| 481 | - | client.set_master_key_raw(master); | |
| 482 | 454 | ||
| 483 | 455 | let group_id = GroupId::new(Uuid::new_v4()); | |
| 484 | 456 | let device = DeviceId::new(Uuid::new_v4()); | |
| 485 | 457 | let gck = generate_group_key(); | |
| 486 | 458 | ||
| 487 | - | Mock::given(method("POST")) | |
| 488 | - | .and(path_regex(r"^/api/v1/sync/groups/[^/]+/push$")) | |
| 489 | - | .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "cursor": 1 }))) | |
| 490 | - | .mount(&server) | |
| 459 | + | kit.matching("POST", path_regex(r"^/api/v1/sync/groups/[^/]+/push$")) | |
| 460 | + | .json(json!({ "cursor": 1 })) | |
| 491 | 461 | .await; | |
| 492 | 462 | let entry = sealed_entry( | |
| 493 | 463 | &client, | |
| 494 | - | &server, | |
| 464 | + | &kit, | |
| 495 | 465 | group_id, | |
| 496 | 466 | &gck, | |
| 497 | 467 | device, | |
| @@ -502,18 +472,15 @@ | |||
| 502 | 472 | ||
| 503 | 473 | let sealed = seal_gck_to_member(&gck, &identity.public_key(), &group_id.to_string(), 5) | |
| 504 | 474 | .expect("seal"); | |
| 505 | - | Mock::given(method("GET")) | |
| 506 | - | .and(path(format!("/api/v1/sync/groups/{group_id}/grant"))) | |
| 507 | - | .respond_with(ResponseTemplate::new(200).set_body_json(json!({ | |
| 475 | + | kit.get(&format!("/api/v1/sync/groups/{group_id}/grant")) | |
| 476 | + | .json(json!({ | |
| 508 | 477 | "sealed_gck": sealed, | |
| 509 | 478 | "gck_version": 5, | |
| 510 | - | }))) | |
| 511 | - | .mount(&server) | |
| 479 | + | })) | |
| 512 | 480 | .await; | |
| 513 | 481 | ||
| 514 | - | Mock::given(method("POST")) | |
| 515 | - | .and(path_regex(r"^/api/v1/sync/groups/[^/]+/pull$")) | |
| 516 | - | .respond_with(ResponseTemplate::new(200).set_body_json(json!({ | |
| 482 | + | kit.matching("POST", path_regex(r"^/api/v1/sync/groups/[^/]+/pull$")) | |
| 483 | + | .json(json!({ | |
| 517 | 484 | "changes": [{ | |
| 518 | 485 | "seq": 1, | |
| 519 | 486 | "device_id": device, | |
| @@ -525,8 +492,7 @@ | |||
| 525 | 492 | }], | |
| 526 | 493 | "cursor": 1, | |
| 527 | 494 | "has_more": false, | |
| 528 | - | }))) | |
| 529 | - | .mount(&server) | |
| 495 | + | })) | |
| 530 | 496 | .await; | |
| 531 | 497 | ||
| 532 | 498 | let (changes, _, _) = client |
| @@ -4,10 +4,11 @@ | |||
| 4 | 4 | //! round-trip for its surface: the request the client builds, the retry and error | |
| 5 | 5 | //! classification around it, and the encryption on either side. | |
| 6 | 6 | //! | |
| 7 | - | //! Shared fixtures live in [`common`]; a module reaches them with | |
| 8 | - | //! `use crate::common::*;`. | |
| 7 | + | //! Shared fixtures live in [`common`] and the mock harness in [`mockkit`]; a | |
| 8 | + | //! module reaches both with `use crate::common::*;`. | |
| 9 | 9 | ||
| 10 | 10 | mod common; | |
| 11 | + | mod mockkit; | |
| 11 | 12 | ||
| 12 | 13 | mod auth; | |
| 13 | 14 | mod blob; |
| @@ -17,6 +17,7 @@ | |||
| 17 | 17 | const ENTRIES_PATH: &str = "/api/v1/sync/keys/rotate/entries"; | |
| 18 | 18 | const BATCH_PATH: &str = "/api/v1/sync/keys/rotate/batch"; | |
| 19 | 19 | const COMPLETE_PATH: &str = "/api/v1/sync/keys/rotate/complete"; | |
| 20 | + | const PULL_PATH: &str = "/api/v1/sync/pull"; | |
| 20 | 21 | ||
| 21 | 22 | const ROTATE_PW: &str = "rotate-password"; | |
| 22 | 23 | ||
| @@ -37,64 +38,49 @@ | |||
| 37 | 38 | json!({ "seq": 1, "table": table, "row_id": row_id, "data": sealed }) | |
| 38 | 39 | } | |
| 39 | 40 | ||
| 40 | - | fn hits(reqs: &[wiremock::Request], p: &str) -> usize { | |
| 41 | - | reqs.iter().filter(|r| r.url.path() == p).count() | |
| 41 | + | /// The `POST /keys/rotate` answer: a rotation covering `target_seq` entries. | |
| 42 | + | fn begin_body(target_seq: usize) -> serde_json::Value { | |
| 43 | + | json!({ "rotation_id": Uuid::new_v4(), "target_seq": target_seq, "new_key_id": 2 }) | |
| 42 | 44 | } | |
| 43 | 45 | ||
| 44 | 46 | #[tokio::test] | |
| 45 | 47 | async fn rotate_key_drives_full_orchestration() { | |
| 46 | - | let server = MockServer::start().await; | |
| 48 | + | let kit = MockKit::start().await; | |
| 47 | 49 | let old_key = synckit_client::crypto::generate_master_key(); | |
| 48 | 50 | ||
| 49 | - | Mock::given(method("GET")) | |
| 50 | - | .and(path(KEYS_PATH)) | |
| 51 | - | .respond_with(ResponseTemplate::new(200).set_body_json(get_keys_body(&old_key))) | |
| 52 | - | .mount(&server) | |
| 53 | - | .await; | |
| 54 | - | Mock::given(method("POST")) | |
| 55 | - | .and(path(ROTATE_PATH)) | |
| 56 | - | .respond_with(ResponseTemplate::new(200).set_body_json( | |
| 57 | - | json!({ "rotation_id": Uuid::new_v4(), "target_seq": 1, "new_key_id": 2 }), | |
| 58 | - | )) | |
| 59 | - | .mount(&server) | |
| 60 | - | .await; | |
| 51 | + | kit.get(KEYS_PATH).json(get_keys_body(&old_key)).await; | |
| 52 | + | kit.post(ROTATE_PATH).json(begin_body(1)).await; | |
| 61 | 53 | // One batch of work, then drained (has_more = false ends the re-encrypt loop). | |
| 62 | - | Mock::given(method("POST")) | |
| 63 | - | .and(path(ENTRIES_PATH)) | |
| 64 | - | .respond_with(ResponseTemplate::new(200).set_body_json(json!({ | |
| 54 | + | kit.post(ENTRIES_PATH) | |
| 55 | + | .json(json!({ | |
| 65 | 56 | "entries": [rotation_entry(&old_key, "tasks", "r1")], | |
| 66 | 57 | "has_more": false | |
| 67 | - | }))) | |
| 68 | - | .mount(&server) | |
| 58 | + | })) | |
| 69 | 59 | .await; | |
| 70 | - | Mock::given(method("POST")) | |
| 71 | - | .and(path(BATCH_PATH)) | |
| 72 | - | .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "updated_count": 1 }))) | |
| 73 | - | .mount(&server) | |
| 74 | - | .await; | |
| 75 | - | Mock::given(method("POST")) | |
| 76 | - | .and(path(COMPLETE_PATH)) | |
| 77 | - | .respond_with(ResponseTemplate::new(200)) | |
| 78 | - | .mount(&server) | |
| 60 | + | kit.post(BATCH_PATH) | |
| 61 | + | .json(json!({ "updated_count": 1 })) | |
| 79 | 62 | .await; | |
| 63 | + | kit.post(COMPLETE_PATH).empty().await; | |
| 80 | 64 | ||
| 81 | - | let client = authed_client(&server); | |
| 82 | - | client | |
| 65 | + | kit.authed() | |
| 83 | 66 | .rotate_key(DeviceId::new(Uuid::new_v4()), ROTATE_PW) | |
| 84 | 67 | .await | |
| 85 | 68 | .expect("full rotation should complete"); | |
| 86 | 69 | ||
| 87 | 70 | // Every stage of the protocol was driven, in the right shape. | |
| 88 | - | let reqs = server.received_requests().await.unwrap(); | |
| 89 | - | assert_eq!(hits(&reqs, KEYS_PATH), 1, "fetched key state once"); | |
| 90 | - | assert_eq!(hits(&reqs, ROTATE_PATH), 1, "began rotation once"); | |
| 71 | + | assert_eq!(kit.hits(KEYS_PATH).await, 1, "fetched key state once"); | |
| 72 | + | assert_eq!(kit.hits(ROTATE_PATH).await, 1, "began rotation once"); | |
| 91 | 73 | assert!( | |
| 92 | - | hits(&reqs, ENTRIES_PATH) >= 1, | |
| 74 | + | kit.hits(ENTRIES_PATH).await >= 1, | |
| 93 | 75 | "pulled entries to re-encrypt" | |
| 94 | 76 | ); | |
| 95 | - | assert_eq!(hits(&reqs, BATCH_PATH), 1, "pushed one re-encrypted batch"); | |
| 96 | 77 | assert_eq!( | |
| 97 | - | hits(&reqs, COMPLETE_PATH), | |
| 78 | + | kit.hits(BATCH_PATH).await, | |
| 79 | + | 1, | |
| 80 | + | "pushed one re-encrypted batch" | |
| 81 | + | ); | |
| 82 | + | assert_eq!( | |
| 83 | + | kit.hits(COMPLETE_PATH).await, | |
| 98 | 84 | 1, | |
| 99 | 85 | "completed once (no stragglers)" | |
| 100 | 86 | ); | |
| @@ -102,74 +88,49 @@ | |||
| 102 | 88 | ||
| 103 | 89 | #[tokio::test] | |
| 104 | 90 | async fn rotate_key_retries_reencrypt_on_straggler_conflict() { | |
| 105 | - | let server = MockServer::start().await; | |
| 91 | + | let kit = MockKit::start().await; | |
| 106 | 92 | let old_key = synckit_client::crypto::generate_master_key(); | |
| 107 | 93 | ||
| 108 | - | Mock::given(method("GET")) | |
| 109 | - | .and(path(KEYS_PATH)) | |
| 110 | - | .respond_with(ResponseTemplate::new(200).set_body_json(get_keys_body(&old_key))) | |
| 111 | - | .mount(&server) | |
| 112 | - | .await; | |
| 113 | - | Mock::given(method("POST")) | |
| 114 | - | .and(path(ROTATE_PATH)) | |
| 115 | - | .respond_with(ResponseTemplate::new(200).set_body_json( | |
| 116 | - | json!({ "rotation_id": Uuid::new_v4(), "target_seq": 1, "new_key_id": 2 }), | |
| 117 | - | )) | |
| 118 | - | .mount(&server) | |
| 119 | - | .await; | |
| 94 | + | kit.get(KEYS_PATH).json(get_keys_body(&old_key)).await; | |
| 95 | + | kit.post(ROTATE_PATH).json(begin_body(1)).await; | |
| 120 | 96 | // First entries pull returns work; every later pull is drained. Mounted in | |
| 121 | - | // this order so the up_to_n_times(1) mock wins the first call, then the | |
| 122 | - | // empty-set fallback serves the straggler round's re-pull. | |
| 123 | - | Mock::given(method("POST")) | |
| 124 | - | .and(path(ENTRIES_PATH)) | |
| 125 | - | .respond_with(ResponseTemplate::new(200).set_body_json(json!({ | |
| 97 | + | // this order so the `once()` mock wins the first call, then the empty-set | |
| 98 | + | // fallback serves the straggler round's re-pull. | |
| 99 | + | kit.post(ENTRIES_PATH) | |
| 100 | + | .once() | |
| 101 | + | .json(json!({ | |
| 126 | 102 | "entries": [rotation_entry(&old_key, "tasks", "r1")], | |
| 127 | 103 | "has_more": false | |
| 128 | - | }))) | |
| 129 | - | .up_to_n_times(1) | |
| 130 | - | .mount(&server) | |
| 104 | + | })) | |
| 131 | 105 | .await; | |
| 132 | - | Mock::given(method("POST")) | |
| 133 | - | .and(path(ENTRIES_PATH)) | |
| 134 | - | .respond_with( | |
| 135 | - | ResponseTemplate::new(200).set_body_json(json!({ "entries": [], "has_more": false })), | |
| 136 | - | ) | |
| 137 | - | .mount(&server) | |
| 106 | + | kit.post(ENTRIES_PATH) | |
| 107 | + | .json(json!({ "entries": [], "has_more": false })) | |
| 138 | 108 | .await; | |
| 139 | - | Mock::given(method("POST")) | |
| 140 | - | .and(path(BATCH_PATH)) | |
| 141 | - | .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "updated_count": 1 }))) | |
| 142 | - | .mount(&server) | |
| 109 | + | kit.post(BATCH_PATH) | |
| 110 | + | .json(json!({ "updated_count": 1 })) | |
| 143 | 111 | .await; | |
| 144 | 112 | // First completion reports a straggler (409); the retry then succeeds. | |
| 145 | - | Mock::given(method("POST")) | |
| 146 | - | .and(path(COMPLETE_PATH)) | |
| 147 | - | .respond_with(ResponseTemplate::new(409).set_body_json(json!({ "message": "stragglers" }))) | |
| 148 | - | .up_to_n_times(1) | |
| 149 | - | .mount(&server) | |
| 150 | - | .await; | |
| 151 | - | Mock::given(method("POST")) | |
| 152 | - | .and(path(COMPLETE_PATH)) | |
| 153 | - | .respond_with(ResponseTemplate::new(200)) | |
| 154 | - | .mount(&server) | |
| 113 | + | kit.post(COMPLETE_PATH) | |
| 114 | + | .code(409) | |
| 115 | + | .once() | |
| 116 | + | .json(json!({ "message": "stragglers" })) | |
| 155 | 117 | .await; | |
| 118 | + | kit.post(COMPLETE_PATH).empty().await; | |
| 156 | 119 | ||
| 157 | - | let client = authed_client(&server); | |
| 158 | - | client | |
| 120 | + | kit.authed() | |
| 159 | 121 | .rotate_key(DeviceId::new(Uuid::new_v4()), ROTATE_PW) | |
| 160 | 122 | .await | |
| 161 | 123 | .expect("rotation should converge after the straggler retry"); | |
| 162 | 124 | ||
| 163 | 125 | // The 409 forced a second completion attempt, and the straggler round | |
| 164 | 126 | // re-ran the re-encrypt loop (a second entries pull). | |
| 165 | - | let reqs = server.received_requests().await.unwrap(); | |
| 166 | 127 | assert_eq!( | |
| 167 | - | hits(&reqs, COMPLETE_PATH), | |
| 128 | + | kit.hits(COMPLETE_PATH).await, | |
| 168 | 129 | 2, | |
| 169 | 130 | "completed twice: 409 then 200" | |
| 170 | 131 | ); | |
| 171 | 132 | assert!( | |
| 172 | - | hits(&reqs, ENTRIES_PATH) >= 2, | |
| 133 | + | kit.hits(ENTRIES_PATH).await >= 2, | |
| 173 | 134 | "straggler round re-pulled entries" | |
| 174 | 135 | ); | |
| 175 | 136 | } | |
| @@ -269,8 +230,8 @@ | |||
| 269 | 230 | ||
| 270 | 231 | // Run A: pull before the rotation, everything under the old key. | |
| 271 | 232 | let before = { | |
| 272 | - | let server = MockServer::start().await; | |
| 273 | - | let client = authed_client(&server); | |
| 233 | + | let kit = MockKit::start().await; | |
| 234 | + | let client = kit.authed(); | |
| 274 | 235 | client.set_master_key_raw(old_key); | |
| 275 | 236 | let wire: Vec<serde_json::Value> = rows | |
| 276 | 237 | .iter() | |
| @@ -280,14 +241,12 @@ | |||
| 280 | 241 | pull_wire(device_id, i as i64 + 1, table, row_id, data, 1) | |
| 281 | 242 | }) | |
| 282 | 243 | .collect(); | |
| 283 | - | Mock::given(method("POST")) | |
| 284 | - | .and(path("/api/v1/sync/pull")) | |
| 285 | - | .respond_with(ResponseTemplate::new(200).set_body_json(json!({ | |
| 244 | + | kit.post(PULL_PATH) | |
| 245 | + | .json(json!({ | |
| 286 | 246 | "changes": wire, | |
| 287 | 247 | "cursor": rows.len(), | |
| 288 | 248 | "has_more": false, | |
| 289 | - | }))) | |
| 290 | - | .mount(&server) | |
| 249 | + | })) | |
| 291 | 250 | .await; | |
| 292 | 251 | ||
| 293 | 252 | let (changes, _, _) = client.pull(device_id, 0).await.unwrap(); | |
| @@ -297,28 +256,19 @@ | |||
| 297 | 256 | // The rotation itself, driven through the full protocol. `pending_key` makes | |
| 298 | 257 | // it the resume path, so the client adopts `new_key` instead of minting one. | |
| 299 | 258 | let reencrypted = { | |
| 300 | - | let server = MockServer::start().await; | |
| 301 | - | let keys_body = json!({ | |
| 302 | - | "encrypted_key": synckit_client::crypto::wrap_master_key(&old_key, ROTATE_PW).unwrap(), | |
| 303 | - | "key_version": 1, | |
| 304 | - | "key_id": 1, | |
| 305 | - | "pending_key": { | |
| 306 | - | "encrypted_key": synckit_client::crypto::wrap_master_key(&new_key, ROTATE_PW).unwrap(), | |
| 307 | - | "key_id": 2, | |
| 308 | - | }, | |
| 309 | - | }); | |
| 310 | - | Mock::given(method("GET")) | |
| 311 | - | .and(path(KEYS_PATH)) | |
| 312 | - | .respond_with(ResponseTemplate::new(200).set_body_json(keys_body)) | |
| 313 | - | .mount(&server) | |
| 314 | - | .await; | |
| 315 | - | Mock::given(method("POST")) | |
| 316 | - | .and(path(ROTATE_PATH)) | |
| 317 | - | .respond_with(ResponseTemplate::new(200).set_body_json( | |
| 318 | - | json!({ "rotation_id": Uuid::new_v4(), "target_seq": rows.len(), "new_key_id": 2 }), | |
| 319 | - | )) | |
| 320 | - | .mount(&server) | |
| 259 | + | let kit = MockKit::start().await; | |
| 260 | + | kit.get(KEYS_PATH) | |
| 261 | + | .json(json!({ | |
| 262 | + | "encrypted_key": synckit_client::crypto::wrap_master_key(&old_key, ROTATE_PW).unwrap(), | |
| 263 | + | "key_version": 1, | |
| 264 | + | "key_id": 1, | |
| 265 | + | "pending_key": { | |
| 266 | + | "encrypted_key": synckit_client::crypto::wrap_master_key(&new_key, ROTATE_PW).unwrap(), | |
| 267 | + | "key_id": 2, | |
| 268 | + | }, | |
| 269 | + | })) | |
| 321 | 270 | .await; | |
| 271 | + | kit.post(ROTATE_PATH).json(begin_body(rows.len())).await; | |
| 322 | 272 | let entries: Vec<serde_json::Value> = rows | |
| 323 | 273 | .iter() | |
| 324 | 274 | .zip(&sealed_under_old) | |
| @@ -327,37 +277,19 @@ | |||
| 327 | 277 | json!({ "seq": i as i64 + 1, "table": table, "row_id": row_id, "data": data }) | |
| 328 | 278 | }) | |
| 329 | 279 | .collect(); | |
| 330 | - | Mock::given(method("POST")) | |
| 331 | - | .and(path(ENTRIES_PATH)) | |
| 332 | - | .respond_with( | |
| 333 | - | ResponseTemplate::new(200) | |
| 334 | - | .set_body_json(json!({ "entries": entries, "has_more": false })), | |
| 335 | - | ) | |
| 336 | - | .up_to_n_times(1) | |
| 337 | - | .mount(&server) | |
| 280 | + | kit.post(ENTRIES_PATH) | |
| 281 | + | .once() | |
| 282 | + | .json(json!({ "entries": entries, "has_more": false })) | |
| 338 | 283 | .await; | |
| 339 | - | Mock::given(method("POST")) | |
| 340 | - | .and(path(ENTRIES_PATH)) | |
| 341 | - | .respond_with( | |
| 342 | - | ResponseTemplate::new(200) | |
| 343 | - | .set_body_json(json!({ "entries": [], "has_more": false })), | |
| 344 | - | ) | |
| 345 | - | .mount(&server) | |
| 284 | + | kit.post(ENTRIES_PATH) | |
| 285 | + | .json(json!({ "entries": [], "has_more": false })) | |
| 346 | 286 | .await; | |
| 347 | - | Mock::given(method("POST")) | |
| 348 | - | .and(path(BATCH_PATH)) | |
| 349 | - | .respond_with( | |
| 350 | - | ResponseTemplate::new(200).set_body_json(json!({ "updated_count": rows.len() })), | |
| 351 | - | ) | |
| 352 | - | .mount(&server) | |
| 353 | - | .await; | |
| 354 | - | Mock::given(method("POST")) | |
| 355 | - | .and(path(COMPLETE_PATH)) | |
| 356 | - | .respond_with(ResponseTemplate::new(200)) | |
| 357 | - | .mount(&server) | |
| 287 | + | kit.post(BATCH_PATH) | |
| 288 | + | .json(json!({ "updated_count": rows.len() })) | |
| 358 | 289 | .await; | |
| 290 | + | kit.post(COMPLETE_PATH).empty().await; | |
| 359 | 291 | ||
| 360 | - | let client = authed_client(&server); | |
| 292 | + | let client = kit.authed(); | |
| 361 | 293 | client.set_master_key_raw(old_key); | |
| 362 | 294 | client | |
| 363 | 295 | .rotate_key(device_id, ROTATE_PW) | |
| @@ -365,12 +297,7 @@ | |||
| 365 | 297 | .expect("the rotation should complete"); | |
| 366 | 298 | ||
| 367 | 299 | // Take back what the client re-encrypted, keyed by seq. | |
| 368 | - | let reqs = server.received_requests().await.unwrap(); | |
| 369 | - | let batch = reqs | |
| 370 | - | .iter() | |
| 371 | - | .find(|r| r.url.path() == BATCH_PATH) | |
| 372 | - | .expect("the client should have pushed a re-encrypted batch"); | |
| 373 | - | let body: serde_json::Value = serde_json::from_slice(&batch.body).unwrap(); | |
| 300 | + | let body = kit.body(BATCH_PATH).await; | |
| 374 | 301 | let entries = body["entries"] | |
| 375 | 302 | .as_array() | |
| 376 | 303 | .expect("batch body should carry entries") | |
| @@ -385,8 +312,8 @@ | |||
| 385 | 312 | ||
| 386 | 313 | // Run B: pull after the rotation, replaying the re-encrypted bytes. | |
| 387 | 314 | let after = { | |
| 388 | - | let server = MockServer::start().await; | |
| 389 | - | let client = authed_client(&server); | |
| 315 | + | let kit = MockKit::start().await; | |
| 316 | + | let client = kit.authed(); | |
| 390 | 317 | client.set_master_key_raw(new_key); | |
| 391 | 318 | let wire: Vec<serde_json::Value> = reencrypted | |
| 392 | 319 | .iter() | |
| @@ -396,14 +323,12 @@ | |||
| 396 | 323 | pull_wire(device_id, seq, table, row_id, &entry["data"], 2) | |
| 397 | 324 | }) | |
| 398 | 325 | .collect(); | |
| 399 | - | Mock::given(method("POST")) | |
| 400 | - | .and(path("/api/v1/sync/pull")) | |
| 401 | - | .respond_with(ResponseTemplate::new(200).set_body_json(json!({ | |
| 326 | + | kit.post(PULL_PATH) | |
| 327 | + | .json(json!({ | |
| 402 | 328 | "changes": wire, | |
| 403 | 329 | "cursor": rows.len(), | |
| 404 | 330 | "has_more": false, | |
| 405 | - | }))) | |
| 406 | - | .mount(&server) | |
| 331 | + | })) | |
| 407 | 332 | .await; | |
| 408 | 333 | ||
| 409 | 334 | let (changes, _, _) = client.pull(device_id, 0).await.unwrap(); |
| @@ -19,14 +19,10 @@ | |||
| 19 | 19 | ||
| 20 | 20 | #[tokio::test] | |
| 21 | 21 | async fn subscribe_yields_changed_event() { | |
| 22 | - | let server = MockServer::start().await; | |
| 23 | - | Mock::given(method("GET")) | |
| 24 | - | .and(path(SUBSCRIBE_PATH)) | |
| 25 | - | .respond_with(ResponseTemplate::new(200).set_body_string(sse_changed_block())) | |
| 26 | - | .mount(&server) | |
| 27 | - | .await; | |
| 22 | + | let kit = MockKit::start().await; | |
| 23 | + | kit.get(SUBSCRIBE_PATH).text(sse_changed_block()).await; | |
| 28 | 24 | ||
| 29 | - | let client = authed_client(&server); | |
| 25 | + | let client = kit.authed(); | |
| 30 | 26 | let mut stream = client.subscribe().await.expect("subscribe should succeed"); | |
| 31 | 27 | ||
| 32 | 28 | // The first block is delivered inside the initial response body, so this | |
| @@ -36,17 +32,13 @@ | |||
| 36 | 32 | ||
| 37 | 33 | #[tokio::test] | |
| 38 | 34 | async fn subscribe_reconnects_transparently_after_stream_drop() { | |
| 39 | - | let server = MockServer::start().await; | |
| 35 | + | let kit = MockKit::start().await; | |
| 40 | 36 | // Every connection serves one block then ends (Content-Length terminates the | |
| 41 | 37 | // body). Consuming the first event, then reading past it, drops the stream | |
| 42 | 38 | // and forces a reconnect that must transparently yield the next event. | |
| 43 | - | Mock::given(method("GET")) | |
| 44 | - | .and(path(SUBSCRIBE_PATH)) | |
| 45 | - | .respond_with(ResponseTemplate::new(200).set_body_string(sse_changed_block())) | |
| 46 | - | .mount(&server) | |
| 47 | - | .await; | |
| 39 | + | kit.get(SUBSCRIBE_PATH).text(sse_changed_block()).await; | |
| 48 | 40 | ||
| 49 | - | let client = authed_client(&server); | |
| 41 | + | let client = kit.authed(); | |
| 50 | 42 | let mut stream = client.subscribe().await.expect("subscribe should succeed"); | |
| 51 | 43 | ||
| 52 | 44 | // #1 comes from the initial connection; #2 can only arrive after the stream | |
| @@ -56,13 +48,7 @@ | |||
| 56 | 48 | ||
| 57 | 49 | // subscribe() opened one connection; the second event required at least one | |
| 58 | 50 | // reconnect, so the server saw two or more subscribe requests. | |
| 59 | - | let hits = server | |
| 60 | - | .received_requests() | |
| 61 | - | .await | |
| 62 | - | .unwrap() | |
| 63 | - | .into_iter() | |
| 64 | - | .filter(|r| r.url.path() == SUBSCRIBE_PATH) | |
| 65 | - | .count(); | |
| 51 | + | let hits = kit.hits(SUBSCRIBE_PATH).await; | |
| 66 | 52 | assert!( | |
| 67 | 53 | hits >= 2, | |
| 68 | 54 | "expected a reconnect (>=2 subscribe requests), got {hits}" | |
| @@ -71,23 +57,14 @@ | |||
| 71 | 57 | ||
| 72 | 58 | #[tokio::test] | |
| 73 | 59 | async fn subscribe_stream_closes_on_auth_rejection() { | |
| 74 | - | let server = MockServer::start().await; | |
| 60 | + | let kit = MockKit::start().await; | |
| 75 | 61 | // First connection opens cleanly but carries no event and ends immediately, | |
| 76 | 62 | // forcing a reconnect. The reconnect is rejected for auth -> fatal, so the | |
| 77 | 63 | // stream ends with `None` rather than retrying. | |
| 78 | - | Mock::given(method("GET")) | |
| 79 | - | .and(path(SUBSCRIBE_PATH)) | |
| 80 | - | .respond_with(ResponseTemplate::new(200).set_body_string("")) | |
| 81 | - | .up_to_n_times(1) | |
| 82 | - | .mount(&server) | |
| 83 | - | .await; | |
| 84 | - | Mock::given(method("GET")) | |
| 85 | - | .and(path(SUBSCRIBE_PATH)) | |
| 86 | - | .respond_with(ResponseTemplate::new(401).set_body_string("unauthorized")) | |
| 87 | - | .mount(&server) | |
| 88 | - | .await; | |
| 64 | + | kit.get(SUBSCRIBE_PATH).once().text("").await; | |
| 65 | + | kit.get(SUBSCRIBE_PATH).code(401).text("unauthorized").await; | |
| 89 | 66 | ||
| 90 | - | let client = authed_client(&server); | |
| 67 | + | let client = kit.authed(); | |
| 91 | 68 | let mut stream = client.subscribe().await.expect("initial subscribe is 200"); | |
| 92 | 69 | ||
| 93 | 70 | // Empty body -> EOF -> reconnect -> 401 -> fatal -> None. |
| @@ -3,35 +3,36 @@ | |||
| 3 | 3 | ||
| 4 | 4 | use crate::common::*; | |
| 5 | 5 | ||
| 6 | + | const PUSH_PATH: &str = "/api/v1/sync/push"; | |
| 7 | + | const PULL_PATH: &str = "/api/v1/sync/pull"; | |
| 8 | + | ||
| 9 | + | /// One Insert the way a caller builds it, with a zero clock: these tests assert | |
| 10 | + | /// on what crosses the wire, never on HLC ordering. | |
| 11 | + | fn insert(table: &str, row_id: &str, data: serde_json::Value) -> ChangeEntry { | |
| 12 | + | ChangeEntry { | |
| 13 | + | table: table.into(), | |
| 14 | + | op: ChangeOp::Insert, | |
| 15 | + | row_id: row_id.into(), | |
| 16 | + | timestamp: Utc::now(), | |
| 17 | + | hlc: Hlc::zero(DeviceId::nil()), | |
| 18 | + | data: Some(data), | |
| 19 | + | extra: serde_json::Map::default(), | |
| 20 | + | } | |
| 21 | + | } | |
| 22 | + | ||
| 6 | 23 | // ── Push / Pull with encryption ── | |
| 7 | 24 | ||
| 8 | 25 | #[tokio::test] | |
| 9 | 26 | async fn push_encrypts_data() { | |
| 10 | - | let server = MockServer::start().await; | |
| 11 | - | ||
| 12 | - | Mock::given(method("POST")) | |
| 13 | - | .and(path("/api/v1/sync/push")) | |
| 14 | - | .respond_with(ResponseTemplate::new(200).set_body_json(json!({"cursor": 1}))) | |
| 15 | - | .mount(&server) | |
| 16 | - | .await; | |
| 17 | - | ||
| 18 | - | let client = authed_client(&server); | |
| 19 | - | let key = synckit_client::crypto::generate_master_key(); | |
| 20 | - | client.set_master_key_raw(key); | |
| 27 | + | let kit = MockKit::start().await; | |
| 28 | + | kit.post(PUSH_PATH).json(json!({"cursor": 1})).await; | |
| 21 | 29 | ||
| 30 | + | let (client, _key) = kit.keyed(); | |
| 22 | 31 | let device_id = DeviceId::new(Uuid::new_v4()); | |
| 23 | 32 | let cursor = client | |
| 24 | 33 | .push( | |
| 25 | 34 | device_id, | |
| 26 | - | vec![ChangeEntry { | |
| 27 | - | table: "tasks".into(), | |
| 28 | - | op: ChangeOp::Insert, | |
| 29 | - | row_id: "row-1".into(), | |
| 30 | - | timestamp: Utc::now(), | |
| 31 | - | hlc: Hlc::zero(DeviceId::nil()), | |
| 32 | - | data: Some(json!({"title": "Secret task"})), | |
| 33 | - | extra: serde_json::Map::default(), | |
| 34 | - | }], | |
| 35 | + | vec![insert("tasks", "row-1", json!({"title": "Secret task"}))], | |
| 35 | 36 | ) | |
| 36 | 37 | .await | |
| 37 | 38 | .unwrap(); | |
| @@ -39,12 +40,7 @@ | |||
| 39 | 40 | assert_eq!(cursor, 1); | |
| 40 | 41 | ||
| 41 | 42 | // Verify the request body was sent with encrypted data (not plaintext) | |
| 42 | - | let requests = server.received_requests().await.unwrap(); | |
| 43 | - | let push_req = requests | |
| 44 | - | .iter() | |
| 45 | - | .find(|r| r.url.path() == "/api/v1/sync/push") | |
| 46 | - | .unwrap(); | |
| 47 | - | let body: serde_json::Value = serde_json::from_slice(&push_req.body).unwrap(); | |
| 43 | + | let body = kit.body(PUSH_PATH).await; | |
| 48 | 44 | let wire_data = body["changes"][0]["data"].as_str().unwrap(); | |
| 49 | 45 | assert!( | |
| 50 | 46 | !wire_data.contains("Secret task"), | |
| @@ -54,20 +50,16 @@ | |||
| 54 | 50 | ||
| 55 | 51 | #[tokio::test] | |
| 56 | 52 | async fn pull_decrypts_data() { | |
| 57 | - | let server = MockServer::start().await; | |
| 58 | - | ||
| 59 | - | let client = authed_client(&server); | |
| 60 | - | let key = synckit_client::crypto::generate_master_key(); | |
| 61 | - | client.set_master_key_raw(key); | |
| 53 | + | let kit = MockKit::start().await; | |
| 54 | + | let (client, key) = kit.keyed(); | |
| 62 | 55 | ||
| 63 | 56 | // Encrypt a value to simulate what the server would return | |
| 64 | 57 | let plaintext = json!({"title": "Decrypted task"}); | |
| 65 | 58 | let encrypted = synckit_client::crypto::encrypt_json(&plaintext, &key).unwrap(); | |
| 66 | 59 | ||
| 67 | 60 | let device_id = DeviceId::new(Uuid::new_v4()); | |
| 68 | - | Mock::given(method("POST")) | |
| 69 | - | .and(path("/api/v1/sync/pull")) | |
| 70 | - | .respond_with(ResponseTemplate::new(200).set_body_json(json!({ | |
| 61 | + | kit.post(PULL_PATH) | |
| 62 | + | .json(json!({ | |
| 71 | 63 | "changes": [{ | |
| 72 | 64 | "seq": 1, | |
| 73 | 65 | "device_id": device_id, | |
| @@ -79,8 +71,7 @@ | |||
| 79 | 71 | }], | |
| 80 | 72 | "cursor": 1, | |
| 81 | 73 | "has_more": false, | |
| 82 | - | }))) | |
| 83 | - | .mount(&server) | |
| 74 | + | })) | |
| 84 | 75 | .await; | |
| 85 | 76 | ||
| 86 | 77 | let (changes, cursor, has_more) = client.pull(device_id, 0).await.unwrap(); | |
| @@ -92,25 +83,11 @@ | |||
| 92 | 83 | ||
| 93 | 84 | #[tokio::test] | |
| 94 | 85 | async fn push_retries_on_503() { | |
| 95 | - | let server = MockServer::start().await; | |
| 96 | - | ||
| 97 | - | Mock::given(method("POST")) | |
| 98 | - | .and(path("/api/v1/sync/push")) | |
| 99 | - | .respond_with(ResponseTemplate::new(503)) | |
| 100 | - | .up_to_n_times(1) | |
| 101 | - | .mount(&server) | |
| 102 | - | .await; | |
| 103 | - | ||
| 104 | - | Mock::given(method("POST")) | |
| 105 | - | .and(path("/api/v1/sync/push")) | |
| 106 | - | .respond_with(ResponseTemplate::new(200).set_body_json(json!({"cursor": 5}))) | |
| 107 | - | .mount(&server) | |
| 108 | - | .await; | |
| 109 | - | ||
| 110 | - | let client = authed_client(&server); | |
| 111 | - | let key = synckit_client::crypto::generate_master_key(); | |
| 112 | - | client.set_master_key_raw(key); | |
| 86 | + | let kit = MockKit::start().await; | |
| 87 | + | kit.post(PUSH_PATH).code(503).once().empty().await; | |
| 88 | + | kit.post(PUSH_PATH).json(json!({"cursor": 5})).await; | |
| 113 | 89 | ||
| 90 | + | let (client, _key) = kit.keyed(); | |
| 114 | 91 | let cursor = client | |
| 115 | 92 | .push(DeviceId::new(Uuid::new_v4()), vec![]) | |
| 116 | 93 | .await | |
| @@ -120,19 +97,14 @@ | |||
| 120 | 97 | ||
| 121 | 98 | #[tokio::test] | |
| 122 | 99 | async fn push_fails_immediately_on_401() { | |
| 123 | - | let server = MockServer::start().await; | |
| 124 | - | ||
| 125 | - | Mock::given(method("POST")) | |
| 126 | - | .and(path("/api/v1/sync/push")) | |
| 127 | - | .respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized")) | |
| 128 | - | .expect(1) | |
| 129 | - | .mount(&server) | |
| 100 | + | let kit = MockKit::start().await; | |
| 101 | + | kit.post(PUSH_PATH) | |
| 102 | + | .code(401) | |
| 103 | + | .exactly(1) | |
| 104 | + | .text("Unauthorized") | |
| 130 | 105 | .await; | |
| 131 | 106 | ||
| 132 | - | let client = authed_client(&server); | |
| 133 | - | let key = synckit_client::crypto::generate_master_key(); | |
| 134 | - | client.set_master_key_raw(key); | |
| 135 | - | ||
| 107 | + | let (client, _key) = kit.keyed(); | |
| 136 | 108 | let err = client | |
| 137 | 109 | .push(DeviceId::new(Uuid::new_v4()), vec![]) | |
| 138 | 110 | .await | |
| @@ -142,24 +114,18 @@ | |||
| 142 | 114 | ||
| 143 | 115 | #[tokio::test] | |
| 144 | 116 | async fn pull_with_has_more_pagination() { | |
| 145 | - | let server = MockServer::start().await; | |
| 146 | - | ||
| 147 | - | let client = authed_client(&server); | |
| 148 | - | let key = synckit_client::crypto::generate_master_key(); | |
| 149 | - | client.set_master_key_raw(key); | |
| 150 | - | ||
| 117 | + | let kit = MockKit::start().await; | |
| 118 | + | let (client, _key) = kit.keyed(); | |
| 151 | 119 | let device_id = DeviceId::new(Uuid::new_v4()); | |
| 152 | 120 | ||
| 153 | 121 | // First pull: has_more = true | |
| 154 | - | Mock::given(method("POST")) | |
| 155 | - | .and(path("/api/v1/sync/pull")) | |
| 156 | - | .respond_with(ResponseTemplate::new(200).set_body_json(json!({ | |
| 122 | + | kit.post(PULL_PATH) | |
| 123 | + | .once() | |
| 124 | + | .json(json!({ | |
| 157 | 125 | "changes": [], | |
| 158 | 126 | "cursor": 50, | |
| 159 | 127 | "has_more": true, | |
| 160 | - | }))) | |
| 161 | - | .up_to_n_times(1) | |
| 162 | - | .mount(&server) | |
| 128 | + | })) | |
| 163 | 129 | .await; | |
| 164 | 130 | ||
| 165 | 131 | let (changes, cursor, has_more) = client.pull(device_id, 0).await.unwrap(); | |
| @@ -168,14 +134,12 @@ | |||
| 168 | 134 | assert!(has_more); | |
| 169 | 135 | ||
| 170 | 136 | // Second pull from cursor 50: has_more = false | |
| 171 | - | Mock::given(method("POST")) | |
| 172 | - | .and(path("/api/v1/sync/pull")) | |
| 173 | - | .respond_with(ResponseTemplate::new(200).set_body_json(json!({ | |
| 137 | + | kit.post(PULL_PATH) | |
| 138 | + | .json(json!({ | |
| 174 | 139 | "changes": [], | |
| 175 | 140 | "cursor": 100, | |
| 176 | 141 | "has_more": false, | |
| 177 | - | }))) | |
| 178 | - | .mount(&server) | |
| 142 | + | })) | |
| 179 | 143 | .await; | |
| 180 | 144 | ||
| 181 | 145 | let (_, cursor2, has_more2) = client.pull(device_id, 50).await.unwrap(); | |
| @@ -187,18 +151,10 @@ | |||
| 187 | 151 | ||
| 188 | 152 | #[tokio::test] | |
| 189 | 153 | async fn push_empty_changes_succeeds() { | |
| 190 | - | let server = MockServer::start().await; | |
| 191 | - | ||
| 192 | - | Mock::given(method("POST")) | |
| 193 | - | .and(path("/api/v1/sync/push")) | |
| 194 | - | .respond_with(ResponseTemplate::new(200).set_body_json(json!({"cursor": 0}))) | |
| 195 | - | .mount(&server) | |
| 196 | - | .await; | |
| 197 | - | ||
| 198 | - | let client = authed_client(&server); | |
| 199 | - | let key = synckit_client::crypto::generate_master_key(); | |
| 200 | - | client.set_master_key_raw(key); | |
| 154 | + | let kit = MockKit::start().await; | |
| 155 | + | kit.post(PUSH_PATH).json(json!({"cursor": 0})).await; | |
| 201 | 156 | ||
| 157 | + | let (client, _key) = kit.keyed(); | |
| 202 | 158 | let cursor = client | |
| 203 | 159 | .push(DeviceId::new(Uuid::new_v4()), vec![]) | |
| 204 | 160 | .await | |
| @@ -210,28 +166,19 @@ | |||
| 210 | 166 | ||
| 211 | 167 | #[tokio::test] | |
| 212 | 168 | async fn push_many_changes_succeeds() { | |
| 213 | - | let server = MockServer::start().await; | |
| 169 | + | let kit = MockKit::start().await; | |
| 170 | + | kit.post(PUSH_PATH).json(json!({"cursor": 1000})).await; | |
| 214 | 171 | ||
| 215 | - | Mock::given(method("POST")) | |
| 216 | - | .and(path("/api/v1/sync/push")) | |
| 217 | - | .respond_with(ResponseTemplate::new(200).set_body_json(json!({"cursor": 1000}))) | |
| 218 | - | .mount(&server) | |
| 219 | - | .await; | |
| 220 | - | ||
| 221 | - | let client = authed_client(&server); | |
| 222 | - | let key = synckit_client::crypto::generate_master_key(); | |
| 223 | - | client.set_master_key_raw(key); | |
| 172 | + | let (client, _key) = kit.keyed(); | |
| 224 | 173 | ||
| 225 | 174 | // Create 1000+ change entries | |
| 226 | 175 | let changes: Vec<ChangeEntry> = (0..1100) | |
| 227 | - | .map(|i| ChangeEntry { | |
| 228 | - | table: "bulk_table".into(), | |
| 229 | - | op: ChangeOp::Insert, | |
| 230 | - | row_id: format!("row-{i}"), | |
| 231 | - | timestamp: Utc::now(), | |
| 232 | - | hlc: Hlc::zero(DeviceId::nil()), | |
| 233 | - | data: Some(json!({"index": i, "value": format!("data-{i}")})), | |
| 234 | - | extra: serde_json::Map::default(), | |
| 176 | + | .map(|i| { | |
| 177 | + | insert( | |
| 178 | + | "bulk_table", | |
| 179 | + | &format!("row-{i}"), | |
| 180 | + | json!({"index": i, "value": format!("data-{i}")}), | |
| 181 | + | ) | |
| 235 | 182 | }) | |
| 236 | 183 | .collect(); | |
| 237 | 184 | ||
| @@ -246,22 +193,14 @@ | |||
| 246 | 193 | ||
| 247 | 194 | #[tokio::test] | |
| 248 | 195 | async fn push_with_data_fails_without_master_key() { | |
| 249 | - | let server = MockServer::start().await; | |
| 250 | - | let client = authed_client(&server); | |
| 251 | - | // No master key set | |
| 252 | - | ||
| 253 | - | let changes = vec![ChangeEntry { | |
| 254 | - | table: "tasks".into(), | |
| 255 | - | op: ChangeOp::Insert, | |
| 256 | - | row_id: "r1".into(), | |
| 257 | - | timestamp: Utc::now(), | |
| 258 | - | hlc: Hlc::zero(DeviceId::nil()), | |
| 259 | - | data: Some(json!({"title": "test"})), | |
| 260 | - | extra: serde_json::Map::default(), | |
| 261 | - | }]; | |
| 196 | + | let kit = MockKit::start().await; | |
| 197 | + | let client = kit.authed(); // No master key | |
| 262 | 198 | ||
| 263 | 199 | let err = client | |
| 264 | - | .push(DeviceId::new(Uuid::new_v4()), changes) | |
| 200 | + | .push( | |
| 201 | + | DeviceId::new(Uuid::new_v4()), | |
| 202 | + | vec![insert("tasks", "r1", json!({"title": "test"}))], | |
| 203 | + | ) | |
| 265 | 204 | .await | |
| 266 | 205 | .unwrap_err(); | |
| 267 | 206 | assert!( | |
| @@ -275,16 +214,10 @@ | |||
| 275 | 214 | // Deletes used to push without a key (no payload to encrypt). With HLC, a | |
| 276 | 215 | // Delete now seals its clock into an encrypted envelope, so the master key is | |
| 277 | 216 | // required for every push, Deletes included. | |
| 278 | - | let server = MockServer::start().await; | |
| 217 | + | let kit = MockKit::start().await; | |
| 218 | + | kit.post(PUSH_PATH).json(json!({"cursor": 1})).await; | |
| 279 | 219 | ||
| 280 | - | Mock::given(method("POST")) | |
| 281 | - | .and(path("/api/v1/sync/push")) | |
| 282 | - | .respond_with(ResponseTemplate::new(200).set_body_json(json!({"cursor": 1}))) | |
| 283 | - | .mount(&server) | |
| 284 | - | .await; | |
| 285 | - | ||
| 286 | - | let client = authed_client(&server); | |
| 287 | - | // No master key loaded. | |
| 220 | + | let client = kit.authed(); // No master key loaded. | |
| 288 | 221 | ||
| 289 | 222 | let changes = vec![ChangeEntry { | |
| 290 | 223 | table: "tasks".into(), | |
| @@ -310,35 +243,14 @@ | |||
| 310 | 243 | ||
| 311 | 244 | #[tokio::test] | |
| 312 | 245 | async fn double_push_same_data_both_succeed() { | |
| 313 | - | let server = MockServer::start().await; | |
| 246 | + | let kit = MockKit::start().await; | |
| 314 | 247 | ||
| 315 | 248 | // Server returns incrementing cursors | |
| 316 | - | Mock::given(method("POST")) | |
| 317 | - | .and(path("/api/v1/sync/push")) | |
| 318 | - | .respond_with(ResponseTemplate::new(200).set_body_json(json!({"cursor": 1}))) | |
| 319 | - | .up_to_n_times(1) | |
| 320 | - | .mount(&server) | |
| 321 | - | .await; | |
| 249 | + | kit.post(PUSH_PATH).once().json(json!({"cursor": 1})).await; | |
| 250 | + | kit.post(PUSH_PATH).json(json!({"cursor": 2})).await; | |
| 322 | 251 | ||
| 323 | - | Mock::given(method("POST")) | |
| 324 | - | .and(path("/api/v1/sync/push")) | |
| 325 | - | .respond_with(ResponseTemplate::new(200).set_body_json(json!({"cursor": 2}))) | |
| 326 | - | .mount(&server) | |
| 327 | - | .await; | |
| 328 | - | ||
| 329 | - | let client = authed_client(&server); | |
| 330 | - | let key = synckit_client::crypto::generate_master_key(); | |
| 331 | - | client.set_master_key_raw(key); | |
| 332 | - | ||
| 333 | - | let entry = ChangeEntry { | |
| 334 | - | table: "tasks".into(), | |
| 335 | - | op: ChangeOp::Insert, | |
| 336 | - | row_id: "same-row".into(), | |
| 337 | - | timestamp: Utc::now(), | |
| 338 | - | hlc: Hlc::zero(DeviceId::nil()), | |
| 339 | - | data: Some(json!({"title": "duplicate push test"})), | |
| 340 | - | extra: serde_json::Map::default(), | |
| 341 | - | }; | |
| 252 | + | let (client, _key) = kit.keyed(); | |
| 253 | + | let entry = insert("tasks", "same-row", json!({"title": "duplicate push test"})); | |
| 342 | 254 | ||
| 343 | 255 | let cursor1 = client | |
| 344 | 256 | .push(DeviceId::new(Uuid::new_v4()), vec![entry.clone()]) | |
| @@ -357,10 +269,9 @@ | |||
| 357 | 269 | ||
| 358 | 270 | #[tokio::test] | |
| 359 | 271 | async fn pull_without_auth_returns_not_authenticated() { | |
| 360 | - | let server = MockServer::start().await; | |
| 361 | - | let client = client_for(&server); | |
| 362 | - | ||
| 363 | - | let err = client | |
| 272 | + | let kit = MockKit::start().await; | |
| 273 | + | let err = kit | |
| 274 | + | .client() | |
| 364 | 275 | .pull(DeviceId::new(Uuid::new_v4()), 0) | |
| 365 | 276 | .await | |
| 366 | 277 | .unwrap_err(); | |
| @@ -371,11 +282,8 @@ | |||
| 371 | 282 | ||
| 372 | 283 | #[tokio::test] | |
| 373 | 284 | async fn end_to_end_push_pull_encryption_roundtrip() { | |
| 374 | - | let server = MockServer::start().await; | |
| 375 | - | ||
| 376 | - | let client = authed_client(&server); | |
| 377 | - | let key = synckit_client::crypto::generate_master_key(); | |
| 378 | - | client.set_master_key_raw(key); | |
| 285 | + | let kit = MockKit::start().await; | |
| 286 | + | let (client, _key) = kit.keyed(); | |
| 379 | 287 | ||
| 380 | 288 | let device_id = DeviceId::new(Uuid::new_v4()); | |
| 381 | 289 | let original_data = json!({ | |
| @@ -385,46 +293,23 @@ | |||
| 385 | 293 | "count": 42 | |
| 386 | 294 | }); | |
| 387 | 295 | ||
| 388 | - | // Capture push request to feed back through pull | |
| 389 | - | Mock::given(method("POST")) | |
| 390 | - | .and(path("/api/v1/sync/push")) | |
| 391 | - | .respond_with(ResponseTemplate::new(200).set_body_json(json!({"cursor": 1}))) | |
| 392 | - | .mount(&server) | |
| 393 | - | .await; | |
| 296 | + | kit.post(PUSH_PATH).json(json!({"cursor": 1})).await; | |
| 394 | 297 | ||
| 395 | 298 | client | |
| 396 | 299 | .push( | |
| 397 | 300 | device_id, | |
| 398 | - | vec![ChangeEntry { | |
| 399 | - | table: "tasks".into(), | |
| 400 | - | op: ChangeOp::Insert, | |
| 401 | - | row_id: "e2e-row".into(), | |
| 402 | - | timestamp: Utc::now(), | |
| 403 | - | hlc: Hlc::zero(DeviceId::nil()), | |
| 404 | - | data: Some(original_data.clone()), | |
| 405 | - | extra: serde_json::Map::default(), | |
| 406 | - | }], | |
| 301 | + | vec![insert("tasks", "e2e-row", original_data.clone())], | |
| 407 | 302 | ) | |
| 408 | 303 | .await | |
| 409 | 304 | .unwrap(); | |
| 410 | 305 | ||
| 411 | 306 | // Extract the encrypted data that was sent to the server | |
| 412 | - | let requests = server.received_requests().await.unwrap(); | |
| 413 | - | let push_body: serde_json::Value = serde_json::from_slice( | |
| 414 | - | &requests | |
| 415 | - | .iter() | |
| 416 | - | .find(|r| r.url.path() == "/api/v1/sync/push") | |
| 417 | - | .unwrap() | |
| 418 | - | .body, | |
| 419 | - | ) | |
| 420 | - | .unwrap(); | |
| 421 | - | ||
| 307 | + | let push_body = kit.body(PUSH_PATH).await; | |
| 422 | 308 | let wire_entry = &push_body["changes"][0]; | |
| 423 | 309 | ||
| 424 | 310 | // Feed encrypted data back through pull | |
| 425 | - | Mock::given(method("POST")) | |
| 426 | - | .and(path("/api/v1/sync/pull")) | |
| 427 | - | .respond_with(ResponseTemplate::new(200).set_body_json(json!({ | |
| 311 | + | kit.post(PULL_PATH) | |
| 312 | + | .json(json!({ | |
| 428 | 313 | "changes": [{ | |
| 429 | 314 | "seq": 1, | |
| 430 | 315 | "device_id": device_id, | |
| @@ -436,8 +321,7 @@ | |||
| 436 | 321 | }], | |
| 437 | 322 | "cursor": 1, | |
| 438 | 323 | "has_more": false, | |
| 439 | - | }))) | |
| 440 | - | .mount(&server) | |
| 324 | + | })) | |
| 441 | 325 | .await; | |
| 442 | 326 | ||
| 443 | 327 | let (changes, _, _) = client.pull(device_id, 0).await.unwrap(); | |
| @@ -495,18 +379,16 @@ | |||
| 495 | 379 | ||
| 496 | 380 | // Run A: one page. | |
| 497 | 381 | let unpaginated = { | |
| 498 | - | let server = MockServer::start().await; | |
| 499 | - | let client = authed_client(&server); | |
| 382 | + | let kit = MockKit::start().await; | |
| 383 | + | let client = kit.authed(); | |
| 500 | 384 | client.set_master_key_raw(key); | |
| 501 | 385 | let (wire, _) = encrypted_changes(&key, device_id, 1, TOTAL); | |
| 502 | - | Mock::given(method("POST")) | |
| 503 | - | .and(path("/api/v1/sync/pull")) | |
| 504 | - | .respond_with(ResponseTemplate::new(200).set_body_json(json!({ | |
| 386 | + | kit.post(PULL_PATH) | |
| 387 | + | .json(json!({ | |
| 505 | 388 | "changes": wire, | |
| 506 | 389 | "cursor": TOTAL, | |
| 507 | 390 | "has_more": false, | |
| 508 | - | }))) | |
| 509 | - | .mount(&server) | |
| 391 | + | })) | |
| 510 | 392 | .await; | |
| 511 | 393 | ||
| 512 | 394 | let (changes, cursor, has_more) = client.pull(device_id, 0).await.unwrap(); | |
| @@ -518,22 +400,20 @@ | |||
| 518 | 400 | // Run B: the same changes, three pages of two, drained the way a caller | |
| 519 | 401 | // drains them. | |
| 520 | 402 | let paginated = { | |
| 521 | - | let server = MockServer::start().await; | |
| 522 | - | let client = authed_client(&server); | |
| 403 | + | let kit = MockKit::start().await; | |
| 404 | + | let client = kit.authed(); | |
| 523 | 405 | client.set_master_key_raw(key); | |
| 524 | 406 | for page in 0..3i64 { | |
| 525 | 407 | let first = page * 2 + 1; | |
| 526 | 408 | let (wire, _) = encrypted_changes(&key, device_id, first, 2); | |
| 527 | 409 | let cursor = first + 1; | |
| 528 | - | Mock::given(method("POST")) | |
| 529 | - | .and(path("/api/v1/sync/pull")) | |
| 530 | - | .respond_with(ResponseTemplate::new(200).set_body_json(json!({ | |
| 410 | + | kit.post(PULL_PATH) | |
| 411 | + | .once() | |
| 412 | + | .json(json!({ | |
| 531 | 413 | "changes": wire, | |
| 532 | 414 | "cursor": cursor, | |
| 533 | 415 | "has_more": page < 2, | |
| 534 | - | }))) | |
| 535 | - | .up_to_n_times(1) | |
| 536 | - | .mount(&server) | |
| 416 | + | })) | |
| 537 | 417 | .await; | |
| 538 | 418 | } | |
| 539 | 419 |
| @@ -4,6 +4,18 @@ | |||
| 4 | 4 | ||
| 5 | 5 | use crate::common::*; | |
| 6 | 6 | ||
| 7 | + | const STATUS_PATH: &str = "/api/v1/sync/status"; | |
| 8 | + | const PUSH_PATH: &str = "/api/v1/sync/push"; | |
| 9 | + | const PULL_PATH: &str = "/api/v1/sync/pull"; | |
| 10 | + | const AUTH_PATH: &str = "/api/v1/sync/auth"; | |
| 11 | + | const DEVICES_PATH: &str = "/api/v1/sync/devices"; | |
| 12 | + | ||
| 13 | + | /// The status body the retry tests fall through to once the failures are spent. | |
| 14 | + | /// They assert on the retry, not on the numbers, so any well-formed body does. | |
| 15 | + | fn ok_status() -> serde_json::Value { | |
| 16 | + | json!({"total_changes": 0, "latest_cursor": null}) | |
| 17 | + | } | |
| 18 | + | ||
| 7 | 19 | // ── Response body cap (DoS) ── | |
| 8 | 20 | ||
| 9 | 21 | #[tokio::test] | |
| @@ -11,16 +23,12 @@ | |||
| 11 | 23 | // A hostile/buggy server streams a control-plane body far larger than the | |
| 12 | 24 | // 8 MiB cap. The client must reject it (the cap fast-rejects on the honest | |
| 13 | 25 | // Content-Length) instead of buffering it into memory and OOMing. | |
| 14 | - | let server = MockServer::start().await; | |
| 15 | - | let huge = vec![b'x'; 9 * 1024 * 1024]; | |
| 16 | - | Mock::given(method("GET")) | |
| 17 | - | .and(path("/api/v1/sync/status")) | |
| 18 | - | .respond_with(ResponseTemplate::new(200).set_body_bytes(huge)) | |
| 19 | - | .mount(&server) | |
| 26 | + | let kit = MockKit::start().await; | |
| 27 | + | kit.get(STATUS_PATH) | |
| 28 | + | .bytes(vec![b'x'; 9 * 1024 * 1024]) | |
| 20 | 29 | .await; | |
| 21 | 30 | ||
| 22 | - | let client = authed_client(&server); | |
| 23 | - | let err = client.status().await.unwrap_err(); | |
| 31 | + | let err = kit.authed().status().await.unwrap_err(); | |
| 24 | 32 | assert!( | |
| 25 | 33 | matches!(err, SyncKitError::Internal(ref m) if m.contains("cap")), | |
| 26 | 34 | "expected the body cap to reject the oversized response, got: {err:?}" | |
| @@ -31,42 +39,34 @@ | |||
| 31 | 39 | ||
| 32 | 40 | #[tokio::test] | |
| 33 | 41 | async fn error_429_is_retried() { | |
| 34 | - | let server = MockServer::start().await; | |
| 35 | - | ||
| 36 | - | Mock::given(method("GET")) | |
| 37 | - | .and(path("/api/v1/sync/status")) | |
| 38 | - | .respond_with(ResponseTemplate::new(429).set_body_string("Too Many Requests")) | |
| 39 | - | .up_to_n_times(1) | |
| 40 | - | .mount(&server) | |
| 42 | + | let kit = MockKit::start().await; | |
| 43 | + | kit.get(STATUS_PATH) | |
| 44 | + | .code(429) | |
| 45 | + | .once() | |
| 46 | + | .text("Too Many Requests") | |
| 47 | + | .await; | |
| 48 | + | kit.get(STATUS_PATH) | |
| 49 | + | .json(json!({"total_changes": 10, "latest_cursor": 5})) | |
| 41 | 50 | .await; | |
| 42 | 51 | ||
| 43 | - | Mock::given(method("GET")) | |
| 44 | - | .and(path("/api/v1/sync/status")) | |
| 45 | - | .respond_with( | |
| 46 | - | ResponseTemplate::new(200) | |
| 47 | - | .set_body_json(json!({"total_changes": 10, "latest_cursor": 5})), | |
| 48 | - | ) | |
| 49 | - | .mount(&server) | |
| 50 | - | .await; | |
| 51 | - | ||
| 52 | - | let client = authed_client(&server); | |
| 53 | - | let status = client.status().await.unwrap(); | |
| 52 | + | let status = kit.authed().status().await.unwrap(); | |
| 54 | 53 | assert_eq!(status.total_changes, 10); | |
| 55 | 54 | } | |
| 56 | 55 | ||
| 57 | 56 | #[tokio::test] | |
| 58 | 57 | async fn error_400_not_retried() { | |
| 59 | - | let server = MockServer::start().await; | |
| 60 | - | ||
| 61 | - | Mock::given(method("POST")) | |
| 62 | - | .and(path("/api/v1/sync/devices")) | |
| 63 | - | .respond_with(ResponseTemplate::new(400).set_body_string("Bad Request")) | |
| 64 | - | .expect(1) | |
| 65 | - | .mount(&server) | |
| 58 | + | let kit = MockKit::start().await; | |
| 59 | + | kit.post(DEVICES_PATH) | |
| 60 | + | .code(400) | |
| 61 | + | .exactly(1) | |
| 62 | + | .text("Bad Request") | |
| 66 | 63 | .await; | |
| 67 | 64 | ||
| 68 | - | let client = authed_client(&server); | |
| 69 | - | let err = client.register_device("Device", "test").await.unwrap_err(); | |
| 65 | + | let err = kit | |
| 66 | + | .authed() | |
| 67 | + | .register_device("Device", "test") | |
| 68 | + | .await | |
| 69 | + | .unwrap_err(); | |
| 70 | 70 | assert!(matches!(err, SyncKitError::Server { status: 400, .. })); | |
| 71 | 71 | } | |
| 72 | 72 | ||
| @@ -74,45 +74,27 @@ | |||
| 74 | 74 | ||
| 75 | 75 | #[tokio::test] | |
| 76 | 76 | async fn status_success() { | |
| 77 | - | let server = MockServer::start().await; | |
| 78 | - | ||
| 79 | - | Mock::given(method("GET")) | |
| 80 | - | .and(path("/api/v1/sync/status")) | |
| 81 | - | .respond_with( | |
| 82 | - | ResponseTemplate::new(200) | |
| 83 | - | .set_body_json(json!({"total_changes": 42, "latest_cursor": 100})), | |
| 84 | - | ) | |
| 85 | - | .mount(&server) | |
| 77 | + | let kit = MockKit::start().await; | |
| 78 | + | kit.get(STATUS_PATH) | |
| 79 | + | .json(json!({"total_changes": 42, "latest_cursor": 100})) | |
| 86 | 80 | .await; | |
| 87 | 81 | ||
| 88 | - | let client = authed_client(&server); | |
| 89 | - | let status = client.status().await.unwrap(); | |
| 82 | + | let status = kit.authed().status().await.unwrap(); | |
| 90 | 83 | assert_eq!(status.total_changes, 42); | |
| 91 | 84 | assert_eq!(status.latest_cursor, Some(100)); | |
| 92 | 85 | } | |
| 93 | 86 | ||
| 94 | 87 | #[tokio::test] | |
| 95 | 88 | async fn status_retries_on_transient() { | |
| 96 | - | let server = MockServer::start().await; | |
| 97 | - | ||
| 98 | - | Mock::given(method("GET")) | |
| 99 | - | .and(path("/api/v1/sync/status")) | |
| 100 | - | .respond_with(ResponseTemplate::new(504).set_body_string("Gateway Timeout")) | |
| 101 | - | .up_to_n_times(1) | |
| 102 | - | .mount(&server) | |
| 89 | + | let kit = MockKit::start().await; | |
| 90 | + | kit.get(STATUS_PATH) | |
| 91 | + | .code(504) | |
| 92 | + | .once() | |
| 93 | + | .text("Gateway Timeout") | |
| 103 | 94 | .await; | |
| 95 | + | kit.get(STATUS_PATH).json(ok_status()).await; | |
| 104 | 96 | ||
| 105 | - | Mock::given(method("GET")) | |
| 106 | - | .and(path("/api/v1/sync/status")) | |
| 107 | - | .respond_with( | |
| 108 | - | ResponseTemplate::new(200) | |
| 109 | - | .set_body_json(json!({"total_changes": 0, "latest_cursor": null})), | |
| 110 | - | ) | |
| 111 | - | .mount(&server) | |
| 112 | - | .await; | |
| 113 | - | ||
| 114 | - | let client = authed_client(&server); | |
| 115 | - | let status = client.status().await.unwrap(); | |
| 97 | + | let status = kit.authed().status().await.unwrap(); | |
| 116 | 98 | assert_eq!(status.total_changes, 0); | |
| 117 | 99 | } | |
| 118 | 100 | ||
| @@ -120,18 +102,10 @@ | |||
| 120 | 102 | ||
| 121 | 103 | #[tokio::test] | |
| 122 | 104 | async fn push_malformed_json_response_handled() { | |
| 123 | - | let server = MockServer::start().await; | |
| 124 | - | ||
| 125 | - | Mock::given(method("POST")) | |
| 126 | - | .and(path("/api/v1/sync/push")) | |
| 127 | - | .respond_with(ResponseTemplate::new(200).set_body_string("not valid json at all")) | |
| 128 | - | .mount(&server) | |
| 129 | - | .await; | |
| 130 | - | ||
| 131 | - | let client = authed_client(&server); | |
| 132 | - | let key = synckit_client::crypto::generate_master_key(); | |
| 133 | - | client.set_master_key_raw(key); | |
| 105 | + | let kit = MockKit::start().await; | |
| 106 | + | kit.post(PUSH_PATH).text("not valid json at all").await; | |
| 134 | 107 | ||
| 108 | + | let (client, _key) = kit.keyed(); | |
| 135 | 109 | let result = client.push(DeviceId::new(Uuid::new_v4()), vec![]).await; | |
| 136 | 110 | assert!(result.is_err(), "Malformed JSON should produce an error"); | |
| 137 | 111 | // Should be a JSON parse error, not a panic | |
| @@ -144,34 +118,20 @@ | |||
| 144 | 118 | ||
| 145 | 119 | #[tokio::test] | |
| 146 | 120 | async fn pull_malformed_json_response_handled() { | |
| 147 | - | let server = MockServer::start().await; | |
| 148 | - | ||
| 149 | - | Mock::given(method("POST")) | |
| 150 | - | .and(path("/api/v1/sync/pull")) | |
| 151 | - | .respond_with(ResponseTemplate::new(200).set_body_string("{invalid json}")) | |
| 152 | - | .mount(&server) | |
| 153 | - | .await; | |
| 154 | - | ||
| 155 | - | let client = authed_client(&server); | |
| 156 | - | let key = synckit_client::crypto::generate_master_key(); | |
| 157 | - | client.set_master_key_raw(key); | |
| 121 | + | let kit = MockKit::start().await; | |
| 122 | + | kit.post(PULL_PATH).text("{invalid json}").await; | |
| 158 | 123 | ||
| 124 | + | let (client, _key) = kit.keyed(); | |
| 159 | 125 | let result = client.pull(DeviceId::new(Uuid::new_v4()), 0).await; | |
| 160 | 126 | assert!(result.is_err(), "Malformed JSON should produce an error"); | |
| 161 | 127 | } | |
| 162 | 128 | ||
| 163 | 129 | #[tokio::test] | |
| 164 | 130 | async fn status_malformed_json_response_handled() { | |
| 165 | - | let server = MockServer::start().await; | |
| 131 | + | let kit = MockKit::start().await; | |
| 132 | + | kit.get(STATUS_PATH).text("this is not json").await; | |
| 166 | 133 | ||
| 167 | - | Mock::given(method("GET")) | |
| 168 | - | .and(path("/api/v1/sync/status")) | |
| 169 | - | .respond_with(ResponseTemplate::new(200).set_body_string("this is not json")) | |
| 170 | - | .mount(&server) | |
| 171 | - | .await; | |
| 172 | - | ||
| 173 | - | let client = authed_client(&server); | |
| 174 | - | let result = client.status().await; | |
| 134 | + | let result = kit.authed().status().await; | |
| 175 | 135 | assert!(result.is_err()); | |
| 176 | 136 | } | |
| 177 | 137 | ||
| @@ -179,18 +139,13 @@ | |||
| 179 | 139 | ||
| 180 | 140 | #[tokio::test] | |
| 181 | 141 | async fn server_error_message_preserved() { | |
| 182 | - | let server = MockServer::start().await; | |
| 183 | - | ||
| 184 | - | Mock::given(method("GET")) | |
| 185 | - | .and(path("/api/v1/sync/status")) | |
| 186 | - | .respond_with( | |
| 187 | - | ResponseTemplate::new(422).set_body_string("Validation failed: missing field"), | |
| 188 | - | ) | |
| 189 | - | .mount(&server) | |
| 142 | + | let kit = MockKit::start().await; | |
| 143 | + | kit.get(STATUS_PATH) | |
| 144 | + | .code(422) | |
| 145 | + | .text("Validation failed: missing field") | |
| 190 | 146 | .await; | |
| 191 | 147 | ||
| 192 | - | let client = authed_client(&server); | |
| 193 | - | let err = client.status().await.unwrap_err(); | |
| 148 | + | let err = kit.authed().status().await.unwrap_err(); | |
| 194 | 149 | match err { | |
| 195 | 150 | SyncKitError::Server { | |
| 196 | 151 | status, message, .. | |
| @@ -228,22 +183,16 @@ | |||
| 228 | 183 | ||
| 229 | 184 | #[tokio::test] | |
| 230 | 185 | async fn all_4xx_error_codes_mapped() { | |
| 231 | - | let server = MockServer::start().await; | |
| 186 | + | let kit = MockKit::start().await; | |
| 232 | 187 | ||
| 233 | 188 | for status_code in [400, 401, 403, 404, 409, 422] { | |
| 234 | - | // Reset mocks | |
| 235 | - | server.reset().await; | |
| 236 | - | ||
| 237 | - | Mock::given(method("GET")) | |
| 238 | - | .and(path("/api/v1/sync/status")) | |
| 239 | - | .respond_with( | |
| 240 | - | ResponseTemplate::new(status_code).set_body_string(format!("Error {status_code}")), | |
| 241 | - | ) | |
| 242 | - | .mount(&server) | |
| 189 | + | kit.reset().await; | |
| 190 | + | kit.get(STATUS_PATH) | |
| 191 | + | .code(status_code) | |
| 192 | + | .text(format!("Error {status_code}")) | |
| 243 | 193 | .await; | |
| 244 | 194 | ||
| 245 | - | let client = authed_client(&server); | |
| 246 | - | let err = client.status().await.unwrap_err(); | |
| 195 | + | let err = kit.authed().status().await.unwrap_err(); | |
| 247 | 196 | ||
| 248 | 197 | match err { | |
| 249 | 198 | SyncKitError::Server { | |
| @@ -260,28 +209,15 @@ | |||
| 260 | 209 | #[tokio::test] | |
| 261 | 210 | async fn all_5xx_error_codes_retried() { | |
| 262 | 211 | for status_code in [500, 502, 503, 504] { | |
| 263 | - | let server = MockServer::start().await; | |
| 264 | - | ||
| 265 | - | // First request fails with 5xx | |
| 266 | - | Mock::given(method("GET")) | |
| 267 | - | .and(path("/api/v1/sync/status")) | |
| 268 | - | .respond_with(ResponseTemplate::new(status_code).set_body_string("Server Error")) | |
| 269 | - | .up_to_n_times(1) | |
| 270 | - | .mount(&server) | |
| 212 | + | let kit = MockKit::start().await; | |
| 213 | + | kit.get(STATUS_PATH) | |
| 214 | + | .code(status_code) | |
| 215 | + | .once() | |
| 216 | + | .text("Server Error") | |
| 271 | 217 | .await; | |
| 218 | + | kit.get(STATUS_PATH).json(ok_status()).await; | |
| 272 | 219 | ||
| 273 | - | // Second request succeeds | |
| 274 | - | Mock::given(method("GET")) | |
| 275 | - | .and(path("/api/v1/sync/status")) | |
| 276 | - | .respond_with( | |
| 277 | - | ResponseTemplate::new(200) | |
| 278 | - | .set_body_json(json!({"total_changes": 0, "latest_cursor": null})), | |
| 279 | - | ) | |
| 280 | - | .mount(&server) | |
| 281 | - | .await; | |
| 282 | - | ||
| 283 | - | let client = authed_client(&server); | |
| 284 | - | let result = client.status().await; | |
| 220 | + | let result = kit.authed().status().await; | |
| 285 | 221 | assert!( | |
| 286 | 222 | result.is_ok(), | |
| 287 | 223 | "Status {status_code} should be retried and succeed: {result:?}" | |
| @@ -293,29 +229,20 @@ | |||
| 293 | 229 | ||
| 294 | 230 | #[tokio::test] | |
| 295 | 231 | async fn retry_exhausts_all_attempts_on_persistent_503() { | |
| 296 | - | let server = MockServer::start().await; | |
| 297 | - | ||
| 298 | - | Mock::given(method("GET")) | |
| 299 | - | .and(path("/api/v1/sync/status")) | |
| 300 | - | .respond_with(ResponseTemplate::new(503).set_body_string("Service Unavailable")) | |
| 301 | - | .mount(&server) | |
| 232 | + | let kit = MockKit::start().await; | |
| 233 | + | kit.get(STATUS_PATH) | |
| 234 | + | .code(503) | |
| 235 | + | .text("Service Unavailable") | |
| 302 | 236 | .await; | |
| 303 | 237 | ||
| 304 | - | let client = authed_client(&server); | |
| 305 | - | let err = client.status().await.unwrap_err(); | |
| 238 | + | let err = kit.authed().status().await.unwrap_err(); | |
| 306 | 239 | assert!(matches!(err, SyncKitError::Server { status: 503, .. })); | |
| 307 | 240 | ||
| 308 | 241 | // Should have made exactly 4 requests (1 initial + 3 retries) | |
| 309 | - | let requests = server.received_requests().await.unwrap(); | |
| 310 | - | let status_requests: Vec<_> = requests | |
| 311 | - | .iter() | |
| 312 | - | .filter(|r| r.url.path() == "/api/v1/sync/status") | |
| 313 | - | .collect(); | |
| 242 | + | let attempts = kit.hits(STATUS_PATH).await; | |
| 314 | 243 | assert_eq!( | |
| 315 | - | status_requests.len(), | |
| 316 | - | 4, | |
| 317 | - | "Expected 4 total requests (1 + MAX_RETRIES=3), got {}", | |
| 318 | - | status_requests.len() | |
| 244 | + | attempts, 4, | |
| 245 | + | "Expected 4 total requests (1 + MAX_RETRIES=3), got {attempts}" | |
| 319 | 246 | ); | |
| 320 | 247 | } | |
| 321 | 248 | ||
| @@ -323,104 +250,75 @@ | |||
| 323 | 250 | async fn unsafe_op_makes_exactly_one_attempt_on_transient_error() { | |
| 324 | 251 | // create_subscription_checkout is Idempotency::Unsafe (a retry could mint a | |
| 325 | 252 | // second Stripe session), so a transient 503 must NOT be retried. | |
| 326 | - | let server = MockServer::start().await; | |
| 327 | - | ||
| 328 | - | Mock::given(method("POST")) | |
| 329 | - | .and(path("/api/v1/sync/subscription/checkout")) | |
| 330 | - | .respond_with(ResponseTemplate::new(503).set_body_string("Service Unavailable")) | |
| 331 | - | .mount(&server) | |
| 253 | + | const CHECKOUT_PATH: &str = "/api/v1/sync/subscription/checkout"; | |
| 254 | + | let kit = MockKit::start().await; | |
| 255 | + | kit.post(CHECKOUT_PATH) | |
| 256 | + | .code(503) | |
| 257 | + | .text("Service Unavailable") | |
| 332 | 258 | .await; | |
| 333 | 259 | ||
| 334 | - | let client = authed_client(&server); | |
| 335 | - | let err = client | |
| 260 | + | let err = kit | |
| 261 | + | .authed() | |
| 336 | 262 | .create_subscription_checkout(1_000_000_000, synckit_client::BillingInterval::Monthly) | |
| 337 | 263 | .await | |
| 338 | 264 | .unwrap_err(); | |
| 339 | 265 | assert!(matches!(err, SyncKitError::Server { status: 503, .. })); | |
| 340 | 266 | ||
| 341 | - | let requests = server.received_requests().await.unwrap(); | |
| 342 | - | let checkout_requests = requests | |
| 343 | - | .iter() | |
| 344 | - | .filter(|r| r.url.path() == "/api/v1/sync/subscription/checkout") | |
| 345 | - | .count(); | |
| 267 | + | let attempts = kit.hits(CHECKOUT_PATH).await; | |
| 346 | 268 | assert_eq!( | |
| 347 | - | checkout_requests, 1, | |
| 348 | - | "Unsafe op must be attempted exactly once, got {checkout_requests}" | |
| 269 | + | attempts, 1, | |
| 270 | + | "Unsafe op must be attempted exactly once, got {attempts}" | |
| 349 | 271 | ); | |
| 350 | 272 | } | |
| 351 | 273 | ||
| 352 | 274 | #[tokio::test] | |
| 353 | 275 | async fn retry_not_attempted_on_404() { | |
| 354 | - | let server = MockServer::start().await; | |
| 276 | + | let kit = MockKit::start().await; | |
| 277 | + | kit.get(STATUS_PATH).code(404).text("Not Found").await; | |
| 355 | 278 | ||
| 356 | - | Mock::given(method("GET")) | |
| 357 | - | .and(path("/api/v1/sync/status")) | |
| 358 | - | .respond_with(ResponseTemplate::new(404).set_body_string("Not Found")) | |
| 359 | - | .mount(&server) | |
| 360 | - | .await; | |
| 361 | - | ||
| 362 | - | let client = authed_client(&server); | |
| 363 | - | let err = client.status().await.unwrap_err(); | |
| 279 | + | let err = kit.authed().status().await.unwrap_err(); | |
| 364 | 280 | assert!(matches!(err, SyncKitError::Server { status: 404, .. })); | |
| 365 | 281 | ||
| 366 | - | let requests = server.received_requests().await.unwrap(); | |
| 367 | - | let count = requests | |
| 368 | - | .iter() | |
| 369 | - | .filter(|r| r.url.path() == "/api/v1/sync/status") | |
| 370 | - | .count(); | |
| 371 | - | assert_eq!(count, 1, "404 should not be retried"); | |
| 282 | + | assert_eq!(kit.hits(STATUS_PATH).await, 1, "404 should not be retried"); | |
| 372 | 283 | } | |
| 373 | 284 | ||
| 374 | 285 | #[tokio::test] | |
| 375 | 286 | async fn retry_succeeds_on_third_attempt() { | |
| 376 | - | let server = MockServer::start().await; | |
| 377 | - | ||
| 378 | - | Mock::given(method("GET")) | |
| 379 | - | .and(path("/api/v1/sync/status")) | |
| 380 | - | .respond_with(ResponseTemplate::new(503).set_body_string("Service Unavailable")) | |
| 381 | - | .up_to_n_times(2) | |
| 382 | - | .mount(&server) | |
| 287 | + | let kit = MockKit::start().await; | |
| 288 | + | kit.get(STATUS_PATH) | |
| 289 | + | .code(503) | |
| 290 | + | .at_most(2) | |
| 291 | + | .text("Service Unavailable") | |
| 292 | + | .await; | |
| 293 | + | kit.get(STATUS_PATH) | |
| 294 | + | .json(json!({"total_changes": 7, "latest_cursor": 3})) | |
| 383 | 295 | .await; | |
| 384 | 296 | ||
| 385 | - | Mock::given(method("GET")) | |
| 386 | - | .and(path("/api/v1/sync/status")) | |
| 387 | - | .respond_with( | |
| 388 | - | ResponseTemplate::new(200) | |
| 389 | - | .set_body_json(json!({"total_changes": 7, "latest_cursor": 3})), | |
| 390 | - | ) | |
| 391 | - | .mount(&server) | |
| 392 | - | .await; | |
| 393 | - | ||
| 394 | - | let client = authed_client(&server); | |
| 395 | - | let status = client.status().await.unwrap(); | |
| 297 | + | let status = kit.authed().status().await.unwrap(); | |
| 396 | 298 | assert_eq!(status.total_changes, 7); | |
| 397 | 299 | ||
| 398 | - | let requests = server.received_requests().await.unwrap(); | |
| 399 | - | let count = requests | |
| 400 | - | .iter() | |
| 401 | - | .filter(|r| r.url.path() == "/api/v1/sync/status") | |
| 402 | - | .count(); | |
| 403 | - | assert_eq!(count, 3, "Should succeed on 3rd attempt"); | |
| 300 | + | assert_eq!( | |
| 301 | + | kit.hits(STATUS_PATH).await, | |
| 302 | + | 3, | |
| 303 | + | "Should succeed on 3rd attempt" | |
| 304 | + | ); | |
| 404 | 305 | } | |
| 405 | 306 | ||
| 406 | 307 | // ── Malformed / unexpected responses ── | |
| 407 | 308 | ||
| 408 | 309 | #[tokio::test] | |
| 409 | 310 | async fn authenticate_html_response_returns_error() { | |
| 410 | - | let server = MockServer::start().await; | |
| 411 | - | ||
| 412 | - | Mock::given(method("POST")) | |
| 413 | - | .and(path("/api/v1/sync/auth")) | |
| 414 | - | .respond_with( | |
| 311 | + | let kit = MockKit::start().await; | |
| 312 | + | kit.post(AUTH_PATH) | |
| 313 | + | .reply( | |
| 415 | 314 | ResponseTemplate::new(200) | |
| 416 | 315 | .insert_header("content-type", "text/html") | |
| 417 | 316 | .set_body_string("<html><body>Not JSON</body></html>"), | |
| 418 | 317 | ) | |
| 419 | - | .mount(&server) | |
| 420 | 318 | .await; | |
| 421 | 319 | ||
| 422 | - | let client = client_for(&server); | |
| 423 | - | let err = client | |
| 320 | + | let err = kit | |
| 321 | + | .client() | |
| 424 | 322 | .authenticate("user@test.com", "pass", "test-key") | |
| 425 | 323 | .await | |
| 426 | 324 | .unwrap_err(); | |
| @@ -433,18 +331,10 @@ | |||
| 433 | 331 | ||
| 434 | 332 | #[tokio::test] | |
| 435 | 333 | async fn push_empty_response_body_returns_error() { | |
| 436 | - | let server = MockServer::start().await; | |
| 437 | - | ||
| 438 | - | Mock::given(method("POST")) | |
| 439 | - | .and(path("/api/v1/sync/push")) | |
| 440 | - | .respond_with(ResponseTemplate::new(200).set_body_string("")) | |
| 441 | - | .mount(&server) | |
| 442 | - | .await; | |
| 443 | - | ||
| 444 | - | let client = authed_client(&server); | |
| 445 | - | let key = synckit_client::crypto::generate_master_key(); | |
| 446 | - | client.set_master_key_raw(key); | |
| 334 | + | let kit = MockKit::start().await; | |
| 335 | + | kit.post(PUSH_PATH).text("").await; | |
| 447 | 336 | ||
| 337 | + | let (client, _key) = kit.keyed(); | |
| 448 | 338 | let err = client | |
| 449 | 339 | .push(DeviceId::new(Uuid::new_v4()), vec![]) | |
| 450 | 340 | .await | |
| @@ -457,22 +347,16 @@ | |||
| 457 | 347 | ||
| 458 | 348 | #[tokio::test] | |
| 459 | 349 | async fn pull_response_missing_has_more_returns_error() { |
Lines truncated
| @@ -1,0 +1,313 @@ | |||
| 1 | + | //! The mock harness: a wiremock server, the clients built against it, and the | |
| 2 | + | //! request inspection every module was hand-rolling. | |
| 3 | + | //! | |
| 4 | + | //! What this replaces is a five-line incantation. Mounting one route used to be | |
| 5 | + | //! | |
| 6 | + | //! ```ignore | |
| 7 | + | //! Mock::given(method("POST")) | |
| 8 | + | //! .and(path("/api/v1/sync/push")) | |
| 9 | + | //! .respond_with(ResponseTemplate::new(200).set_body_json(json!({"cursor": 1}))) | |
| 10 | + | //! .mount(&server) | |
| 11 | + | //! .await; | |
| 12 | + | //! ``` | |
| 13 | + | //! | |
| 14 | + | //! and is now `kit.post("/api/v1/sync/push").json(json!({"cursor": 1})).await`. | |
| 15 | + | //! The point is not the line count: four of those five lines are identical in | |
| 16 | + | //! every one of the suite's mounts, so the one line that differs (the response) | |
| 17 | + | //! was the hardest thing on screen to find. | |
| 18 | + | //! | |
| 19 | + | //! [`MockKit`] mounts routes and hands out clients; [`Route`] is the per-mount | |
| 20 | + | //! builder. Response fixtures stay in [`common`](crate::common) — this module | |
| 21 | + | //! knows how to serve a body, never what a body says. | |
| 22 | + | ||
| 23 | + | use wiremock::matchers::{method, path}; | |
| 24 | + | use wiremock::{Match, MockBuilder, MockServer, Respond, ResponseTemplate}; | |
| 25 | + | ||
| 26 | + | use synckit_client::{SyncKitClient, SyncKitConfig}; | |
| 27 | + | ||
| 28 | + | use crate::common::{ensure_crypto_provider, fresh_token, test_ids}; | |
| 29 | + | ||
| 30 | + | /// A wiremock server plus the wiring around it. | |
| 31 | + | /// | |
| 32 | + | /// Every test owns one: `MockServer::start` binds its own port, so nothing is | |
| 33 | + | /// shared between tests and they pass in any order at any thread count. | |
| 34 | + | pub(crate) struct MockKit { | |
| 35 | + | server: MockServer, | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | impl MockKit { | |
| 39 | + | pub(crate) async fn start() -> Self { | |
| 40 | + | Self { | |
| 41 | + | server: MockServer::start().await, | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | // ── Mounting ── | |
| 46 | + | ||
| 47 | + | pub(crate) fn get(&self, route_path: &str) -> Route<'_> { | |
| 48 | + | self.route("GET", route_path) | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | pub(crate) fn post(&self, route_path: &str) -> Route<'_> { | |
| 52 | + | self.route("POST", route_path) | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | pub(crate) fn put(&self, route_path: &str) -> Route<'_> { | |
| 56 | + | self.route("PUT", route_path) | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | fn route(&self, verb: &str, route_path: &str) -> Route<'_> { | |
| 60 | + | Route::new( | |
| 61 | + | self, | |
| 62 | + | wiremock::Mock::given(method(verb)).and(path(route_path)), | |
| 63 | + | ) | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | /// A route matched by something other than an exact path: a `path_regex` | |
| 67 | + | /// over an id-bearing URL, or a path plus a `query_param`. | |
| 68 | + | pub(crate) fn matching(&self, verb: &str, matcher: impl Match + 'static) -> Route<'_> { | |
| 69 | + | Route::new(self, wiremock::Mock::given(method(verb)).and(matcher)) | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | /// Drop every mounted route and the recorded requests. Lets one test run two | |
| 73 | + | /// phases against one server (a second device, a loop over status codes). | |
| 74 | + | pub(crate) async fn reset(&self) { | |
| 75 | + | self.server.reset().await; | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // ── Clients ── | |
| 79 | + | ||
| 80 | + | /// No session: the client a test uses to assert `NotAuthenticated`. | |
| 81 | + | pub(crate) fn client(&self) -> SyncKitClient { | |
| 82 | + | ensure_crypto_provider(); | |
| 83 | + | SyncKitClient::new(self.config()) | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | /// A session restored from a token that is valid for the next hour. | |
| 87 | + | pub(crate) fn authed(&self) -> SyncKitClient { | |
| 88 | + | self.client_with_token(&fresh_token()) | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | /// Authenticated and holding a master key, which is what every push, pull or | |
| 92 | + | /// blob call needs before it will encrypt anything. The key is returned | |
| 93 | + | /// because a test that seals its own fixture has to seal it under this one. | |
| 94 | + | pub(crate) fn keyed(&self) -> (SyncKitClient, [u8; 32]) { | |
| 95 | + | let client = self.authed(); | |
| 96 | + | let key = synckit_client::crypto::generate_master_key(); | |
| 97 | + | client.set_master_key_raw(key); | |
| 98 | + | (client, key) | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | /// A session restored from a caller-supplied JWT. The expiry cases pass one | |
| 102 | + | /// that is already past, or inside the pre-flight buffer. | |
| 103 | + | pub(crate) fn client_with_token(&self, token: &str) -> SyncKitClient { | |
| 104 | + | let client = self.client(); | |
| 105 | + | let (user_id, app_id) = test_ids(); | |
| 106 | + | client.restore_session(token, user_id, app_id); | |
| 107 | + | client | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | /// Authenticated over a caller-built reqwest client. The timeout tests are | |
| 111 | + | /// the reason: the timeout is a property of the HTTP client, so it cannot be | |
| 112 | + | /// set after the fact. | |
| 113 | + | pub(crate) fn authed_with_http(&self, http: reqwest::Client) -> SyncKitClient { | |
| 114 | + | ensure_crypto_provider(); | |
| 115 | + | let client = SyncKitClient::with_http_client(self.config(), http); | |
| 116 | + | let (user_id, app_id) = test_ids(); | |
| 117 | + | client.restore_session(&fresh_token(), user_id, app_id); | |
| 118 | + | client | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | fn config(&self) -> SyncKitConfig { | |
| 122 | + | SyncKitConfig { | |
| 123 | + | server_url: self.server.uri(), | |
| 124 | + | api_key: "test-api-key".to_string(), | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | // ── URLs ── | |
| 129 | + | ||
| 130 | + | pub(crate) fn uri(&self) -> String { | |
| 131 | + | self.server.uri() | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | /// An absolute URL on this server, for the presigned-URL arguments the blob | |
| 135 | + | /// calls take: the SDK is handed a full URL rather than building one. | |
| 136 | + | pub(crate) fn url(&self, route_path: &str) -> String { | |
| 137 | + | format!("{}{route_path}", self.server.uri()) | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | // ── Request inspection ── | |
| 141 | + | ||
| 142 | + | pub(crate) async fn requests(&self) -> Vec<wiremock::Request> { | |
| 143 | + | self.server | |
| 144 | + | .received_requests() | |
| 145 | + | .await | |
| 146 | + | .expect("the mock server records requests unless explicitly disabled") | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | pub(crate) async fn requests_to(&self, route_path: &str) -> Vec<wiremock::Request> { | |
| 150 | + | self.requests() | |
| 151 | + | .await | |
| 152 | + | .into_iter() | |
| 153 | + | .filter(|r| r.url.path() == route_path) | |
| 154 | + | .collect() | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | pub(crate) async fn hits(&self, route_path: &str) -> usize { | |
| 158 | + | self.requests_to(route_path).await.len() | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | /// The JSON body of the first request to `route_path`. | |
| 162 | + | /// | |
| 163 | + | /// Panics when there was none: a test reading a body it never provoked is | |
| 164 | + | /// asserting against nothing, and should fail loudly rather than compare | |
| 165 | + | /// two `null`s. | |
| 166 | + | pub(crate) async fn body(&self, route_path: &str) -> serde_json::Value { | |
| 167 | + | let requests = self.requests_to(route_path).await; | |
| 168 | + | let first = requests | |
| 169 | + | .first() | |
| 170 | + | .unwrap_or_else(|| panic!("no request was made to {route_path}")); | |
| 171 | + | first | |
| 172 | + | .body_json() | |
| 173 | + | .unwrap_or_else(|e| panic!("{route_path} body is not JSON: {e}")) | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | /// The JSON bodies of every request to `route_path` with the given verb, in | |
| 177 | + | /// order. `/keys` is read and written over the same path, so the method is | |
| 178 | + | /// part of the question. | |
| 179 | + | pub(crate) async fn bodies(&self, verb: &str, route_path: &str) -> Vec<serde_json::Value> { | |
| 180 | + | self.requests() | |
| 181 | + | .await | |
| 182 | + | .iter() | |
| 183 | + | .filter(|r| r.method.as_str() == verb && r.url.path() == route_path) | |
| 184 | + | .map(|r| { | |
| 185 | + | r.body_json() | |
| 186 | + | .unwrap_or_else(|e| panic!("{verb} {route_path} body is not JSON: {e}")) | |
| 187 | + | }) | |
| 188 | + | .collect() | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | /// The raw bytes of the first request to `route_path`, for the blob paths | |
| 192 | + | /// where the body is ciphertext rather than JSON. | |
| 193 | + | pub(crate) async fn raw_body(&self, route_path: &str) -> Vec<u8> { | |
| 194 | + | let requests = self.requests_to(route_path).await; | |
| 195 | + | requests | |
| 196 | + | .first() | |
| 197 | + | .unwrap_or_else(|| panic!("no request was made to {route_path}")) | |
| 198 | + | .body | |
| 199 | + | .clone() | |
| 200 | + | } | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | /// One route, from the matcher to the response. | |
| 204 | + | /// | |
| 205 | + | /// Built by [`MockKit::get`] and friends, finished by a terminal method | |
| 206 | + | /// (`json`, `text`, `empty`, `bytes`, `responder`, `reply`) that mounts it. | |
| 207 | + | /// The status code and the call-count constraints are set in between. | |
| 208 | + | pub(crate) struct Route<'a> { | |
| 209 | + | kit: &'a MockKit, | |
| 210 | + | builder: MockBuilder, | |
| 211 | + | code: u16, | |
| 212 | + | at_most: Option<u64>, | |
| 213 | + | exactly: Option<u64>, | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | impl<'a> Route<'a> { | |
| 217 | + | fn new(kit: &'a MockKit, builder: MockBuilder) -> Self { | |
| 218 | + | Self { | |
| 219 | + | kit, | |
| 220 | + | builder, | |
| 221 | + | code: 200, | |
| 222 | + | at_most: None, | |
| 223 | + | exactly: None, | |
| 224 | + | } | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | /// Narrow the match further: a `query_param`, a second path condition. | |
| 228 | + | pub(crate) fn and(mut self, matcher: impl Match + 'static) -> Self { | |
| 229 | + | self.builder = self.builder.and(matcher); | |
| 230 | + | self | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | /// The status to answer with. 200 unless set. | |
| 234 | + | pub(crate) fn code(mut self, code: u16) -> Self { | |
| 235 | + | self.code = code; | |
| 236 | + | self | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | /// Serve this response once, then fall through to whatever was mounted | |
| 240 | + | /// after it. The transient-failure-then-success pattern. | |
| 241 | + | pub(crate) fn once(self) -> Self { | |
| 242 | + | self.at_most(1) | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | /// Serve this response at most `n` times before falling through. | |
| 246 | + | pub(crate) fn at_most(mut self, n: u64) -> Self { | |
| 247 | + | self.at_most = Some(n); | |
| 248 | + | self | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | /// Assert, when the server drops, that this route was called exactly `n` | |
| 252 | + | /// times. How "not retried" is stated. | |
| 253 | + | pub(crate) fn exactly(mut self, n: u64) -> Self { | |
| 254 | + | self.exactly = Some(n); | |
| 255 | + | self | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | // ── Terminals ── | |
| 259 | + | ||
| 260 | + | pub(crate) async fn json(self, body: impl serde::Serialize) { | |
| 261 | + | let code = self.code; | |
| 262 | + | self.reply(ResponseTemplate::new(code).set_body_json(body)) | |
| 263 | + | .await; | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | pub(crate) async fn text(self, body: impl Into<String>) { | |
| 267 | + | let code = self.code; | |
| 268 | + | self.reply(ResponseTemplate::new(code).set_body_string(body)) | |
| 269 | + | .await; | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | pub(crate) async fn bytes(self, body: impl Into<Vec<u8>>) { | |
| 273 | + | let code = self.code; | |
| 274 | + | self.reply(ResponseTemplate::new(code).set_body_bytes(body)) | |
| 275 | + | .await; | |
| 276 | + | } | |
| 277 | + | ||
| 278 | + | /// A status and nothing else. | |
| 279 | + | pub(crate) async fn empty(self) { | |
| 280 | + | let code = self.code; | |
| 281 | + | self.reply(ResponseTemplate::new(code)).await; | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | /// A response computed from the request, for a server whose answer depends | |
| 285 | + | /// on what was asked (the multipart part-URL minting). | |
| 286 | + | pub(crate) async fn responder(self, responder: impl Respond + 'static) { | |
| 287 | + | let mock = self.builder.respond_with(responder); | |
| 288 | + | self.kit.mount(mock, self.at_most, self.exactly).await; | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | /// A caller-built template, for the response knobs the terminals above do | |
| 292 | + | /// not carry: a delay, an extra header. | |
| 293 | + | pub(crate) async fn reply(self, template: ResponseTemplate) { | |
| 294 | + | let mock = self.builder.respond_with(template); | |
| 295 | + | self.kit.mount(mock, self.at_most, self.exactly).await; | |
| 296 | + | } | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | impl MockKit { | |
| 300 | + | async fn mount(&self, mock: wiremock::Mock, at_most: Option<u64>, exactly: Option<u64>) { | |
| 301 | + | // `up_to_n_times` consumes the Mock and `expect` borrows it, so the | |
| 302 | + | // order here is forced rather than chosen. | |
| 303 | + | let mock = match at_most { | |
| 304 | + | Some(n) => mock.up_to_n_times(n), | |
| 305 | + | None => mock, | |
| 306 | + | }; | |
| 307 | + | let mock = match exactly { | |
| 308 | + | Some(n) => mock.expect(n), | |
| 309 | + | None => mock, | |
| 310 | + | }; | |
| 311 | + | mock.mount(&self.server).await; | |
| 312 | + | } | |
| 313 | + | } |