Skip to main content

max / synckit

Put the integration suite on a mock harness Every mount was five lines of which four were identical, so the one line that differed (the response) was the hardest thing on screen to find. MockKit takes the wiremock server, the clients built against it, and the request inspection each module was hand-rolling. The type was drafted and dropped once before, on the grounds that adding it with no callers is a speculative helper. This lands it with callers: all twelve modules are retrofitted, so nothing here is guessed at, and three separate local copies of a hits() helper are gone. keyed() is the one that earns the most: 47 sites opened with authed + generate_master_key + set_master_key_raw. 128 integration tests before and after, 131 under --no-default-features --features store,testing.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-08 00:08 UTC
Signed with PGP, not checked
Commit: c59c29d8161069241c7a2d65e38c4ece4878cc87
Parent: 210f383
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 + }