Skip to main content

max / makenotwork

11.0 KB · 376 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 // Register a device
185 let device_id = register_device(&mut h, "MacBook Pro").await;
186
187 // List devices — should be 1
188 let resp = h.client.get("/api/sync/devices").await;
189 assert_eq!(resp.status, 200);
190 let devices: Vec<DeviceResponse> = resp.json();
191 assert_eq!(devices.len(), 1);
192 assert_eq!(devices[0].id, device_id);
193
194 // Delete device
195 let resp = h
196 .client
197 .delete(&format!("/api/sync/devices/{}", device_id))
198 .await;
199 assert_eq!(resp.status, 204);
200
201 // List again — should be 0
202 let resp = h.client.get("/api/sync/devices").await;
203 assert_eq!(resp.status, 200);
204 let devices: Vec<DeviceResponse> = resp.json();
205 assert_eq!(devices.len(), 0);
206 }
207
208 #[tokio::test]
209 async fn push_pull_roundtrip() {
210 let mut h = TestHarness::new().await;
211 setup_authenticated(&mut h).await;
212 let device_id = register_device(&mut h, "MacBook Pro").await;
213
214 // Push 3 changes
215 let resp = h
216 .client
217 .post_json(
218 "/api/sync/push",
219 &json!({
220 "device_id": device_id,
221 "batch_id": uuid::Uuid::new_v4().to_string(),
222 "changes": [
223 { "table": "tasks", "op": "INSERT", "row_id": "aaa", "timestamp": "2025-01-01T00:00:00Z", "data": {"title": "Task 1"} },
224 { "table": "tasks", "op": "UPDATE", "row_id": "aaa", "timestamp": "2025-01-01T00:01:00Z", "data": {"title": "Task 1 updated"} },
225 { "table": "tasks", "op": "DELETE", "row_id": "bbb", "timestamp": "2025-01-01T00:02:00Z" },
226 ]
227 })
228 .to_string(),
229 )
230 .await;
231 assert_eq!(resp.status, 200, "Push failed: {}", resp.text);
232 let push: PushResponse = resp.json();
233 assert!(push.cursor > 0);
234
235 // Pull from cursor 0
236 let resp = h
237 .client
238 .post_json(
239 "/api/sync/pull",
240 &json!({ "device_id": device_id, "cursor": 0 }).to_string(),
241 )
242 .await;
243 assert_eq!(resp.status, 200, "Pull failed: {}", resp.text);
244 let pull: PullResponse = resp.json();
245 assert_eq!(pull.changes.len(), 3);
246 assert!(!pull.has_more);
247 assert_eq!(pull.cursor, push.cursor);
248
249 // Verify change content
250 assert_eq!(pull.changes[0].table, "tasks");
251 assert_eq!(pull.changes[0].op, "INSERT");
252 assert_eq!(pull.changes[0].row_id, "aaa");
253 assert_eq!(pull.changes[2].op, "DELETE");
254 assert_eq!(pull.changes[2].row_id, "bbb");
255 assert!(pull.changes[2].data.is_none());
256 }
257
258 #[tokio::test]
259 async fn key_management() {
260 let mut h = TestHarness::new().await;
261 setup_authenticated(&mut h).await;
262
263 // PUT encrypted key (expected_version 0 = no prior key)
264 let resp = h
265 .client
266 .put_json(
267 "/api/sync/keys",
268 &json!({ "encrypted_key": "encrypted-master-key-blob", "expected_version": 0 }).to_string(),
269 )
270 .await;
271 assert_eq!(resp.status, 204, "Put key failed: {}", resp.text);
272
273 // GET it back
274 let resp = h.client.get("/api/sync/keys").await;
275 assert_eq!(resp.status, 200, "Get key failed: {}", resp.text);
276 let key: KeyResponse = resp.json();
277 assert_eq!(key.encrypted_key, "encrypted-master-key-blob");
278 assert_eq!(key.key_version, 1);
279
280 // PUT again (upsert bumps version, expect current version 1)
281 let resp = h
282 .client
283 .put_json(
284 "/api/sync/keys",
285 &json!({ "encrypted_key": "rotated-key-blob", "expected_version": 1 }).to_string(),
286 )
287 .await;
288 assert_eq!(resp.status, 204);
289
290 let resp = h.client.get("/api/sync/keys").await;
291 assert_eq!(resp.status, 200);
292 let key: KeyResponse = resp.json();
293 assert_eq!(key.encrypted_key, "rotated-key-blob");
294 assert_eq!(key.key_version, 2);
295 }
296
297 #[tokio::test]
298 async fn unauthenticated_rejected() {
299 let mut h = TestHarness::new().await;
300
301 // No bearer token — all sync endpoints should return 401
302 let resp = h.client.get("/api/sync/status").await;
303 assert_eq!(resp.status, 401);
304
305 let resp = h.client.get("/api/sync/devices").await;
306 assert_eq!(resp.status, 401);
307
308 let resp = h.client.get("/api/sync/keys").await;
309 assert_eq!(resp.status, 401);
310
311 let resp = h
312 .client
313 .post_json("/api/sync/push", &json!({"device_id": 1, "batch_id": uuid::Uuid::new_v4().to_string(), "changes": []}).to_string())
314 .await;
315 assert_eq!(resp.status, 401);
316 }
317
318 #[tokio::test]
319 async fn push_validation() {
320 let mut h = TestHarness::new().await;
321 setup_authenticated(&mut h).await;
322 let device_id = register_device(&mut h, "MacBook Pro").await;
323
324 // Too many changes (>500)
325 let changes: Vec<_> = (0..501)
326 .map(|i| {
327 json!({
328 "table": "tasks",
329 "op": "INSERT",
330 "row_id": format!("id-{}", i),
331 "timestamp": "2025-01-01T00:00:00Z",
332 "data": {"title": "x"}
333 })
334 })
335 .collect();
336 let resp = h
337 .client
338 .post_json(
339 "/api/sync/push",
340 &json!({ "device_id": device_id, "batch_id": uuid::Uuid::new_v4().to_string(), "changes": changes }).to_string(),
341 )
342 .await;
343 assert_eq!(resp.status, 400, "Expected 400 for >500 changes: {}", resp.text);
344
345 // DELETE with data should be rejected
346 let resp = h
347 .client
348 .post_json(
349 "/api/sync/push",
350 &json!({
351 "device_id": device_id,
352 "batch_id": uuid::Uuid::new_v4().to_string(),
353 "changes": [{
354 "table": "tasks",
355 "op": "DELETE",
356 "row_id": "aaa",
357 "timestamp": "2025-01-01T00:00:00Z",
358 "data": {"should": "not be here"}
359 }]
360 })
361 .to_string(),
362 )
363 .await;
364 assert_eq!(resp.status, 400, "Expected 400 for DELETE with data: {}", resp.text);
365
366 // Empty changes should be rejected
367 let resp = h
368 .client
369 .post_json(
370 "/api/sync/push",
371 &json!({ "device_id": device_id, "batch_id": uuid::Uuid::new_v4().to_string(), "changes": [] }).to_string(),
372 )
373 .await;
374 assert_eq!(resp.status, 400, "Expected 400 for empty changes: {}", resp.text);
375 }
376