//! Group membership: the URLs the lifecycle walks and the grants it seals. // ── The membership lifecycle ── // // Create, add, list, remove. Every call after the first is addressed by a URL // built from a group id the client itself minted, so the path is where a wrong // id shows up. Rotation covers the removal batch in `group_rotation`; what is // here is the surface around it: which endpoint each call reaches, and whether // the grant it seals opens for the member it names. use crate::common::*; use synckit_client::{IdentityKeypair, open_gck_grant}; use wiremock::matchers::path; const GROUPS: &str = "/api/v1/sync/groups"; /// Answers a create with the group the client asked for, as the server does: /// the id and name are the caller's, the generation starts at 1. struct EchoCreatedGroup { admin_id: UserId, app_id: AppId, } impl wiremock::Respond for EchoCreatedGroup { fn respond(&self, req: &wiremock::Request) -> ResponseTemplate { let body: serde_json::Value = serde_json::from_slice(&req.body).unwrap(); ResponseTemplate::new(200).set_body_json(json!({ "id": body["id"], "app_id": self.app_id, "admin_user_id": self.admin_id, "name": body["name"], "gck_version": 1, "created_at": "2026-06-07T00:00:00Z", })) } } #[tokio::test] async fn the_membership_lifecycle_walks_the_group_urls() { let kit = MockKit::start().await; let (client, master) = kit.keyed(); let admin = IdentityKeypair::from_master_key(&master); let (admin_id, app_id) = test_ids(); // ── create ── kit.post(GROUPS) .responder(EchoCreatedGroup { admin_id, app_id }) .await; let group = client.create_group("Design team").await.unwrap(); assert_eq!(group.name, "Design team"); assert_eq!(group.gck_version, 1); let created = kit.bodies("POST", GROUPS).await.remove(0); assert_eq!(created["name"], "Design team"); assert_eq!( created["admin_pubkey"].as_str().unwrap(), client.my_identity_public_key().unwrap() ); // The admin's grant is sealed before the round-trip, against the id the // client chose, so it must open under generation 1 for that id. let gck = open_gck_grant( created["admin_sealed_gck"].as_str().unwrap(), &admin, &group.id.to_string(), 1, ) .expect("the creator must be able to open their own grant"); let members_path = format!("{GROUPS}/{}/members", group.id); let pubkeys_path = format!("{GROUPS}/{}/pubkeys", group.id); let grant_path = format!("{GROUPS}/{}/grant", group.id); let rotate_path = format!("{GROUPS}/{}/rotate", group.id); // ── add ── // add_member reads the admin's own grant to recover the key it re-seals, so // hand back exactly what create sealed. kit.get(&grant_path) .json(json!({ "sealed_gck": created["admin_sealed_gck"], "gck_version": 1, })) .await; kit.post(&members_path).code(204).empty().await; let bob = IdentityKeypair::generate(); let bob_id = UserId::new(Uuid::new_v4()); client .add_member(group.id, "bob@example.com", &bob.public_key().to_base64()) .await .unwrap(); let added = kit.bodies("POST", &members_path).await.remove(0); assert_eq!(added["member_email"], "bob@example.com"); assert_eq!( added["member_pubkey"].as_str().unwrap(), bob.public_key().to_base64() ); assert_eq!( open_gck_grant( added["sealed_gck"].as_str().unwrap(), &bob, &group.id.to_string(), 1, ) .expect("the new member must be able to open the grant sealed to them"), gck, "the member is sealed the group's current key, not a fresh one" ); // ── list ── kit.get(&members_path) .json(json!([ { "user_id": admin_id, "email": "admin@example.com", "role": "admin", "added_at": "2026-06-07T00:00:00Z", }, { "user_id": bob_id, "email": "bob@example.com", "role": "member", "added_at": "2026-06-08T00:00:00Z", }, ])) .await; let members = client.list_members(group.id).await.unwrap(); assert_eq!(members.len(), 2); assert_eq!(members[0].user_id, admin_id); assert_eq!(members[0].role, "admin"); assert_eq!(members[1].email, "bob@example.com"); // The pubkey list is a different endpoint from the member list: it carries // the re-seal inputs rather than the roster. kit.get(&pubkeys_path) .json(json!([ {"user_id": admin_id, "pubkey": admin.public_key().to_base64()}, {"user_id": bob_id, "pubkey": bob.public_key().to_base64()}, ])) .await; let pubkeys = client.list_member_pubkeys(group.id).await.unwrap(); assert_eq!(pubkeys.len(), 2); assert_eq!(pubkeys[1].user_id, bob_id); assert_eq!(pubkeys[1].pubkey, bob.public_key().to_base64()); // ── remove ── // Removal is a rotation, so it must not touch the per-member URL: leaving // the member's key working is the failure this shape exists to prevent. kit.post(&rotate_path).code(204).empty().await; let member_path = format!("{members_path}/{bob_id}"); kit.matching("DELETE", path(member_path.clone())) .code(204) .empty() .await; client.remove_member(group.id, bob_id).await.unwrap(); assert_eq!(kit.hits(&rotate_path).await, 1); assert_eq!( kit.hits(&member_path).await, 0, "remove_member re-keys instead of deleting the membership row" ); // ── list the groups back ── kit.get(GROUPS) .json(json!([{ "id": group.id, "app_id": app_id, "admin_user_id": admin_id, "name": "Design team", "gck_version": 2, "created_at": "2026-06-07T00:00:00Z", }])) .await; let groups = client.list_groups().await.unwrap(); assert_eq!(groups.len(), 1); assert_eq!(groups[0].id, group.id); assert_eq!( groups[0].gck_version, 2, "the removal advanced the generation" ); // Each step went to its own URL. The reads a rotation makes are counted // here too: removal re-reads the grant and the pubkey list to build its // re-seal batch, so those are two each rather than one. assert_eq!(kit.hits(&grant_path).await, 2); assert_eq!(kit.hits(&pubkeys_path).await, 2); assert_eq!(kit.hits(&members_path).await, 2, "added, then read back"); assert_eq!(kit.hits(GROUPS).await, 2, "created, then listed"); } #[tokio::test] async fn revoking_without_a_rekey_deletes_the_member_url_and_rotates_nothing() { // The escape hatch for a member whose stored public key will not re-seal: // access ends now, and the group key deliberately does not change. let kit = MockKit::start().await; let client = kit.authed(); let group_id = synckit_client::GroupId::new(Uuid::new_v4()); let member = UserId::new(Uuid::new_v4()); let member_path = format!("{GROUPS}/{group_id}/members/{member}"); let rotate_path = format!("{GROUPS}/{group_id}/rotate"); kit.matching("DELETE", path(member_path.clone())) .code(204) .empty() .await; kit.post(&rotate_path).code(204).empty().await; client .revoke_member_without_rekey(group_id, member) .await .unwrap(); let requests = kit.requests_to(&member_path).await; assert_eq!(requests.len(), 1); assert_eq!(requests[0].method.as_str(), "DELETE"); assert_eq!( kit.hits(&rotate_path).await, 0, "revocation alone must not mint a new key" ); } #[tokio::test] async fn a_grant_for_one_generation_asks_for_that_generation() { // Reading an entry written before a rotation needs the grant from the // generation it was sealed under, which is the query the newest-grant read // does not carry. let kit = MockKit::start().await; let (client, master) = kit.keyed(); let admin = IdentityKeypair::from_master_key(&master); let group_id = synckit_client::GroupId::new(Uuid::new_v4()); let gck = synckit_client::generate_group_key(); let sealed = synckit_client::seal_gck_to_member(&gck, &admin.public_key(), &group_id.to_string(), 3) .unwrap(); let grant_path = format!("{GROUPS}/{group_id}/grant"); kit.get(&grant_path) .json(json!({"sealed_gck": sealed, "gck_version": 3})) .await; let grant = client.group_grant_at(group_id, 3).await.unwrap(); assert_eq!(grant.gck_version, 3); assert_eq!( open_gck_grant(&grant.sealed_gck, &admin, &group_id.to_string(), 3).unwrap(), gck ); let requests = kit.requests_to(&grant_path).await; assert_eq!(requests.len(), 1); assert_eq!( requests[0].url.query(), Some("version=3"), "the generation travels in the query, not the path" ); }