Skip to main content

max / makenotwork

13.7 KB · 361 lines History Blame Raw
1 //! HTTP contract tests for `routes::synckit::groups`, specifically the three
2 //! guards at the top of that file and which of its thirteen handlers each one
3 //! covers.
4 //!
5 //! `synckit_group_rotation` owns the rotation state machine, `synckit_groups_billing`
6 //! the subscription gate, and `db_synckit_groups` / `db_synckit_invitations` the
7 //! layer beneath. All of them authenticate as somebody who is allowed to be there.
8 //! Between them exactly one handler, `rotate`, has its permission checked.
9 //!
10 //! The guards are `require_group` (404 for a group outside the caller's app),
11 //! `require_member` (403 for group-scoped reads and writes) and `require_admin`
12 //! (403 for membership management). Each is one line at the top of each handler,
13 //! which is the cheapest thing in the file to leave out and the most expensive to
14 //! leave out of the wrong one. A missing `require_admin` on `add_member` lets any
15 //! member add anyone; a missing `require_member` on `pull` hands a stranger the
16 //! whole group changelog; a missing `require_group` turns a group id into a
17 //! cross-tenant read.
18 //!
19 //! So this file walks the matrix rather than the features: for a stranger, and
20 //! then for a member who is not the admin, it asks every handler and asserts the
21 //! answer. What it pins is the shape of the wall, not any one brick.
22 //!
23 //! One distinction is deliberate and worth keeping straight. A group in another
24 //! app answers 404, not 403: the caller's app scope is applied before membership
25 //! is consulted, so a developer cannot use group ids to learn what exists in
26 //! somebody else's app. Within the caller's own app, a group they are not in
27 //! answers 403, which tells them only what they could learn by being told to go
28 //! away.
29
30 use serde_json::json;
31
32 use super::synckit_paid_sync::{auth_as, create_internal_app, harness_with_blobs};
33 use crate::harness::TestHarness;
34 use makenotwork::db::{SyncAppId, UserId};
35
36 /// The admin-only endpoints, as `(method, path suffix, body)`. `{g}` is the
37 /// group id. A member who is not the admin must be refused every one of them.
38 const ADMIN_ONLY: &[(&str, &str)] = &[
39 ("POST", "/members"),
40 ("GET", "/pubkeys"),
41 ("POST", "/rotate"),
42 ("POST", "/invitations"),
43 ("GET", "/invitations"),
44 ];
45
46 /// A body that would be valid if the caller were allowed. The guard runs before
47 /// the body is acted on, so these exist to prove the refusal is the guard rather
48 /// than a parse failure further in.
49 fn plausible_body(suffix: &str, subject: UserId) -> String {
50 match suffix {
51 "/members" => json!({
52 "member_email": "nobody@example.com",
53 "sealed_gck": "sealed_x_v1",
54 "member_pubkey": "pk_x",
55 })
56 .to_string(),
57 "/rotate" => json!({
58 "gck_version": 2,
59 "grants": [{ "user_id": subject.to_string(), "sealed_gck": "sealed_x_v2" }],
60 })
61 .to_string(),
62 "/invitations" => json!({ "note": "join us" }).to_string(),
63 // The guards run before the device is resolved, so any well-formed id
64 // reaches them; using a real device would test the device check instead.
65 "/pull" => {
66 json!({ "device_id": uuid::Uuid::new_v4().to_string(), "cursor": 0 }).to_string()
67 }
68 _ => String::new(),
69 }
70 }
71
72 async fn call(
73 h: &mut TestHarness,
74 method: &str,
75 path: &str,
76 body: &str,
77 ) -> crate::harness::client::TestResponse {
78 match method {
79 "GET" => h.client.get(path).await,
80 "POST" => h.client.post_json(path, body).await,
81 "DELETE" => h.client.delete(path).await,
82 other => panic!("unhandled method {other}"),
83 }
84 }
85
86 async fn create_group(h: &mut TestHarness, name: &str) -> String {
87 let resp = h
88 .client
89 .post_json(
90 "/api/sync/groups",
91 &json!({
92 "id": uuid::Uuid::new_v4().to_string(),
93 "name": name,
94 "admin_sealed_gck": "sealed_admin_v1",
95 "admin_pubkey": "pk_admin",
96 })
97 .to_string(),
98 )
99 .await;
100 assert_eq!(resp.status.as_u16(), 200, "create group: {}", resp.text);
101 resp.json::<serde_json::Value>()["id"]
102 .as_str()
103 .expect("group id")
104 .to_string()
105 }
106
107 /// Seed a verified account and add it to the group as an ordinary member.
108 async fn add_member(h: &mut TestHarness, group_id: &str, username: &str) -> UserId {
109 let email = format!("{username}@example.com");
110 let user = h.signup(username, &email, "Password1!").await;
111 sqlx::query("UPDATE users SET email_verified = true WHERE id = $1")
112 .bind(user)
113 .execute(&h.db)
114 .await
115 .expect("verify member");
116
117 let resp = h
118 .client
119 .post_json(
120 &format!("/api/sync/groups/{group_id}/members"),
121 &json!({
122 "member_email": email,
123 "sealed_gck": format!("sealed_{username}_v1"),
124 "member_pubkey": format!("pk_{username}"),
125 })
126 .to_string(),
127 )
128 .await;
129 assert_eq!(resp.status.as_u16(), 204, "add member: {}", resp.text);
130 user
131 }
132
133 /// An admin with one group, plus the app they both live in.
134 async fn group_with_admin(h: &mut TestHarness, tag: &str) -> (UserId, SyncAppId, String) {
135 let admin = h
136 .signup(
137 &format!("{tag}_admin"),
138 &format!("{tag}_admin@example.com"),
139 "Password1!",
140 )
141 .await;
142 let (app, _key) = create_internal_app(&h.db, admin).await;
143 auth_as(h, admin, app, "admin-key");
144 let group_id = create_group(h, "Team").await;
145 (admin, app, group_id)
146 }
147
148 /// Every group-scoped handler, asked by somebody in the same app who is not in
149 /// the group. All of them must answer 403, and none of them may leak the group's
150 /// contents on the way.
151 ///
152 /// The read endpoints matter most here: `pull` and `grant` return the group's
153 /// encrypted changelog and a sealed key, and an endpoint that answered
154 /// 200-with-nothing rather than 403 would be one refactor away from answering
155 /// 200-with-something.
156 #[tokio::test]
157 async fn a_stranger_in_the_same_app_is_refused_every_group_endpoint() {
158 let (mut h, _blobs) = harness_with_blobs().await;
159 let (_admin, app, group_id) = group_with_admin(&mut h, "stranger").await;
160
161 let stranger = h
162 .signup("stranger_eve", "stranger_eve@example.com", "Password1!")
163 .await;
164 auth_as(&mut h, stranger, app, "eve-key");
165
166 // Member-gated and admin-gated alike: a non-member is refused by the first
167 // guard either way, so the whole surface answers the same way.
168 let endpoints: Vec<(&str, String, String)> = vec![
169 ("GET", "/members".into(), String::new()),
170 (
171 "POST",
172 "/members".into(),
173 plausible_body("/members", stranger),
174 ),
175 ("GET", "/pubkeys".into(), String::new()),
176 (
177 "POST",
178 "/rotate".into(),
179 plausible_body("/rotate", stranger),
180 ),
181 ("GET", "/grant".into(), String::new()),
182 ("POST", "/pull".into(), plausible_body("/pull", stranger)),
183 (
184 "POST",
185 "/invitations".into(),
186 plausible_body("/invitations", stranger),
187 ),
188 ("GET", "/invitations".into(), String::new()),
189 ];
190
191 for (method, suffix, body) in endpoints {
192 let path = format!("/api/sync/groups/{group_id}{suffix}");
193 let resp = call(&mut h, method, &path, &body).await;
194 assert_eq!(
195 resp.status.as_u16(),
196 403,
197 "{method} {suffix} must refuse a non-member, got {}: {}",
198 resp.status,
199 resp.text
200 );
201 assert!(
202 !resp.text.contains("sealed_admin_v1"),
203 "{method} {suffix} leaked a grant to a non-member"
204 );
205 }
206
207 // And the group does not show up in what they can see.
208 let resp = h.client.get("/api/sync/groups").await;
209 assert_eq!(resp.status.as_u16(), 200, "list groups: {}", resp.text);
210 assert!(
211 !resp.text.contains(&group_id),
212 "a group the caller is not in must not be listed for them"
213 );
214 }
215
216 /// The admin-only half of the matrix, asked by a real member. This is the
217 /// distinction a single missing guard erases: the caller is legitimately in the
218 /// group, so `require_member` passes and only `require_admin` is left.
219 ///
220 /// A member who could add members could add themselves an accomplice; one who
221 /// could read `/pubkeys` could seal to identities they were never given; one who
222 /// could mint an invitation could hand out the group.
223 #[tokio::test]
224 async fn a_member_who_is_not_the_admin_is_refused_the_admin_endpoints() {
225 let (mut h, _blobs) = harness_with_blobs().await;
226 let (_admin, app, group_id) = group_with_admin(&mut h, "member").await;
227 let bob = add_member(&mut h, &group_id, "member_bob").await;
228
229 auth_as(&mut h, bob, app, "bob-key");
230
231 for (method, suffix) in ADMIN_ONLY {
232 let path = format!("/api/sync/groups/{group_id}{suffix}");
233 let resp = call(&mut h, method, &path, &plausible_body(suffix, bob)).await;
234 assert_eq!(
235 resp.status.as_u16(),
236 403,
237 "{method} {suffix} is admin-only, got {}: {}",
238 resp.status,
239 resp.text
240 );
241 }
242
243 // The member-gated endpoints stay open to them, which is what makes the
244 // assertions above about `require_admin` rather than about being refused
245 // in general.
246 for suffix in ["/members", "/grant"] {
247 let path = format!("/api/sync/groups/{group_id}{suffix}");
248 let resp = h.client.get(&path).await;
249 assert_eq!(
250 resp.status.as_u16(),
251 200,
252 "GET {suffix} is open to a member: {}",
253 resp.text
254 );
255 }
256 }
257
258 /// A member cannot remove another member, and in particular cannot remove the
259 /// admin. `remove_member` is the one admin endpoint whose path carries a second
260 /// id, so it is the one where a guard could be written against the wrong subject.
261 #[tokio::test]
262 async fn a_member_cannot_remove_anyone_including_the_admin() {
263 let (mut h, _blobs) = harness_with_blobs().await;
264 let (admin, app, group_id) = group_with_admin(&mut h, "removal").await;
265 let bob = add_member(&mut h, &group_id, "removal_bob").await;
266 let carol = add_member(&mut h, &group_id, "removal_carol").await;
267
268 auth_as(&mut h, bob, app, "bob-key");
269
270 for target in [admin, carol, bob] {
271 let resp = h
272 .client
273 .delete(&format!("/api/sync/groups/{group_id}/members/{target}"))
274 .await;
275 assert_eq!(
276 resp.status.as_u16(),
277 403,
278 "a member removing {target} must be refused, got {}: {}",
279 resp.status,
280 resp.text
281 );
282 }
283
284 // Nobody left. Read it back as the admin, since membership is admin-visible
285 // and this is the assertion the three refusals above are for.
286 auth_as(&mut h, admin, app, "admin-key");
287 let resp = h
288 .client
289 .get(&format!("/api/sync/groups/{group_id}/members"))
290 .await;
291 assert_eq!(resp.status.as_u16(), 200, "list members: {}", resp.text);
292 let members = resp.json::<serde_json::Value>();
293 assert_eq!(
294 members.as_array().map(Vec::len),
295 Some(3),
296 "all three are still in the group: {members}"
297 );
298 }
299
300 /// App scope is applied before membership, so a group belonging to another app
301 /// is 404 rather than 403 even to a caller who is its admin under a different
302 /// token. Answering 403 would confirm the id exists, which is a cross-tenant
303 /// read of exactly the kind the scope is there to refuse.
304 #[tokio::test]
305 async fn a_group_in_another_app_is_not_found_rather_than_forbidden() {
306 let (mut h, _blobs) = harness_with_blobs().await;
307 let (admin, _first_app, group_id) = group_with_admin(&mut h, "scope").await;
308
309 // The same human, a second app of their own. Nothing about the group changed;
310 // only the app claim in the token did. Inserted here rather than through
311 // `create_internal_app`, which mints a fixed api_key and so cannot be called
312 // twice against one database.
313 let second_key = "test-api-key-group-scope-second";
314 let second_app: SyncAppId = sqlx::query_scalar(
315 "INSERT INTO sync_apps (creator_id, name, api_key_hash, api_key_prefix, is_internal, billing_status) \
316 VALUES ($1, 'SecondApp', $2, $3, TRUE, 'active') RETURNING id",
317 )
318 .bind(admin)
319 .bind(crate::harness::hash_api_key(second_key))
320 .bind(&second_key[..8])
321 .fetch_one(&h.db)
322 .await
323 .expect("insert the second internal app");
324 auth_as(&mut h, admin, second_app, second_key);
325
326 for (method, suffix) in [("GET", "/members"), ("GET", "/grant"), ("POST", "/pull")] {
327 let path = format!("/api/sync/groups/{group_id}{suffix}");
328 let resp = call(&mut h, method, &path, &plausible_body(suffix, admin)).await;
329 assert_eq!(
330 resp.status.as_u16(),
331 404,
332 "{method} {suffix} under another app must not confirm the id exists, \
333 got {}: {}",
334 resp.status,
335 resp.text
336 );
337 }
338 }
339
340 /// A group id that exists nowhere answers the same 404 as one in another app, so
341 /// the two cases are indistinguishable from outside. If they diverged, the pair
342 /// would become an oracle for which group ids are real.
343 #[tokio::test]
344 async fn an_unknown_group_answers_the_same_as_one_in_another_app() {
345 let (mut h, _blobs) = harness_with_blobs().await;
346 let (_admin, _app, _group_id) = group_with_admin(&mut h, "ghost").await;
347
348 let ghost = uuid::Uuid::new_v4();
349 let resp = h
350 .client
351 .get(&format!("/api/sync/groups/{ghost}/members"))
352 .await;
353
354 assert_eq!(
355 resp.status.as_u16(),
356 404,
357 "an id that matches no group is not found: {}",
358 resp.text
359 );
360 }
361