//! HTTP tests: rotating a group's Group Content Key. //! //! The property under test is that removal and re-key are one transaction. The //! grant set an admin posts *is* the new membership, so there is no window in //! which a removed member's key is still the group's current one. Everything the //! server rejects here (a generation that does not advance, a batch missing the //! admin, a grant for a non-member) exists to stop a rotation that would leave //! the group readable by someone it just removed, or unreadable by its admin. //! //! Design: wiki synckit-groups-design. use serde_json::json; use super::synckit_paid_sync::{ auth_as, create_internal_app, harness_with_blobs, seed_subscription, }; use crate::harness::TestHarness; const GIB: i64 = 1024 * 1024 * 1024; async fn create_group(h: &mut TestHarness, name: &str) -> String { let resp = h .client .post_json( "/api/sync/groups", &json!({ "id": uuid::Uuid::new_v4().to_string(), "name": name, "admin_sealed_gck": "sealed_admin_v1", "admin_pubkey": "pk_admin" }).to_string(), ) .await; assert_eq!(resp.status, 200, "create group: {}", resp.text); resp.json::()["id"] .as_str() .expect("group id") .to_string() } /// Seed a verified account and add it to `group_id` as a member. async fn add_member( h: &mut TestHarness, group_id: &str, username: &str, email: &str, ) -> makenotwork::db::UserId { let user = h.signup(username, email, "Password1!").await; sqlx::query("UPDATE users SET email_verified = true WHERE id = $1") .bind(user) .execute(&h.db) .await .expect("verify member"); let resp = h .client .post_json( &format!("/api/sync/groups/{group_id}/members"), &json!({ "member_email": email, "sealed_gck": format!("sealed_{username}_v1"), "member_pubkey": format!("pk_{username}"), }) .to_string(), ) .await; assert_eq!(resp.status, 204, "add member: {}", resp.text); user } /// The caller's own grant and the generation it was sealed under. async fn grant(h: &mut TestHarness, group_id: &str) -> (String, i64) { let resp = h .client .get(&format!("/api/sync/groups/{group_id}/grant")) .await; assert_eq!(resp.status, 200, "get grant: {}", resp.text); let body: serde_json::Value = resp.json(); ( body["sealed_gck"].as_str().expect("sealed_gck").to_string(), body["gck_version"].as_i64().expect("gck_version"), ) } async fn member_ids(h: &mut TestHarness, group_id: &str) -> Vec { let resp = h .client .get(&format!("/api/sync/groups/{group_id}/members")) .await; assert_eq!(resp.status, 200, "list members: {}", resp.text); resp.json::() .as_array() .expect("member array") .iter() .map(|m| m["user_id"].as_str().expect("user_id").to_string()) .collect() } async fn register_device(h: &mut TestHarness, name: &str) -> String { let resp = h .client .post_json( "/api/sync/devices", &json!({ "device_name": name, "platform": "macos" }).to_string(), ) .await; assert_eq!(resp.status, 200, "register device: {}", resp.text); resp.json::()["id"] .as_str() .expect("device id") .to_string() } /// Push one group entry, returning nothing: these tests care about what a later /// pull says the entry was sealed under, not about the cursor. async fn push_entry(h: &mut TestHarness, group_id: &str, device_id: &str, row: &str) { let resp = h .client .post_json( &format!("/api/sync/groups/{group_id}/push"), &json!({ "device_id": device_id, "batch_id": uuid::Uuid::new_v4(), "changes": [{ "table": "tasks", "op": "INSERT", "row_id": row, "timestamp": "2026-01-01T00:00:00Z", "data": { "ciphertext": format!("sealed-{row}") }, }], }) .to_string(), ) .await; assert_eq!(resp.status, 200, "group push: {}", resp.text); } async fn pull_entries( h: &mut TestHarness, group_id: &str, device_id: &str, ) -> Vec { let resp = h .client .post_json( &format!("/api/sync/groups/{group_id}/pull"), &json!({ "device_id": device_id, "cursor": 0 }).to_string(), ) .await; assert_eq!(resp.status, 200, "group pull: {}", resp.text); resp.json::()["changes"] .as_array() .expect("changes") .clone() } /// A rotation that drops one member: the generation advances, everyone kept is /// re-granted under it, and the removed member is gone from the group in the same /// operation. #[tokio::test] async fn rotation_advances_the_generation_regrants_and_drops_the_removed_member() { let (mut h, _blobs) = harness_with_blobs().await; let admin = h .signup("gr_admin", "gr_admin@example.com", "Password1!") .await; let (app, _key) = create_internal_app(&h.db, admin).await; auth_as(&mut h, admin, app, "admin-key"); seed_subscription(&h.db, admin, app, "active", 10 * GIB).await; let group_id = create_group(&mut h, "Team").await; let bob = add_member(&mut h, &group_id, "gr_bob", "gr_bob@example.com").await; let carol = add_member(&mut h, &group_id, "gr_carol", "gr_carol@example.com").await; auth_as(&mut h, admin, app, "admin-key"); let (_, before) = grant(&mut h, &group_id).await; assert_eq!(before, 1, "groups start at generation 1"); // Drop Carol: her grant is simply absent from the batch. let resp = h .client .post_json( &format!("/api/sync/groups/{group_id}/rotate"), &json!({ "gck_version": 2, "grants": [ { "user_id": admin.to_string(), "sealed_gck": "sealed_admin_v2" }, { "user_id": bob.to_string(), "sealed_gck": "sealed_bob_v2" }, ], }) .to_string(), ) .await; assert_eq!(resp.status, 204, "rotate: {}", resp.text); let (admin_grant, admin_version) = grant(&mut h, &group_id).await; assert_eq!( admin_version, 2, "admin is re-granted at the new generation" ); assert_eq!(admin_grant, "sealed_admin_v2"); let remaining = member_ids(&mut h, &group_id).await; assert_eq!(remaining.len(), 2, "carol is gone: {remaining:?}"); assert!(remaining.contains(&admin.to_string())); assert!(remaining.contains(&bob.to_string())); assert!(!remaining.contains(&carol.to_string())); // Bob kept his access and holds the new generation. auth_as(&mut h, bob, app, "bob-key"); let (bob_grant, bob_version) = grant(&mut h, &group_id).await; assert_eq!(bob_version, 2); assert_eq!(bob_grant, "sealed_bob_v2"); // Carol is no longer a member, so she cannot read the group at all. auth_as(&mut h, carol, app, "carol-key"); let resp = h .client .get(&format!("/api/sync/groups/{group_id}/grant")) .await; assert_eq!( resp.status, 403, "a removed member must lose group access: {}", resp.text ); } /// A generation that does not advance is refused. Accepting one would re-point /// every member at a key a previously-removed member may still hold, and a /// replayed rotation would silently roll the group back. #[tokio::test] async fn rotation_requires_the_generation_to_advance() { let (mut h, _blobs) = harness_with_blobs().await; let admin = h .signup("gr2_admin", "gr2_admin@example.com", "Password1!") .await; let (app, _key) = create_internal_app(&h.db, admin).await; auth_as(&mut h, admin, app, "admin-key"); let group_id = create_group(&mut h, "Team").await; for stale in [1, 0, -5] { let resp = h .client .post_json( &format!("/api/sync/groups/{group_id}/rotate"), &json!({ "gck_version": stale, "grants": [{ "user_id": admin.to_string(), "sealed_gck": "sealed_admin_replay" }], }) .to_string(), ) .await; assert_eq!(resp.status, 400, "stale generation {stale}: {}", resp.text); } // A committed rotation cannot be replayed at its own generation. let resp = h .client .post_json( &format!("/api/sync/groups/{group_id}/rotate"), &json!({ "gck_version": 2, "grants": [{ "user_id": admin.to_string(), "sealed_gck": "sealed_admin_v2" }], }) .to_string(), ) .await; assert_eq!(resp.status, 204, "first rotation: {}", resp.text); let resp = h .client .post_json( &format!("/api/sync/groups/{group_id}/rotate"), &json!({ "gck_version": 2, "grants": [{ "user_id": admin.to_string(), "sealed_gck": "sealed_admin_replay" }], }) .to_string(), ) .await; assert_eq!(resp.status, 400, "replayed rotation: {}", resp.text); let (sealed, version) = grant(&mut h, &group_id).await; assert_eq!(version, 2, "the replay changed nothing"); assert_eq!(sealed, "sealed_admin_v2"); } /// The batch must carry the admin's own re-sealed grant. Without it the rotation /// would delete the admin along with everyone else omitted, orphaning the group. #[tokio::test] async fn rotation_without_the_admins_own_grant_is_refused() { let (mut h, _blobs) = harness_with_blobs().await; let admin = h .signup("gr3_admin", "gr3_admin@example.com", "Password1!") .await; let (app, _key) = create_internal_app(&h.db, admin).await; auth_as(&mut h, admin, app, "admin-key"); let group_id = create_group(&mut h, "Team").await; let bob = add_member(&mut h, &group_id, "gr3_bob", "gr3_bob@example.com").await; auth_as(&mut h, admin, app, "admin-key"); let resp = h .client .post_json( &format!("/api/sync/groups/{group_id}/rotate"), &json!({ "gck_version": 2, "grants": [{ "user_id": bob.to_string(), "sealed_gck": "sealed_bob_v2" }], }) .to_string(), ) .await; assert_eq!(resp.status, 400, "admin omitted: {}", resp.text); // An empty batch is the same mistake with nothing left standing. let resp = h .client .post_json( &format!("/api/sync/groups/{group_id}/rotate"), &json!({ "gck_version": 2, "grants": [] }).to_string(), ) .await; assert_eq!(resp.status, 400, "empty batch: {}", resp.text); let (_, version) = grant(&mut h, &group_id).await; assert_eq!(version, 1, "no rejected rotation touched the generation"); assert_eq!(member_ids(&mut h, &group_id).await.len(), 2); } /// Rotation re-seals; it does not recruit. A grant naming a non-member would be a /// silent no-op in the db layer's UPDATE, so the admin must not be able to believe /// someone was added by rotating. #[tokio::test] async fn rotation_grant_for_a_non_member_is_refused() { let (mut h, _blobs) = harness_with_blobs().await; let admin = h .signup("gr4_admin", "gr4_admin@example.com", "Password1!") .await; let (app, _key) = create_internal_app(&h.db, admin).await; auth_as(&mut h, admin, app, "admin-key"); let group_id = create_group(&mut h, "Team").await; let outsider = h .signup("gr4_dave", "gr4_dave@example.com", "Password1!") .await; auth_as(&mut h, admin, app, "admin-key"); let resp = h .client .post_json( &format!("/api/sync/groups/{group_id}/rotate"), &json!({ "gck_version": 2, "grants": [ { "user_id": admin.to_string(), "sealed_gck": "sealed_admin_v2" }, { "user_id": outsider.to_string(), "sealed_gck": "sealed_dave_v2" }, ], }) .to_string(), ) .await; assert_eq!(resp.status, 400, "grant for a non-member: {}", resp.text); // A duplicate grant for the same member is refused for the same reason: the // batch would not mean what it appears to. let resp = h .client .post_json( &format!("/api/sync/groups/{group_id}/rotate"), &json!({ "gck_version": 2, "grants": [ { "user_id": admin.to_string(), "sealed_gck": "sealed_admin_v2" }, { "user_id": admin.to_string(), "sealed_gck": "sealed_admin_v2_again" }, ], }) .to_string(), ) .await; assert_eq!(resp.status, 400, "duplicate grant: {}", resp.text); } /// Only the admin may rotate. A member holding a valid grant must not be able to /// re-key the group, which would let them lock the admin out. #[tokio::test] async fn only_the_admin_may_rotate() { let (mut h, _blobs) = harness_with_blobs().await; let admin = h .signup("gr5_admin", "gr5_admin@example.com", "Password1!") .await; let (app, _key) = create_internal_app(&h.db, admin).await; auth_as(&mut h, admin, app, "admin-key"); let group_id = create_group(&mut h, "Team").await; let bob = add_member(&mut h, &group_id, "gr5_bob", "gr5_bob@example.com").await; auth_as(&mut h, bob, app, "bob-key"); let resp = h .client .post_json( &format!("/api/sync/groups/{group_id}/rotate"), &json!({ "gck_version": 2, "grants": [{ "user_id": bob.to_string(), "sealed_gck": "sealed_bob_v2" }], }) .to_string(), ) .await; assert_eq!(resp.status, 403, "member rotating: {}", resp.text); // A non-member gets the same answer, and learns nothing about the group. let carol = h .signup("gr5_carol", "gr5_carol@example.com", "Password1!") .await; auth_as(&mut h, carol, app, "carol-key"); let resp = h .client .post_json( &format!("/api/sync/groups/{group_id}/rotate"), &json!({ "gck_version": 2, "grants": [{ "user_id": carol.to_string(), "sealed_gck": "sealed_carol_v2" }], }) .to_string(), ) .await; assert_eq!(resp.status, 403, "non-member rotating: {}", resp.text); } /// The property the whole per-generation design exists for: entries written /// before a rotation stay readable afterwards. /// /// A rotation bumps the generation without re-encrypting the log (the server /// cannot, it never sees plaintext), so every entry records the generation it was /// sealed under and members keep the grant for every generation they lived /// through. Without both halves a single removal would orphan the group's whole /// history. #[tokio::test] async fn entries_written_before_a_rotation_stay_readable_after_it() { let (mut h, _blobs) = harness_with_blobs().await; let admin = h .signup("gr6_admin", "gr6_admin@example.com", "Password1!") .await; let (app, _key) = create_internal_app(&h.db, admin).await; auth_as(&mut h, admin, app, "admin-key"); seed_subscription(&h.db, admin, app, "active", 10 * GIB).await; let group_id = create_group(&mut h, "Team").await; let bob = add_member(&mut h, &group_id, "gr6_bob", "gr6_bob@example.com").await; let carol = add_member(&mut h, &group_id, "gr6_carol", "gr6_carol@example.com").await; auth_as(&mut h, admin, app, "admin-key"); let device = register_device(&mut h, "admin-dev").await; push_entry(&mut h, &group_id, &device, "before-rotation").await; let resp = h .client .post_json( &format!("/api/sync/groups/{group_id}/rotate"), &json!({ "gck_version": 2, "grants": [ { "user_id": admin.to_string(), "sealed_gck": "sealed_admin_v2" }, { "user_id": bob.to_string(), "sealed_gck": "sealed_bob_v2" }, ], }) .to_string(), ) .await; assert_eq!(resp.status, 204, "rotate: {}", resp.text); push_entry(&mut h, &group_id, &device, "after-rotation").await; // A pull spans both generations, and each entry says which key opens it. let changes = pull_entries(&mut h, &group_id, &device).await; assert_eq!(changes.len(), 2, "both entries pull: {changes:?}"); let by_row = |row: &str| -> i64 { changes .iter() .find(|c| c["row_id"] == row) .unwrap_or_else(|| panic!("{row} missing from {changes:?}"))["gck_version"] .as_i64() .expect("gck_version on a group entry") }; assert_eq!(by_row("before-rotation"), 1, "the old entry keeps its key"); assert_eq!( by_row("after-rotation"), 2, "the new entry uses the new key" ); // A member who lived through the rotation can still fetch the old generation's // grant, which is what makes the old entry decryptable rather than merely // present. auth_as(&mut h, bob, app, "bob-key"); let resp = h .client .get(&format!("/api/sync/groups/{group_id}/grant?version=1")) .await; assert_eq!(resp.status, 200, "bob's generation-1 grant: {}", resp.text); assert_eq!( resp.json::()["sealed_gck"], "sealed_gr6_bob_v1" ); let resp = h .client .get(&format!("/api/sync/groups/{group_id}/grant?version=2")) .await; assert_eq!(resp.status, 200, "bob's generation-2 grant: {}", resp.text); assert_eq!( resp.json::()["sealed_gck"], "sealed_bob_v2" ); // Without a version he gets the newest, which is what a writer wants. let (newest, version) = grant(&mut h, &group_id).await; assert_eq!(version, 2); assert_eq!(newest, "sealed_bob_v2"); // The removed member loses every generation, not just the new one. auth_as(&mut h, carol, app, "carol-key"); for version in [1, 2] { let resp = h .client .get(&format!( "/api/sync/groups/{group_id}/grant?version={version}" )) .await; assert_eq!( resp.status, 403, "a removed member must not fetch generation {version}: {}", resp.text ); } } /// A member added after a rotation gets the current generation only. They see the /// group from when they joined, not before, which is the same rule as a removed /// member losing the old generations. #[tokio::test] async fn a_member_added_after_a_rotation_does_not_get_earlier_generations() { let (mut h, _blobs) = harness_with_blobs().await; let admin = h .signup("gr7_admin", "gr7_admin@example.com", "Password1!") .await; let (app, _key) = create_internal_app(&h.db, admin).await; auth_as(&mut h, admin, app, "admin-key"); let group_id = create_group(&mut h, "Team").await; let resp = h .client .post_json( &format!("/api/sync/groups/{group_id}/rotate"), &json!({ "gck_version": 2, "grants": [{ "user_id": admin.to_string(), "sealed_gck": "sealed_admin_v2" }], }) .to_string(), ) .await; assert_eq!(resp.status, 204, "rotate: {}", resp.text); let bob = add_member(&mut h, &group_id, "gr7_bob", "gr7_bob@example.com").await; auth_as(&mut h, bob, app, "bob-key"); let (_, version) = grant(&mut h, &group_id).await; assert_eq!(version, 2, "a new member joins at the current generation"); let resp = h .client .get(&format!("/api/sync/groups/{group_id}/grant?version=1")) .await; assert_eq!( resp.status, 403, "a generation predating the member must not be fetchable: {}", resp.text ); // The admin, who was there for generation 1, still holds it. auth_as(&mut h, admin, app, "admin-key"); let resp = h .client .get(&format!("/api/sync/groups/{group_id}/grant?version=1")) .await; assert_eq!(resp.status, 200, "admin keeps generation 1: {}", resp.text); assert_eq!( resp.json::()["sealed_gck"], "sealed_admin_v1" ); }