//! HTTP contract tests for `routes::synckit::groups`, specifically the three //! guards at the top of that file and which of its thirteen handlers each one //! covers. //! //! `synckit_group_rotation` owns the rotation state machine, `synckit_groups_billing` //! the subscription gate, and `db_synckit_groups` / `db_synckit_invitations` the //! layer beneath. All of them authenticate as somebody who is allowed to be there. //! Between them exactly one handler, `rotate`, has its permission checked. //! //! The guards are `require_group` (404 for a group outside the caller's app), //! `require_member` (403 for group-scoped reads and writes) and `require_admin` //! (403 for membership management). Each is one line at the top of each handler, //! which is the cheapest thing in the file to leave out and the most expensive to //! leave out of the wrong one. A missing `require_admin` on `add_member` lets any //! member add anyone; a missing `require_member` on `pull` hands a stranger the //! whole group changelog; a missing `require_group` turns a group id into a //! cross-tenant read. //! //! So this file walks the matrix rather than the features: for a stranger, and //! then for a member who is not the admin, it asks every handler and asserts the //! answer. What it pins is the shape of the wall, not any one brick. //! //! One distinction is deliberate and worth keeping straight. A group in another //! app answers 404, not 403: the caller's app scope is applied before membership //! is consulted, so a developer cannot use group ids to learn what exists in //! somebody else's app. Within the caller's own app, a group they are not in //! answers 403, which tells them only what they could learn by being told to go //! away. use serde_json::json; use super::synckit_paid_sync::{auth_as, create_internal_app, harness_with_blobs}; use crate::harness::TestHarness; use makenotwork::db::{SyncAppId, UserId}; /// The admin-only endpoints, as `(method, path suffix, body)`. `{g}` is the /// group id. A member who is not the admin must be refused every one of them. const ADMIN_ONLY: &[(&str, &str)] = &[ ("POST", "/members"), ("GET", "/pubkeys"), ("POST", "/rotate"), ("POST", "/invitations"), ("GET", "/invitations"), ]; /// A body that would be valid if the caller were allowed. The guard runs before /// the body is acted on, so these exist to prove the refusal is the guard rather /// than a parse failure further in. fn plausible_body(suffix: &str, subject: UserId) -> String { match suffix { "/members" => json!({ "member_email": "nobody@example.com", "sealed_gck": "sealed_x_v1", "member_pubkey": "pk_x", }) .to_string(), "/rotate" => json!({ "gck_version": 2, "grants": [{ "user_id": subject.to_string(), "sealed_gck": "sealed_x_v2" }], }) .to_string(), "/invitations" => json!({ "note": "join us" }).to_string(), // The guards run before the device is resolved, so any well-formed id // reaches them; using a real device would test the device check instead. "/pull" => { json!({ "device_id": uuid::Uuid::new_v4().to_string(), "cursor": 0 }).to_string() } _ => String::new(), } } async fn call( h: &mut TestHarness, method: &str, path: &str, body: &str, ) -> crate::harness::client::TestResponse { match method { "GET" => h.client.get(path).await, "POST" => h.client.post_json(path, body).await, "DELETE" => h.client.delete(path).await, other => panic!("unhandled method {other}"), } } 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.as_u16(), 200, "create group: {}", resp.text); resp.json::()["id"] .as_str() .expect("group id") .to_string() } /// Seed a verified account and add it to the group as an ordinary member. async fn add_member(h: &mut TestHarness, group_id: &str, username: &str) -> UserId { let email = format!("{username}@example.com"); 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.as_u16(), 204, "add member: {}", resp.text); user } /// An admin with one group, plus the app they both live in. async fn group_with_admin(h: &mut TestHarness, tag: &str) -> (UserId, SyncAppId, String) { let admin = h .signup( &format!("{tag}_admin"), &format!("{tag}_admin@example.com"), "Password1!", ) .await; let (app, _key) = create_internal_app(&h.db, admin).await; auth_as(h, admin, app, "admin-key"); let group_id = create_group(h, "Team").await; (admin, app, group_id) } /// Every group-scoped handler, asked by somebody in the same app who is not in /// the group. All of them must answer 403, and none of them may leak the group's /// contents on the way. /// /// The read endpoints matter most here: `pull` and `grant` return the group's /// encrypted changelog and a sealed key, and an endpoint that answered /// 200-with-nothing rather than 403 would be one refactor away from answering /// 200-with-something. #[tokio::test] async fn a_stranger_in_the_same_app_is_refused_every_group_endpoint() { let (mut h, _blobs) = harness_with_blobs().await; let (_admin, app, group_id) = group_with_admin(&mut h, "stranger").await; let stranger = h .signup("stranger_eve", "stranger_eve@example.com", "Password1!") .await; auth_as(&mut h, stranger, app, "eve-key"); // Member-gated and admin-gated alike: a non-member is refused by the first // guard either way, so the whole surface answers the same way. let endpoints: Vec<(&str, String, String)> = vec![ ("GET", "/members".into(), String::new()), ( "POST", "/members".into(), plausible_body("/members", stranger), ), ("GET", "/pubkeys".into(), String::new()), ( "POST", "/rotate".into(), plausible_body("/rotate", stranger), ), ("GET", "/grant".into(), String::new()), ("POST", "/pull".into(), plausible_body("/pull", stranger)), ( "POST", "/invitations".into(), plausible_body("/invitations", stranger), ), ("GET", "/invitations".into(), String::new()), ]; for (method, suffix, body) in endpoints { let path = format!("/api/sync/groups/{group_id}{suffix}"); let resp = call(&mut h, method, &path, &body).await; assert_eq!( resp.status.as_u16(), 403, "{method} {suffix} must refuse a non-member, got {}: {}", resp.status, resp.text ); assert!( !resp.text.contains("sealed_admin_v1"), "{method} {suffix} leaked a grant to a non-member" ); } // And the group does not show up in what they can see. let resp = h.client.get("/api/sync/groups").await; assert_eq!(resp.status.as_u16(), 200, "list groups: {}", resp.text); assert!( !resp.text.contains(&group_id), "a group the caller is not in must not be listed for them" ); } /// The admin-only half of the matrix, asked by a real member. This is the /// distinction a single missing guard erases: the caller is legitimately in the /// group, so `require_member` passes and only `require_admin` is left. /// /// A member who could add members could add themselves an accomplice; one who /// could read `/pubkeys` could seal to identities they were never given; one who /// could mint an invitation could hand out the group. #[tokio::test] async fn a_member_who_is_not_the_admin_is_refused_the_admin_endpoints() { let (mut h, _blobs) = harness_with_blobs().await; let (_admin, app, group_id) = group_with_admin(&mut h, "member").await; let bob = add_member(&mut h, &group_id, "member_bob").await; auth_as(&mut h, bob, app, "bob-key"); for (method, suffix) in ADMIN_ONLY { let path = format!("/api/sync/groups/{group_id}{suffix}"); let resp = call(&mut h, method, &path, &plausible_body(suffix, bob)).await; assert_eq!( resp.status.as_u16(), 403, "{method} {suffix} is admin-only, got {}: {}", resp.status, resp.text ); } // The member-gated endpoints stay open to them, which is what makes the // assertions above about `require_admin` rather than about being refused // in general. for suffix in ["/members", "/grant"] { let path = format!("/api/sync/groups/{group_id}{suffix}"); let resp = h.client.get(&path).await; assert_eq!( resp.status.as_u16(), 200, "GET {suffix} is open to a member: {}", resp.text ); } } /// A member cannot remove another member, and in particular cannot remove the /// admin. `remove_member` is the one admin endpoint whose path carries a second /// id, so it is the one where a guard could be written against the wrong subject. #[tokio::test] async fn a_member_cannot_remove_anyone_including_the_admin() { let (mut h, _blobs) = harness_with_blobs().await; let (admin, app, group_id) = group_with_admin(&mut h, "removal").await; let bob = add_member(&mut h, &group_id, "removal_bob").await; let carol = add_member(&mut h, &group_id, "removal_carol").await; auth_as(&mut h, bob, app, "bob-key"); for target in [admin, carol, bob] { let resp = h .client .delete(&format!("/api/sync/groups/{group_id}/members/{target}")) .await; assert_eq!( resp.status.as_u16(), 403, "a member removing {target} must be refused, got {}: {}", resp.status, resp.text ); } // Nobody left. Read it back as the admin, since membership is admin-visible // and this is the assertion the three refusals above are for. auth_as(&mut h, admin, app, "admin-key"); let resp = h .client .get(&format!("/api/sync/groups/{group_id}/members")) .await; assert_eq!(resp.status.as_u16(), 200, "list members: {}", resp.text); let members = resp.json::(); assert_eq!( members.as_array().map(Vec::len), Some(3), "all three are still in the group: {members}" ); } /// App scope is applied before membership, so a group belonging to another app /// is 404 rather than 403 even to a caller who is its admin under a different /// token. Answering 403 would confirm the id exists, which is a cross-tenant /// read of exactly the kind the scope is there to refuse. #[tokio::test] async fn a_group_in_another_app_is_not_found_rather_than_forbidden() { let (mut h, _blobs) = harness_with_blobs().await; let (admin, _first_app, group_id) = group_with_admin(&mut h, "scope").await; // The same human, a second app of their own. Nothing about the group changed; // only the app claim in the token did. Inserted here rather than through // `create_internal_app`, which mints a fixed api_key and so cannot be called // twice against one database. let second_key = "test-api-key-group-scope-second"; let second_app: SyncAppId = sqlx::query_scalar( "INSERT INTO sync_apps (creator_id, name, api_key_hash, api_key_prefix, is_internal, billing_status) \ VALUES ($1, 'SecondApp', $2, $3, TRUE, 'active') RETURNING id", ) .bind(admin) .bind(crate::harness::hash_api_key(second_key)) .bind(&second_key[..8]) .fetch_one(&h.db) .await .expect("insert the second internal app"); auth_as(&mut h, admin, second_app, second_key); for (method, suffix) in [("GET", "/members"), ("GET", "/grant"), ("POST", "/pull")] { let path = format!("/api/sync/groups/{group_id}{suffix}"); let resp = call(&mut h, method, &path, &plausible_body(suffix, admin)).await; assert_eq!( resp.status.as_u16(), 404, "{method} {suffix} under another app must not confirm the id exists, \ got {}: {}", resp.status, resp.text ); } } /// A group id that exists nowhere answers the same 404 as one in another app, so /// the two cases are indistinguishable from outside. If they diverged, the pair /// would become an oracle for which group ids are real. #[tokio::test] async fn an_unknown_group_answers_the_same_as_one_in_another_app() { let (mut h, _blobs) = harness_with_blobs().await; let (_admin, _app, _group_id) = group_with_admin(&mut h, "ghost").await; let ghost = uuid::Uuid::new_v4(); let resp = h .client .get(&format!("/api/sync/groups/{ghost}/members")) .await; assert_eq!( resp.status.as_u16(), 404, "an id that matches no group is not found: {}", resp.text ); }