Skip to main content

max / makenotwork

15.2 KB · 538 lines History Blame Raw
1 //! DB-layer contract tests for `db::synckit::groups` and the group-scoped
2 //! changelog (`push_group_changes` / `pull_group_changes_filtered`).
3 //!
4 //! The load-bearing test here is `group_entries_never_appear_in_personal_sync`:
5 //! group changes live in their own `sync_group_log` table, so personal-scope
6 //! queries (personal pull, status) can never see GCK-encrypted group rows. That
7 //! isolation is structural, not a `group_id IS NULL` guard sprinkled across every
8 //! personal query. Design: wiki synckit-groups-design.
9
10 use crate::harness::db::TestDb;
11 use crate::harness::seed_user;
12 use makenotwork::db::synckit;
13 use makenotwork::db::{SyncAppId, SyncDeviceId, SyncGroupId, UserId};
14 use uuid::Uuid;
15
16 /// Seed a sync app owned by `user`.
17 async fn seed_app(pool: &sqlx::PgPool, user: UserId, name: &str) -> SyncAppId {
18 sqlx::query_scalar::<_, SyncAppId>(
19 "INSERT INTO sync_apps (creator_id, name, api_key_hash, api_key_prefix)
20 VALUES ($1, $2, $3, $4) RETURNING id",
21 )
22 .bind(user)
23 .bind(name)
24 .bind(format!("hash_{name}"))
25 .bind(&name[..name.len().min(8)])
26 .fetch_one(pool)
27 .await
28 .expect("seed sync app")
29 }
30
31 /// Seed a device row for a user within an app.
32 async fn seed_device(
33 pool: &sqlx::PgPool,
34 app: SyncAppId,
35 user: UserId,
36 name: &str,
37 ) -> SyncDeviceId {
38 sqlx::query_scalar::<_, SyncDeviceId>(
39 "INSERT INTO sync_devices (app_id, user_id, device_name, platform)
40 VALUES ($1, $2, $3, 'macos') RETURNING id",
41 )
42 .bind(app)
43 .bind(user)
44 .bind(name)
45 .fetch_one(pool)
46 .await
47 .expect("seed device")
48 }
49
50 /// One INSERT change tuple in the shape `push_*_changes` expects.
51 fn change(
52 table: &str,
53 row: &str,
54 v: i32,
55 ) -> (
56 String,
57 String,
58 String,
59 chrono::DateTime<chrono::Utc>,
60 Option<serde_json::Value>,
61 ) {
62 (
63 table.to_string(),
64 "INSERT".to_string(),
65 row.to_string(),
66 chrono::Utc::now(),
67 Some(serde_json::json!({ "v": v })),
68 )
69 }
70
71 // ── Group + membership + grants ──────────────────────────────────────────────
72
73 #[tokio::test]
74 async fn create_group_enrolls_admin_as_member_and_admin() {
75 let db = TestDb::new().await;
76 let admin = seed_user(&db.pool, "grp_admin").await;
77 let app = seed_app(&db.pool, admin, "grpcreate").await;
78
79 let group = synckit::create_group(
80 &db.pool,
81 SyncGroupId::new(),
82 app,
83 admin,
84 "Team",
85 "sealed_admin",
86 "pk",
87 )
88 .await
89 .unwrap();
90
91 assert_eq!(group.gck_version, 1);
92 assert!(
93 synckit::is_group_member(&db.pool, group.id, admin)
94 .await
95 .unwrap()
96 );
97 assert!(
98 synckit::is_group_admin(&db.pool, group.id, admin)
99 .await
100 .unwrap()
101 );
102 assert_eq!(
103 synckit::get_member_grant(&db.pool, group.id, admin)
104 .await
105 .unwrap(),
106 Some(("sealed_admin".to_string(), 1)),
107 );
108 }
109
110 #[tokio::test]
111 async fn add_member_stores_grant_and_gates_admin() {
112 let db = TestDb::new().await;
113 let admin = seed_user(&db.pool, "gm_admin").await;
114 let app = seed_app(&db.pool, admin, "gmadd").await;
115 let group = synckit::create_group(
116 &db.pool,
117 SyncGroupId::new(),
118 app,
119 admin,
120 "Team",
121 "sealed_admin",
122 "pk",
123 )
124 .await
125 .unwrap();
126
127 let bob = seed_user(&db.pool, "gm_bob").await;
128 synckit::add_or_update_member(
129 &db.pool,
130 group.id,
131 bob,
132 "member",
133 "sealed_bob",
134 group.gck_version,
135 "pk_bob",
136 )
137 .await
138 .unwrap();
139
140 assert!(
141 synckit::is_group_member(&db.pool, group.id, bob)
142 .await
143 .unwrap()
144 );
145 assert!(
146 !synckit::is_group_admin(&db.pool, group.id, bob)
147 .await
148 .unwrap()
149 );
150 assert_eq!(
151 synckit::get_member_grant(&db.pool, group.id, bob)
152 .await
153 .unwrap(),
154 Some(("sealed_bob".to_string(), 1)),
155 );
156
157 // A non-member has no grant and is not a member.
158 let carol = seed_user(&db.pool, "gm_carol").await;
159 assert!(
160 !synckit::is_group_member(&db.pool, group.id, carol)
161 .await
162 .unwrap()
163 );
164 assert_eq!(
165 synckit::get_member_grant(&db.pool, group.id, carol)
166 .await
167 .unwrap(),
168 None
169 );
170 }
171
172 #[tokio::test]
173 async fn add_or_update_member_is_idempotent_upsert() {
174 let db = TestDb::new().await;
175 let admin = seed_user(&db.pool, "up_admin").await;
176 let app = seed_app(&db.pool, admin, "upsert").await;
177 let group = synckit::create_group(
178 &db.pool,
179 SyncGroupId::new(),
180 app,
181 admin,
182 "Team",
183 "sealed_admin",
184 "pk",
185 )
186 .await
187 .unwrap();
188 let bob = seed_user(&db.pool, "up_bob").await;
189
190 synckit::add_or_update_member(&db.pool, group.id, bob, "member", "grant_v1", 1, "pk")
191 .await
192 .unwrap();
193 // Re-adding replaces the grant in place, not a second row.
194 synckit::add_or_update_member(&db.pool, group.id, bob, "member", "grant_v2", 1, "pk")
195 .await
196 .unwrap();
197
198 assert_eq!(
199 synckit::get_member_grant(&db.pool, group.id, bob)
200 .await
201 .unwrap(),
202 Some(("grant_v2".to_string(), 1)),
203 );
204 // admin + bob, no duplicate.
205 assert_eq!(
206 synckit::list_members(&db.pool, group.id)
207 .await
208 .unwrap()
209 .len(),
210 2
211 );
212 }
213
214 #[tokio::test]
215 async fn list_groups_for_user_is_membership_scoped() {
216 let db = TestDb::new().await;
217 let admin = seed_user(&db.pool, "lg_admin").await;
218 let app = seed_app(&db.pool, admin, "listgrp").await;
219 let g1 = synckit::create_group(&db.pool, SyncGroupId::new(), app, admin, "One", "s", "pk")
220 .await
221 .unwrap();
222 let _g2 = synckit::create_group(&db.pool, SyncGroupId::new(), app, admin, "Two", "s", "pk")
223 .await
224 .unwrap();
225
226 let bob = seed_user(&db.pool, "lg_bob").await;
227 synckit::add_or_update_member(&db.pool, g1.id, bob, "member", "sb", 1, "pk")
228 .await
229 .unwrap();
230 let carol = seed_user(&db.pool, "lg_carol").await;
231
232 assert_eq!(
233 synckit::list_groups_for_user(&db.pool, app, admin)
234 .await
235 .unwrap()
236 .len(),
237 2
238 );
239 assert_eq!(
240 synckit::list_groups_for_user(&db.pool, app, bob)
241 .await
242 .unwrap()
243 .len(),
244 1
245 );
246 assert_eq!(
247 synckit::list_groups_for_user(&db.pool, app, carol)
248 .await
249 .unwrap()
250 .len(),
251 0
252 );
253 }
254
255 #[tokio::test]
256 async fn remove_member_revokes_membership() {
257 let db = TestDb::new().await;
258 let admin = seed_user(&db.pool, "rm_admin").await;
259 let app = seed_app(&db.pool, admin, "removem").await;
260 let group = synckit::create_group(&db.pool, SyncGroupId::new(), app, admin, "Team", "s", "pk")
261 .await
262 .unwrap();
263 let bob = seed_user(&db.pool, "rm_bob").await;
264 synckit::add_or_update_member(&db.pool, group.id, bob, "member", "sb", 1, "pk")
265 .await
266 .unwrap();
267
268 assert!(
269 synckit::remove_member(&db.pool, group.id, bob)
270 .await
271 .unwrap()
272 );
273 assert!(
274 !synckit::is_group_member(&db.pool, group.id, bob)
275 .await
276 .unwrap()
277 );
278 // Removing again is a no-op.
279 assert!(
280 !synckit::remove_member(&db.pool, group.id, bob)
281 .await
282 .unwrap()
283 );
284 }
285
286 // ── Group changelog push/pull ────────────────────────────────────────────────
287
288 #[tokio::test]
289 async fn group_push_pull_roundtrip_and_idempotent() {
290 let db = TestDb::new().await;
291 let admin = seed_user(&db.pool, "pp_admin").await;
292 let app = seed_app(&db.pool, admin, "pushpull").await;
293 let group = synckit::create_group(&db.pool, SyncGroupId::new(), app, admin, "Team", "s", "pk")
294 .await
295 .unwrap();
296 let device = seed_device(&db.pool, app, admin, "dev").await;
297
298 let batch = Uuid::new_v4();
299 let changes = vec![change("tasks", "r1", 1), change("tasks", "r2", 2)];
300 let cursor =
301 synckit::push_group_changes(&db.pool, app, group.id, admin, device, batch, &changes)
302 .await
303 .unwrap();
304 assert!(cursor > 0);
305
306 let pulled = synckit::pull_group_changes_filtered(&db.pool, app, group.id, 0, 100, None, None)
307 .await
308 .unwrap();
309 assert_eq!(pulled.len(), 2);
310 assert!(pulled.iter().all(|e| e.group_id == group.id));
311
312 // Re-pushing the same batch returns the same cursor and inserts nothing new.
313 let cursor2 =
314 synckit::push_group_changes(&db.pool, app, group.id, admin, device, batch, &changes)
315 .await
316 .unwrap();
317 assert_eq!(cursor2, cursor);
318 let pulled2 = synckit::pull_group_changes_filtered(&db.pool, app, group.id, 0, 100, None, None)
319 .await
320 .unwrap();
321 assert_eq!(pulled2.len(), 2);
322 }
323
324 #[tokio::test]
325 async fn group_pull_is_scoped_to_its_group() {
326 let db = TestDb::new().await;
327 let admin = seed_user(&db.pool, "sc_admin").await;
328 let app = seed_app(&db.pool, admin, "scoped").await;
329 let g1 = synckit::create_group(&db.pool, SyncGroupId::new(), app, admin, "One", "s", "pk")
330 .await
331 .unwrap();
332 let g2 = synckit::create_group(&db.pool, SyncGroupId::new(), app, admin, "Two", "s", "pk")
333 .await
334 .unwrap();
335 let device = seed_device(&db.pool, app, admin, "dev").await;
336
337 synckit::push_group_changes(
338 &db.pool,
339 app,
340 g1.id,
341 admin,
342 device,
343 Uuid::new_v4(),
344 &[change("t", "a", 1)],
345 )
346 .await
347 .unwrap();
348 synckit::push_group_changes(
349 &db.pool,
350 app,
351 g2.id,
352 admin,
353 device,
354 Uuid::new_v4(),
355 &[change("t", "b", 2)],
356 )
357 .await
358 .unwrap();
359
360 // Each group sees only its own entries.
361 assert_eq!(
362 synckit::pull_group_changes_filtered(&db.pool, app, g1.id, 0, 100, None, None)
363 .await
364 .unwrap()
365 .len(),
366 1
367 );
368 assert_eq!(
369 synckit::pull_group_changes_filtered(&db.pool, app, g2.id, 0, 100, None, None)
370 .await
371 .unwrap()
372 .len(),
373 1
374 );
375 }
376
377 /// The payoff of the dedicated `sync_group_log` table: group changes are
378 /// invisible to personal-scope queries, and vice versa, with no per-query guard.
379 #[tokio::test]
380 async fn group_entries_never_appear_in_personal_sync() {
381 let db = TestDb::new().await;
382 let user = seed_user(&db.pool, "iso_user").await;
383 let app = seed_app(&db.pool, user, "isolate").await;
384 let group = synckit::create_group(&db.pool, SyncGroupId::new(), app, user, "Team", "s", "pk")
385 .await
386 .unwrap();
387 let device = seed_device(&db.pool, app, user, "dev").await;
388
389 // Push two GROUP changes as this user's device.
390 synckit::push_group_changes(
391 &db.pool,
392 app,
393 group.id,
394 user,
395 device,
396 Uuid::new_v4(),
397 &[change("tasks", "g1", 1), change("tasks", "g2", 2)],
398 )
399 .await
400 .unwrap();
401
402 // The user's PERSONAL pull sees nothing, group rows are in a different table.
403 let personal = synckit::pull_sync_changes_filtered(&db.pool, app, user, 0, 100, None, None)
404 .await
405 .unwrap();
406 assert!(
407 personal.is_empty(),
408 "group entries must not leak into personal pull"
409 );
410
411 // Personal status counts zero personal changes.
412 let (total, latest) = synckit::get_sync_status(&db.pool, app, user).await.unwrap();
413 assert_eq!(total, 0);
414 assert_eq!(latest, None);
415
416 // A PERSONAL push does not appear in the group pull either.
417 synckit::push_sync_changes(
418 &db.pool,
419 app,
420 user,
421 device,
422 Uuid::new_v4(),
423 &[change("notes", "p1", 9)],
424 )
425 .await
426 .unwrap();
427 let group_pull =
428 synckit::pull_group_changes_filtered(&db.pool, app, group.id, 0, 100, None, None)
429 .await
430 .unwrap();
431 assert_eq!(
432 group_pull.len(),
433 2,
434 "personal entries must not leak into group pull"
435 );
436
437 // And the personal pull now sees exactly the one personal entry.
438 let personal2 = synckit::pull_sync_changes_filtered(&db.pool, app, user, 0, 100, None, None)
439 .await
440 .unwrap();
441 assert_eq!(personal2.len(), 1);
442 }
443
444 // ── Rotation ─────────────────────────────────────────────────────────────────
445
446 #[tokio::test]
447 async fn rotate_group_gck_bumps_version_regrants_and_drops_removed() {
448 let db = TestDb::new().await;
449 let admin = seed_user(&db.pool, "rot_admin").await;
450 let app = seed_app(&db.pool, admin, "grprot").await;
451 let group = synckit::create_group(
452 &db.pool,
453 SyncGroupId::new(),
454 app,
455 admin,
456 "Team",
457 "admin_v1",
458 "pk",
459 )
460 .await
461 .unwrap();
462 let bob = seed_user(&db.pool, "rot_bob").await;
463 let carol = seed_user(&db.pool, "rot_carol").await;
464 synckit::add_or_update_member(&db.pool, group.id, bob, "member", "bob_v1", 1, "pk")
465 .await
466 .unwrap();
467 synckit::add_or_update_member(&db.pool, group.id, carol, "member", "carol_v1", 1, "pk")
468 .await
469 .unwrap();
470
471 // Rotate to generation 2, re-granting admin + bob only (carol is removed).
472 let grants = vec![(admin, "admin_v2".to_string()), (bob, "bob_v2".to_string())];
473 synckit::rotate_group_gck(&db.pool, group.id, 2, &grants)
474 .await
475 .unwrap();
476
477 let g = synckit::get_group(&db.pool, app, group.id)
478 .await
479 .unwrap()
480 .unwrap();
481 assert_eq!(g.gck_version, 2);
482 assert_eq!(
483 synckit::get_member_grant(&db.pool, group.id, admin)
484 .await
485 .unwrap(),
486 Some(("admin_v2".to_string(), 2))
487 );
488 assert_eq!(
489 synckit::get_member_grant(&db.pool, group.id, bob)
490 .await
491 .unwrap(),
492 Some(("bob_v2".to_string(), 2))
493 );
494 assert!(
495 !synckit::is_group_member(&db.pool, group.id, carol)
496 .await
497 .unwrap()
498 );
499 }
500
501 #[tokio::test]
502 async fn list_member_pubkeys_returns_admin_and_members() {
503 let db = TestDb::new().await;
504 let admin = seed_user(&db.pool, "pk_admin").await;
505 let app = seed_app(&db.pool, admin, "pubkeys").await;
506 let group = synckit::create_group(
507 &db.pool,
508 SyncGroupId::new(),
509 app,
510 admin,
511 "Team",
512 "sealed_admin",
513 "admin_pub",
514 )
515 .await
516 .unwrap();
517 let bob = seed_user(&db.pool, "pk_bob").await;
518 synckit::add_or_update_member(
519 &db.pool,
520 group.id,
521 bob,
522 "member",
523 "sealed_bob",
524 1,
525 "bob_pub",
526 )
527 .await
528 .unwrap();
529
530 let mut pubkeys = synckit::list_member_pubkeys(&db.pool, group.id)
531 .await
532 .unwrap();
533 pubkeys.sort_by_key(|(_, k)| k.clone());
534 assert_eq!(pubkeys.len(), 2);
535 assert_eq!(pubkeys[0], (admin, "admin_pub".to_string()));
536 assert_eq!(pubkeys[1], (bob, "bob_pub".to_string()));
537 }
538