//! DB-layer contract tests for `db::synckit::groups` and the group-scoped //! changelog (`push_group_changes` / `pull_group_changes_filtered`). //! //! The load-bearing test here is `group_entries_never_appear_in_personal_sync`: //! group changes live in their own `sync_group_log` table, so personal-scope //! queries (personal pull, status) can never see GCK-encrypted group rows. That //! isolation is structural, not a `group_id IS NULL` guard sprinkled across every //! personal query. Design: wiki synckit-groups-design. use crate::harness::db::TestDb; use makenotwork::db::synckit; use makenotwork::db::{SyncAppId, SyncDeviceId, SyncGroupId, UserId}; use uuid::Uuid; /// Seed a verified user. async fn seed_user(pool: &sqlx::PgPool, username: &str) -> UserId { let hash = makenotwork::auth::hash_password("password123").expect("hash"); sqlx::query_scalar::<_, UserId>( "INSERT INTO users (username, email, password_hash, email_verified) VALUES ($1, $2, $3, true) RETURNING id", ) .bind(username) .bind(format!("{username}@test.com")) .bind(&hash) .fetch_one(pool) .await .expect("seed user") } /// Seed a sync app owned by `user`. async fn seed_app(pool: &sqlx::PgPool, user: UserId, name: &str) -> SyncAppId { sqlx::query_scalar::<_, SyncAppId>( "INSERT INTO sync_apps (creator_id, name, api_key_hash, api_key_prefix) VALUES ($1, $2, $3, $4) RETURNING id", ) .bind(user) .bind(name) .bind(format!("hash_{name}")) .bind(&name[..name.len().min(8)]) .fetch_one(pool) .await .expect("seed sync app") } /// Seed a device row for a user within an app. async fn seed_device( pool: &sqlx::PgPool, app: SyncAppId, user: UserId, name: &str, ) -> SyncDeviceId { sqlx::query_scalar::<_, SyncDeviceId>( "INSERT INTO sync_devices (app_id, user_id, device_name, platform) VALUES ($1, $2, $3, 'macos') RETURNING id", ) .bind(app) .bind(user) .bind(name) .fetch_one(pool) .await .expect("seed device") } /// One INSERT change tuple in the shape `push_*_changes` expects. fn change( table: &str, row: &str, v: i32, ) -> ( String, String, String, chrono::DateTime, Option, ) { ( table.to_string(), "INSERT".to_string(), row.to_string(), chrono::Utc::now(), Some(serde_json::json!({ "v": v })), ) } // ── Group + membership + grants ────────────────────────────────────────────── #[tokio::test] async fn create_group_enrolls_admin_as_member_and_admin() { let db = TestDb::new().await; let admin = seed_user(&db.pool, "grp_admin").await; let app = seed_app(&db.pool, admin, "grpcreate").await; let group = synckit::create_group( &db.pool, SyncGroupId::new(), app, admin, "Team", "sealed_admin", "pk", ) .await .unwrap(); assert_eq!(group.gck_version, 1); assert!( synckit::is_group_member(&db.pool, group.id, admin) .await .unwrap() ); assert!( synckit::is_group_admin(&db.pool, group.id, admin) .await .unwrap() ); assert_eq!( synckit::get_member_grant(&db.pool, group.id, admin) .await .unwrap(), Some(("sealed_admin".to_string(), 1)), ); } #[tokio::test] async fn add_member_stores_grant_and_gates_admin() { let db = TestDb::new().await; let admin = seed_user(&db.pool, "gm_admin").await; let app = seed_app(&db.pool, admin, "gmadd").await; let group = synckit::create_group( &db.pool, SyncGroupId::new(), app, admin, "Team", "sealed_admin", "pk", ) .await .unwrap(); let bob = seed_user(&db.pool, "gm_bob").await; synckit::add_or_update_member( &db.pool, group.id, bob, "member", "sealed_bob", group.gck_version, "pk_bob", ) .await .unwrap(); assert!( synckit::is_group_member(&db.pool, group.id, bob) .await .unwrap() ); assert!( !synckit::is_group_admin(&db.pool, group.id, bob) .await .unwrap() ); assert_eq!( synckit::get_member_grant(&db.pool, group.id, bob) .await .unwrap(), Some(("sealed_bob".to_string(), 1)), ); // A non-member has no grant and is not a member. let carol = seed_user(&db.pool, "gm_carol").await; assert!( !synckit::is_group_member(&db.pool, group.id, carol) .await .unwrap() ); assert_eq!( synckit::get_member_grant(&db.pool, group.id, carol) .await .unwrap(), None ); } #[tokio::test] async fn add_or_update_member_is_idempotent_upsert() { let db = TestDb::new().await; let admin = seed_user(&db.pool, "up_admin").await; let app = seed_app(&db.pool, admin, "upsert").await; let group = synckit::create_group( &db.pool, SyncGroupId::new(), app, admin, "Team", "sealed_admin", "pk", ) .await .unwrap(); let bob = seed_user(&db.pool, "up_bob").await; synckit::add_or_update_member(&db.pool, group.id, bob, "member", "grant_v1", 1, "pk") .await .unwrap(); // Re-adding replaces the grant in place, not a second row. synckit::add_or_update_member(&db.pool, group.id, bob, "member", "grant_v2", 1, "pk") .await .unwrap(); assert_eq!( synckit::get_member_grant(&db.pool, group.id, bob) .await .unwrap(), Some(("grant_v2".to_string(), 1)), ); // admin + bob, no duplicate. assert_eq!( synckit::list_members(&db.pool, group.id) .await .unwrap() .len(), 2 ); } #[tokio::test] async fn list_groups_for_user_is_membership_scoped() { let db = TestDb::new().await; let admin = seed_user(&db.pool, "lg_admin").await; let app = seed_app(&db.pool, admin, "listgrp").await; let g1 = synckit::create_group(&db.pool, SyncGroupId::new(), app, admin, "One", "s", "pk") .await .unwrap(); let _g2 = synckit::create_group(&db.pool, SyncGroupId::new(), app, admin, "Two", "s", "pk") .await .unwrap(); let bob = seed_user(&db.pool, "lg_bob").await; synckit::add_or_update_member(&db.pool, g1.id, bob, "member", "sb", 1, "pk") .await .unwrap(); let carol = seed_user(&db.pool, "lg_carol").await; assert_eq!( synckit::list_groups_for_user(&db.pool, app, admin) .await .unwrap() .len(), 2 ); assert_eq!( synckit::list_groups_for_user(&db.pool, app, bob) .await .unwrap() .len(), 1 ); assert_eq!( synckit::list_groups_for_user(&db.pool, app, carol) .await .unwrap() .len(), 0 ); } #[tokio::test] async fn remove_member_revokes_membership() { let db = TestDb::new().await; let admin = seed_user(&db.pool, "rm_admin").await; let app = seed_app(&db.pool, admin, "removem").await; let group = synckit::create_group(&db.pool, SyncGroupId::new(), app, admin, "Team", "s", "pk") .await .unwrap(); let bob = seed_user(&db.pool, "rm_bob").await; synckit::add_or_update_member(&db.pool, group.id, bob, "member", "sb", 1, "pk") .await .unwrap(); assert!( synckit::remove_member(&db.pool, group.id, bob) .await .unwrap() ); assert!( !synckit::is_group_member(&db.pool, group.id, bob) .await .unwrap() ); // Removing again is a no-op. assert!( !synckit::remove_member(&db.pool, group.id, bob) .await .unwrap() ); } // ── Group changelog push/pull ──────────────────────────────────────────────── #[tokio::test] async fn group_push_pull_roundtrip_and_idempotent() { let db = TestDb::new().await; let admin = seed_user(&db.pool, "pp_admin").await; let app = seed_app(&db.pool, admin, "pushpull").await; let group = synckit::create_group(&db.pool, SyncGroupId::new(), app, admin, "Team", "s", "pk") .await .unwrap(); let device = seed_device(&db.pool, app, admin, "dev").await; let batch = Uuid::new_v4(); let changes = vec![change("tasks", "r1", 1), change("tasks", "r2", 2)]; let cursor = synckit::push_group_changes(&db.pool, app, group.id, admin, device, batch, &changes) .await .unwrap(); assert!(cursor > 0); let pulled = synckit::pull_group_changes_filtered(&db.pool, app, group.id, 0, 100, None, None) .await .unwrap(); assert_eq!(pulled.len(), 2); assert!(pulled.iter().all(|e| e.group_id == group.id)); // Re-pushing the same batch returns the same cursor and inserts nothing new. let cursor2 = synckit::push_group_changes(&db.pool, app, group.id, admin, device, batch, &changes) .await .unwrap(); assert_eq!(cursor2, cursor); let pulled2 = synckit::pull_group_changes_filtered(&db.pool, app, group.id, 0, 100, None, None) .await .unwrap(); assert_eq!(pulled2.len(), 2); } #[tokio::test] async fn group_pull_is_scoped_to_its_group() { let db = TestDb::new().await; let admin = seed_user(&db.pool, "sc_admin").await; let app = seed_app(&db.pool, admin, "scoped").await; let g1 = synckit::create_group(&db.pool, SyncGroupId::new(), app, admin, "One", "s", "pk") .await .unwrap(); let g2 = synckit::create_group(&db.pool, SyncGroupId::new(), app, admin, "Two", "s", "pk") .await .unwrap(); let device = seed_device(&db.pool, app, admin, "dev").await; synckit::push_group_changes( &db.pool, app, g1.id, admin, device, Uuid::new_v4(), &[change("t", "a", 1)], ) .await .unwrap(); synckit::push_group_changes( &db.pool, app, g2.id, admin, device, Uuid::new_v4(), &[change("t", "b", 2)], ) .await .unwrap(); // Each group sees only its own entries. assert_eq!( synckit::pull_group_changes_filtered(&db.pool, app, g1.id, 0, 100, None, None) .await .unwrap() .len(), 1 ); assert_eq!( synckit::pull_group_changes_filtered(&db.pool, app, g2.id, 0, 100, None, None) .await .unwrap() .len(), 1 ); } /// The payoff of the dedicated `sync_group_log` table: group changes are /// invisible to personal-scope queries, and vice versa, with no per-query guard. #[tokio::test] async fn group_entries_never_appear_in_personal_sync() { let db = TestDb::new().await; let user = seed_user(&db.pool, "iso_user").await; let app = seed_app(&db.pool, user, "isolate").await; let group = synckit::create_group(&db.pool, SyncGroupId::new(), app, user, "Team", "s", "pk") .await .unwrap(); let device = seed_device(&db.pool, app, user, "dev").await; // Push two GROUP changes as this user's device. synckit::push_group_changes( &db.pool, app, group.id, user, device, Uuid::new_v4(), &[change("tasks", "g1", 1), change("tasks", "g2", 2)], ) .await .unwrap(); // The user's PERSONAL pull sees nothing, group rows are in a different table. let personal = synckit::pull_sync_changes_filtered(&db.pool, app, user, 0, 100, None, None) .await .unwrap(); assert!( personal.is_empty(), "group entries must not leak into personal pull" ); // Personal status counts zero personal changes. let (total, latest) = synckit::get_sync_status(&db.pool, app, user).await.unwrap(); assert_eq!(total, 0); assert_eq!(latest, None); // A PERSONAL push does not appear in the group pull either. synckit::push_sync_changes( &db.pool, app, user, device, Uuid::new_v4(), &[change("notes", "p1", 9)], ) .await .unwrap(); let group_pull = synckit::pull_group_changes_filtered(&db.pool, app, group.id, 0, 100, None, None) .await .unwrap(); assert_eq!( group_pull.len(), 2, "personal entries must not leak into group pull" ); // And the personal pull now sees exactly the one personal entry. let personal2 = synckit::pull_sync_changes_filtered(&db.pool, app, user, 0, 100, None, None) .await .unwrap(); assert_eq!(personal2.len(), 1); } // ── Rotation ───────────────────────────────────────────────────────────────── #[tokio::test] async fn rotate_group_gck_bumps_version_regrants_and_drops_removed() { let db = TestDb::new().await; let admin = seed_user(&db.pool, "rot_admin").await; let app = seed_app(&db.pool, admin, "grprot").await; let group = synckit::create_group( &db.pool, SyncGroupId::new(), app, admin, "Team", "admin_v1", "pk", ) .await .unwrap(); let bob = seed_user(&db.pool, "rot_bob").await; let carol = seed_user(&db.pool, "rot_carol").await; synckit::add_or_update_member(&db.pool, group.id, bob, "member", "bob_v1", 1, "pk") .await .unwrap(); synckit::add_or_update_member(&db.pool, group.id, carol, "member", "carol_v1", 1, "pk") .await .unwrap(); // Rotate to generation 2, re-granting admin + bob only (carol is removed). let grants = vec![(admin, "admin_v2".to_string()), (bob, "bob_v2".to_string())]; synckit::rotate_group_gck(&db.pool, group.id, 2, &grants) .await .unwrap(); let g = synckit::get_group(&db.pool, app, group.id) .await .unwrap() .unwrap(); assert_eq!(g.gck_version, 2); assert_eq!( synckit::get_member_grant(&db.pool, group.id, admin) .await .unwrap(), Some(("admin_v2".to_string(), 2)) ); assert_eq!( synckit::get_member_grant(&db.pool, group.id, bob) .await .unwrap(), Some(("bob_v2".to_string(), 2)) ); assert!( !synckit::is_group_member(&db.pool, group.id, carol) .await .unwrap() ); } #[tokio::test] async fn list_member_pubkeys_returns_admin_and_members() { let db = TestDb::new().await; let admin = seed_user(&db.pool, "pk_admin").await; let app = seed_app(&db.pool, admin, "pubkeys").await; let group = synckit::create_group( &db.pool, SyncGroupId::new(), app, admin, "Team", "sealed_admin", "admin_pub", ) .await .unwrap(); let bob = seed_user(&db.pool, "pk_bob").await; synckit::add_or_update_member( &db.pool, group.id, bob, "member", "sealed_bob", 1, "bob_pub", ) .await .unwrap(); let mut pubkeys = synckit::list_member_pubkeys(&db.pool, group.id) .await .unwrap(); pubkeys.sort_by_key(|(_, k)| k.clone()); assert_eq!(pubkeys.len(), 2); assert_eq!(pubkeys[0], (admin, "admin_pub".to_string())); assert_eq!(pubkeys[1], (bob, "bob_pub".to_string())); }