Skip to main content

max / makenotwork

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