Skip to main content

max / synckit

Drive the group membership lifecycle over the mock server Ten public group functions shared one test call, and that call went through the rotate endpoint, so the members and per-member URLs were never reached. Add groups.rs: create, add, list, list pubkeys, remove and list groups against exact path matchers, checking that the grant sealed for a new member opens to the group's current key. Plus the two calls the lifecycle does not cover, revoke_member_without_rekey and group_grant_at.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-23 22:29 UTC
Signed with PGP, not checked
Commit: 84e06f813aab7a173782c98d50aabbe57cf9dcc7
Parent: 03a9e71
2 files changed, +261 insertions, -0 deletions
@@ -18,6 +18,7 @@
18 18 mod device;
19 19 mod encryption;
20 20 mod group_rotation;
21 + mod groups;
21 22 mod ota;
22 23 // `rotate_key` finishes by caching the new key through `keystore::store_key`,
23 24 // which hits the OS secret service under `keychain` and is unavailable on a
@@ -1,0 +1,260 @@
1 + //! Group membership: the URLs the lifecycle walks and the grants it seals.
2 +
3 + // ── The membership lifecycle ──
4 + //
5 + // Create, add, list, remove. Every call after the first is addressed by a URL
6 + // built from a group id the client itself minted, so the path is where a wrong
7 + // id shows up. Rotation covers the removal batch in `group_rotation`; what is
8 + // here is the surface around it: which endpoint each call reaches, and whether
9 + // the grant it seals opens for the member it names.
10 + use crate::common::*;
11 + use synckit_client::{IdentityKeypair, open_gck_grant};
12 + use wiremock::matchers::path;
13 +
14 + const GROUPS: &str = "/api/v1/sync/groups";
15 +
16 + /// Answers a create with the group the client asked for, as the server does:
17 + /// the id and name are the caller's, the generation starts at 1.
18 + struct EchoCreatedGroup {
19 + admin_id: UserId,
20 + app_id: AppId,
21 + }
22 +
23 + impl wiremock::Respond for EchoCreatedGroup {
24 + fn respond(&self, req: &wiremock::Request) -> ResponseTemplate {
25 + let body: serde_json::Value = serde_json::from_slice(&req.body).unwrap();
26 + ResponseTemplate::new(200).set_body_json(json!({
27 + "id": body["id"],
28 + "app_id": self.app_id,
29 + "admin_user_id": self.admin_id,
30 + "name": body["name"],
31 + "gck_version": 1,
32 + "created_at": "2026-06-07T00:00:00Z",
33 + }))
34 + }
35 + }
36 +
37 + #[tokio::test]
38 + async fn the_membership_lifecycle_walks_the_group_urls() {
39 + let kit = MockKit::start().await;
40 + let (client, master) = kit.keyed();
41 + let admin = IdentityKeypair::from_master_key(&master);
42 + let (admin_id, app_id) = test_ids();
43 +
44 + // ── create ──
45 + kit.post(GROUPS)
46 + .responder(EchoCreatedGroup { admin_id, app_id })
47 + .await;
48 +
49 + let group = client.create_group("Design team").await.unwrap();
50 + assert_eq!(group.name, "Design team");
51 + assert_eq!(group.gck_version, 1);
52 +
53 + let created = kit.bodies("POST", GROUPS).await.remove(0);
54 + assert_eq!(created["name"], "Design team");
55 + assert_eq!(
56 + created["admin_pubkey"].as_str().unwrap(),
57 + client.my_identity_public_key().unwrap()
58 + );
59 + // The admin's grant is sealed before the round-trip, against the id the
60 + // client chose, so it must open under generation 1 for that id.
61 + let gck = open_gck_grant(
62 + created["admin_sealed_gck"].as_str().unwrap(),
63 + &admin,
64 + &group.id.to_string(),
65 + 1,
66 + )
67 + .expect("the creator must be able to open their own grant");
68 +
69 + let members_path = format!("{GROUPS}/{}/members", group.id);
70 + let pubkeys_path = format!("{GROUPS}/{}/pubkeys", group.id);
71 + let grant_path = format!("{GROUPS}/{}/grant", group.id);
72 + let rotate_path = format!("{GROUPS}/{}/rotate", group.id);
73 +
74 + // ── add ──
75 + // add_member reads the admin's own grant to recover the key it re-seals, so
76 + // hand back exactly what create sealed.
77 + kit.get(&grant_path)
78 + .json(json!({
79 + "sealed_gck": created["admin_sealed_gck"],
80 + "gck_version": 1,
81 + }))
82 + .await;
83 + kit.post(&members_path).code(204).empty().await;
84 +
85 + let bob = IdentityKeypair::generate();
86 + let bob_id = UserId::new(Uuid::new_v4());
87 + client
88 + .add_member(group.id, "bob@example.com", &bob.public_key().to_base64())
89 + .await
90 + .unwrap();
91 +
92 + let added = kit.bodies("POST", &members_path).await.remove(0);
93 + assert_eq!(added["member_email"], "bob@example.com");
94 + assert_eq!(
95 + added["member_pubkey"].as_str().unwrap(),
96 + bob.public_key().to_base64()
97 + );
98 + assert_eq!(
99 + open_gck_grant(
100 + added["sealed_gck"].as_str().unwrap(),
101 + &bob,
102 + &group.id.to_string(),
103 + 1,
104 + )
105 + .expect("the new member must be able to open the grant sealed to them"),
106 + gck,
107 + "the member is sealed the group's current key, not a fresh one"
108 + );
109 +
110 + // ── list ──
111 + kit.get(&members_path)
112 + .json(json!([
113 + {
114 + "user_id": admin_id,
115 + "email": "admin@example.com",
116 + "role": "admin",
117 + "added_at": "2026-06-07T00:00:00Z",
118 + },
119 + {
120 + "user_id": bob_id,
121 + "email": "bob@example.com",
122 + "role": "member",
123 + "added_at": "2026-06-08T00:00:00Z",
124 + },
125 + ]))
126 + .await;
127 +
128 + let members = client.list_members(group.id).await.unwrap();
129 + assert_eq!(members.len(), 2);
130 + assert_eq!(members[0].user_id, admin_id);
131 + assert_eq!(members[0].role, "admin");
132 + assert_eq!(members[1].email, "bob@example.com");
133 +
134 + // The pubkey list is a different endpoint from the member list: it carries
135 + // the re-seal inputs rather than the roster.
136 + kit.get(&pubkeys_path)
137 + .json(json!([
138 + {"user_id": admin_id, "pubkey": admin.public_key().to_base64()},
139 + {"user_id": bob_id, "pubkey": bob.public_key().to_base64()},
140 + ]))
141 + .await;
142 +
143 + let pubkeys = client.list_member_pubkeys(group.id).await.unwrap();
144 + assert_eq!(pubkeys.len(), 2);
145 + assert_eq!(pubkeys[1].user_id, bob_id);
146 + assert_eq!(pubkeys[1].pubkey, bob.public_key().to_base64());
147 +
148 + // ── remove ──
149 + // Removal is a rotation, so it must not touch the per-member URL: leaving
150 + // the member's key working is the failure this shape exists to prevent.
151 + kit.post(&rotate_path).code(204).empty().await;
152 + let member_path = format!("{members_path}/{bob_id}");
153 + kit.matching("DELETE", path(member_path.clone()))
154 + .code(204)
155 + .empty()
156 + .await;
157 +
158 + client.remove_member(group.id, bob_id).await.unwrap();
159 + assert_eq!(kit.hits(&rotate_path).await, 1);
160 + assert_eq!(
161 + kit.hits(&member_path).await,
162 + 0,
163 + "remove_member re-keys instead of deleting the membership row"
164 + );
165 +
166 + // ── list the groups back ──
167 + kit.get(GROUPS)
168 + .json(json!([{
169 + "id": group.id,
170 + "app_id": app_id,
171 + "admin_user_id": admin_id,
172 + "name": "Design team",
173 + "gck_version": 2,
174 + "created_at": "2026-06-07T00:00:00Z",
175 + }]))
176 + .await;
177 +
178 + let groups = client.list_groups().await.unwrap();
179 + assert_eq!(groups.len(), 1);
180 + assert_eq!(groups[0].id, group.id);
181 + assert_eq!(
182 + groups[0].gck_version, 2,
183 + "the removal advanced the generation"
184 + );
185 +
186 + // Each step went to its own URL. The reads a rotation makes are counted
187 + // here too: removal re-reads the grant and the pubkey list to build its
188 + // re-seal batch, so those are two each rather than one.
189 + assert_eq!(kit.hits(&grant_path).await, 2);
190 + assert_eq!(kit.hits(&pubkeys_path).await, 2);
191 + assert_eq!(kit.hits(&members_path).await, 2, "added, then read back");
192 + assert_eq!(kit.hits(GROUPS).await, 2, "created, then listed");
193 + }
194 +
195 + #[tokio::test]
196 + async fn revoking_without_a_rekey_deletes_the_member_url_and_rotates_nothing() {
197 + // The escape hatch for a member whose stored public key will not re-seal:
198 + // access ends now, and the group key deliberately does not change.
199 + let kit = MockKit::start().await;
200 + let client = kit.authed();
201 + let group_id = synckit_client::GroupId::new(Uuid::new_v4());
202 + let member = UserId::new(Uuid::new_v4());
203 +
204 + let member_path = format!("{GROUPS}/{group_id}/members/{member}");
205 + let rotate_path = format!("{GROUPS}/{group_id}/rotate");
206 + kit.matching("DELETE", path(member_path.clone()))
207 + .code(204)
208 + .empty()
209 + .await;
210 + kit.post(&rotate_path).code(204).empty().await;
211 +
212 + client
213 + .revoke_member_without_rekey(group_id, member)
214 + .await
215 + .unwrap();
216 +
217 + let requests = kit.requests_to(&member_path).await;
218 + assert_eq!(requests.len(), 1);
219 + assert_eq!(requests[0].method.as_str(), "DELETE");
220 + assert_eq!(
221 + kit.hits(&rotate_path).await,
222 + 0,
223 + "revocation alone must not mint a new key"
224 + );
225 + }
226 +
227 + #[tokio::test]
228 + async fn a_grant_for_one_generation_asks_for_that_generation() {
229 + // Reading an entry written before a rotation needs the grant from the
230 + // generation it was sealed under, which is the query the newest-grant read
231 + // does not carry.
232 + let kit = MockKit::start().await;
233 + let (client, master) = kit.keyed();
234 + let admin = IdentityKeypair::from_master_key(&master);
235 + let group_id = synckit_client::GroupId::new(Uuid::new_v4());
236 + let gck = synckit_client::generate_group_key();
237 + let sealed =
238 + synckit_client::seal_gck_to_member(&gck, &admin.public_key(), &group_id.to_string(), 3)
239 + .unwrap();
240 +
241 + let grant_path = format!("{GROUPS}/{group_id}/grant");
242 + kit.get(&grant_path)
243 + .json(json!({"sealed_gck": sealed, "gck_version": 3}))
244 + .await;
245 +
246 + let grant = client.group_grant_at(group_id, 3).await.unwrap();
247 + assert_eq!(grant.gck_version, 3);
248 + assert_eq!(
249 + open_gck_grant(&grant.sealed_gck, &admin, &group_id.to_string(), 3).unwrap(),
250 + gck
251 + );
252 +
253 + let requests = kit.requests_to(&grant_path).await;
254 + assert_eq!(requests.len(), 1);
255 + assert_eq!(
256 + requests[0].url.query(),
257 + Some("version=3"),
258 + "the generation travels in the query, not the path"
259 + );
260 + }