Skip to main content

max / makenotwork

13.0 KB · 445 lines History Blame Raw
1 //! SyncKit integration tests, auth, devices, push/pull, key management.
2
3 use crate::harness::TestHarness;
4 use makenotwork::db::{SyncAppId, SyncDeviceId, UserId};
5 use serde::Deserialize;
6 use serde_json::json;
7 use sqlx::PgPool;
8
9 // ── Response types for deserialization ──
10
11 #[derive(Deserialize)]
12 struct AuthResponse {
13 token: String,
14 user_id: UserId,
15 #[serde(rename = "app_id")]
16 _app_id: SyncAppId,
17 }
18
19 #[derive(Deserialize)]
20 struct StatusResponse {
21 total_changes: i64,
22 latest_cursor: Option<i64>,
23 }
24
25 #[derive(Deserialize)]
26 struct DeviceResponse {
27 id: SyncDeviceId,
28 }
29
30 #[derive(Deserialize)]
31 struct PushResponse {
32 cursor: i64,
33 }
34
35 #[derive(Deserialize)]
36 struct PullResponse {
37 changes: Vec<PullChange>,
38 cursor: i64,
39 has_more: bool,
40 }
41
42 #[derive(Deserialize)]
43 struct PullChange {
44 #[serde(rename = "seq")]
45 _seq: i64,
46 table: String,
47 op: String,
48 row_id: String,
49 data: Option<serde_json::Value>,
50 }
51
52 #[derive(Deserialize)]
53 struct KeyResponse {
54 encrypted_key: String,
55 key_version: i32,
56 }
57
58 // ── Helper ──
59
60 /// Insert a sync app directly via SQL and return (app_id, api_key).
61 async fn create_sync_app_for_user(pool: &PgPool, user_id: UserId) -> (SyncAppId, String) {
62 let api_key = "test-api-key-for-synckit-integration";
63 let key_hash = crate::harness::hash_api_key(api_key);
64 let key_prefix = &api_key[..8];
65 let app_id: SyncAppId = sqlx::query_scalar(
66 "INSERT INTO sync_apps (creator_id, name, api_key_hash, api_key_prefix) VALUES ($1, 'AudioFiles', $2, $3) RETURNING id",
67 )
68 .bind(user_id)
69 .bind(&key_hash)
70 .bind(key_prefix)
71 .fetch_one(pool)
72 .await
73 .expect("Failed to create sync app");
74
75 (app_id, api_key.to_string())
76 }
77
78 /// Sign up a user, create a sync app, authenticate, and return the bearer token.
79 async fn setup_authenticated(h: &mut TestHarness) -> (String, SyncAppId) {
80 let user_id = h.signup("syncer", "syncer@example.com", "Password1!").await;
81 let (app_id, api_key) = create_sync_app_for_user(&h.db, user_id).await;
82
83 let resp = h
84 .client
85 .post_json(
86 "/api/sync/auth",
87 &json!({
88 "email": "syncer@example.com",
89 "password": "Password1!",
90 "api_key": api_key,
91 "key": "test-sdk-key",
92 })
93 .to_string(),
94 )
95 .await;
96 assert_eq!(resp.status, 200, "Auth failed: {}", resp.text);
97
98 let auth: AuthResponse = resp.json();
99 h.client.set_bearer_token(&auth.token);
100
101 (auth.token, app_id)
102 }
103
104 /// Register a device and return its ID.
105 async fn register_device(h: &mut TestHarness, name: &str) -> SyncDeviceId {
106 let resp = h
107 .client
108 .post_json(
109 "/api/sync/devices",
110 &json!({ "device_name": name, "platform": "macos" }).to_string(),
111 )
112 .await;
113 assert_eq!(resp.status, 200, "Register device failed: {}", resp.text);
114 let dev: DeviceResponse = resp.json();
115 dev.id
116 }
117
118 // ── Tests ──
119
120 #[tokio::test]
121 async fn auth_and_status() {
122 let mut h = TestHarness::new().await;
123 let user_id = h.signup("syncer", "syncer@example.com", "Password1!").await;
124 let (_, api_key) = create_sync_app_for_user(&h.db, user_id).await;
125
126 // Authenticate
127 let resp = h
128 .client
129 .post_json(
130 "/api/sync/auth",
131 &json!({
132 "email": "syncer@example.com",
133 "password": "Password1!",
134 "api_key": api_key,
135 "key": "test-sdk-key",
136 })
137 .to_string(),
138 )
139 .await;
140 assert_eq!(resp.status, 200, "Auth failed: {}", resp.text);
141
142 let auth: AuthResponse = resp.json();
143 assert_eq!(auth.user_id, user_id);
144 assert!(!auth.token.is_empty());
145
146 // Use token for status
147 h.client.set_bearer_token(&auth.token);
148 let resp = h.client.get("/api/sync/status").await;
149 assert_eq!(resp.status, 200);
150
151 let status: StatusResponse = resp.json();
152 assert_eq!(status.total_changes, 0);
153 assert!(status.latest_cursor.is_none());
154 }
155
156 #[tokio::test]
157 async fn auth_rejects_bad_credentials() {
158 let mut h = TestHarness::new().await;
159 let user_id = h.signup("syncer", "syncer@example.com", "Password1!").await;
160 let (_, api_key) = create_sync_app_for_user(&h.db, user_id).await;
161
162 // Wrong password
163 let resp = h
164 .client
165 .post_json(
166 "/api/sync/auth",
167 &json!({
168 "email": "syncer@example.com",
169 "password": "WrongPass1!",
170 "api_key": api_key,
171 "key": "test-sdk-key",
172 })
173 .to_string(),
174 )
175 .await;
176 assert_eq!(resp.status, 401);
177 }
178
179 #[tokio::test]
180 async fn device_crud() {
181 let mut h = TestHarness::new().await;
182 setup_authenticated(&mut h).await;
183
184 let device_id = register_device(&mut h, "MacBook Pro").await;
185
186 // List devices, should be 1
187 let resp = h.client.get("/api/sync/devices").await;
188 assert_eq!(resp.status, 200);
189 let devices: Vec<DeviceResponse> = resp.json();
190 assert_eq!(devices.len(), 1);
191 assert_eq!(devices[0].id, device_id);
192
193 // Delete device
194 let resp = h
195 .client
196 .delete(&format!("/api/sync/devices/{device_id}"))
197 .await;
198 assert_eq!(resp.status, 204);
199
200 // Deleting a device invalidates the user's sync tokens, the removed
201 // device's JWT must not outlive the removal, so the old token is now
202 // rejected. (The 204 above already confirms the row was deleted.)
203 let resp = h.client.get("/api/sync/devices").await;
204 assert_eq!(
205 resp.status, 401,
206 "sync token must be invalid after a device removal"
207 );
208 }
209
210 #[tokio::test]
211 async fn push_pull_roundtrip() {
212 let mut h = TestHarness::new().await;
213 setup_authenticated(&mut h).await;
214 let device_id = register_device(&mut h, "MacBook Pro").await;
215
216 // Push 3 changes
217 let resp = h
218 .client
219 .post_json(
220 "/api/sync/push",
221 &json!({
222 "device_id": device_id,
223 "batch_id": uuid::Uuid::new_v4().to_string(),
224 "changes": [
225 { "table": "tasks", "op": "INSERT", "row_id": "aaa", "timestamp": "2025-01-01T00:00:00Z", "data": {"title": "Task 1"} },
226 { "table": "tasks", "op": "UPDATE", "row_id": "aaa", "timestamp": "2025-01-01T00:01:00Z", "data": {"title": "Task 1 updated"} },
227 { "table": "tasks", "op": "DELETE", "row_id": "bbb", "timestamp": "2025-01-01T00:02:00Z" },
228 ]
229 })
230 .to_string(),
231 )
232 .await;
233 assert_eq!(resp.status, 200, "Push failed: {}", resp.text);
234 let push: PushResponse = resp.json();
235 assert!(push.cursor > 0);
236
237 // Pull from cursor 0
238 let resp = h
239 .client
240 .post_json(
241 "/api/sync/pull",
242 &json!({ "device_id": device_id, "cursor": 0 }).to_string(),
243 )
244 .await;
245 assert_eq!(resp.status, 200, "Pull failed: {}", resp.text);
246 let pull: PullResponse = resp.json();
247 assert_eq!(pull.changes.len(), 3);
248 assert!(!pull.has_more);
249 assert_eq!(pull.cursor, push.cursor);
250
251 // Verify change content
252 assert_eq!(pull.changes[0].table, "tasks");
253 assert_eq!(pull.changes[0].op, "INSERT");
254 assert_eq!(pull.changes[0].row_id, "aaa");
255 assert_eq!(pull.changes[2].op, "DELETE");
256 assert_eq!(pull.changes[2].row_id, "bbb");
257 assert!(pull.changes[2].data.is_none());
258 }
259
260 #[tokio::test]
261 async fn key_management() {
262 let mut h = TestHarness::new().await;
263 setup_authenticated(&mut h).await;
264
265 // PUT encrypted key (expected_version 0 = no prior key)
266 let resp = h
267 .client
268 .put_json(
269 "/api/sync/keys",
270 &json!({ "encrypted_key": "encrypted-master-key-blob", "expected_version": 0 })
271 .to_string(),
272 )
273 .await;
274 assert_eq!(resp.status, 204, "Put key failed: {}", resp.text);
275
276 // GET it back
277 let resp = h.client.get("/api/sync/keys").await;
278 assert_eq!(resp.status, 200, "Get key failed: {}", resp.text);
279 let key: KeyResponse = resp.json();
280 assert_eq!(key.encrypted_key, "encrypted-master-key-blob");
281 assert_eq!(key.key_version, 1);
282
283 // PUT again (upsert bumps version, expect current version 1)
284 let resp = h
285 .client
286 .put_json(
287 "/api/sync/keys",
288 &json!({ "encrypted_key": "rotated-key-blob", "expected_version": 1 }).to_string(),
289 )
290 .await;
291 assert_eq!(resp.status, 204);
292
293 let resp = h.client.get("/api/sync/keys").await;
294 assert_eq!(resp.status, 200);
295 let key: KeyResponse = resp.json();
296 assert_eq!(key.encrypted_key, "rotated-key-blob");
297 assert_eq!(key.key_version, 2);
298 }
299
300 #[tokio::test]
301 async fn unauthenticated_rejected() {
302 let mut h = TestHarness::new().await;
303
304 // No bearer token, all sync endpoints should return 401
305 let resp = h.client.get("/api/sync/status").await;
306 assert_eq!(resp.status, 401);
307
308 let resp = h.client.get("/api/sync/devices").await;
309 assert_eq!(resp.status, 401);
310
311 let resp = h.client.get("/api/sync/keys").await;
312 assert_eq!(resp.status, 401);
313
314 let resp = h
315 .client
316 .post_json(
317 "/api/sync/push",
318 &json!({"device_id": 1, "batch_id": uuid::Uuid::new_v4().to_string(), "changes": []})
319 .to_string(),
320 )
321 .await;
322 assert_eq!(resp.status, 401);
323 }
324
325 #[tokio::test]
326 async fn push_validation() {
327 let mut h = TestHarness::new().await;
328 setup_authenticated(&mut h).await;
329 let device_id = register_device(&mut h, "MacBook Pro").await;
330
331 // Too many changes (>500)
332 let changes: Vec<_> = (0..501)
333 .map(|i| {
334 json!({
335 "table": "tasks",
336 "op": "INSERT",
337 "row_id": format!("id-{}", i),
338 "timestamp": "2025-01-01T00:00:00Z",
339 "data": {"title": "x"}
340 })
341 })
342 .collect();
343 let resp = h
344 .client
345 .post_json(
346 "/api/sync/push",
347 &json!({ "device_id": device_id, "batch_id": uuid::Uuid::new_v4().to_string(), "changes": changes }).to_string(),
348 )
349 .await;
350 assert_eq!(
351 resp.status, 400,
352 "Expected 400 for >500 changes: {}",
353 resp.text
354 );
355
356 // DELETE with data should be rejected
357 let resp = h
358 .client
359 .post_json(
360 "/api/sync/push",
361 &json!({
362 "device_id": device_id,
363 "batch_id": uuid::Uuid::new_v4().to_string(),
364 "changes": [{
365 "table": "tasks",
366 "op": "DELETE",
367 "row_id": "aaa",
368 "timestamp": "2025-01-01T00:00:00Z",
369 "data": {"should": "not be here"}
370 }]
371 })
372 .to_string(),
373 )
374 .await;
375 assert_eq!(
376 resp.status, 400,
377 "Expected 400 for DELETE with data: {}",
378 resp.text
379 );
380
381 // Empty changes should be rejected
382 let resp = h
383 .client
384 .post_json(
385 "/api/sync/push",
386 &json!({ "device_id": device_id, "batch_id": uuid::Uuid::new_v4().to_string(), "changes": [] }).to_string(),
387 )
388 .await;
389 assert_eq!(
390 resp.status, 400,
391 "Expected 400 for empty changes: {}",
392 resp.text
393 );
394 }
395
396 /// A correct password on a 2FA-enabled account must be accounted exactly like a
397 /// wrong password: 401 AND the failed-login counter increments. Otherwise the
398 /// counter is an oracle that confirms the password of a 2FA account (a wrong
399 /// guess increments, a correct-but-2FA guess would not). ultra-fuzz Run 3 #4.
400 #[tokio::test]
401 async fn sync_auth_2fa_account_increments_lockout_like_wrong_password() {
402 let mut h = TestHarness::new().await;
403 let user_id = h.signup("tfa_user", "tfa@example.com", "Password1!").await;
404 let (_app_id, api_key) = create_sync_app_for_user(&h.db, user_id).await;
405
406 // Enable 2FA on the account.
407 sqlx::query("UPDATE users SET totp_enabled = TRUE WHERE id = $1")
408 .bind(user_id)
409 .execute(&h.db)
410 .await
411 .expect("enable totp");
412
413 // Correct password, but 2FA-gated → 401, not a token.
414 let resp = h
415 .client
416 .post_json(
417 "/api/sync/auth",
418 &json!({
419 "email": "tfa@example.com",
420 "password": "Password1!",
421 "api_key": api_key,
422 "key": "test-sdk-key",
423 })
424 .to_string(),
425 )
426 .await;
427 assert_eq!(
428 resp.status, 401,
429 "2FA account must not get a token: {}",
430 resp.text
431 );
432
433 // The lockout counter incremented, exactly as a wrong password would, no
434 // oracle distinguishing "correct password + 2FA" from "wrong password".
435 let attempts: i32 = sqlx::query_scalar("SELECT failed_login_attempts FROM users WHERE id = $1")
436 .bind(user_id)
437 .fetch_one(&h.db)
438 .await
439 .expect("read failed_login_attempts");
440 assert_eq!(
441 attempts, 1,
442 "correct-password-on-2FA must increment the lockout counter"
443 );
444 }
445