//! Role enforcement over real HTTP: a viewer/editor/owner matrix on every vault //! endpoint, membership management rules, and the invite lifecycle. Non-members are //! always 404, viewers above their level are 403. mod common; use common::Server; use serde_json::json; /// A vault with an owner, an invited editor, an invited viewer and a stranger that /// only belongs to a different vault. struct Fixture { vault: String, owner: String, editor: String, viewer: String, stranger: String, editor_user: String, viewer_user: String, } fn fixture(server: &Server, name: &str) -> Fixture { let (vault, owner, _) = server.owned_vault(name); // Tokens that are not members yet come from accounts of their own bootstrap vaults: // each pairing without `user` makes a new account; only the invite adds membership. let (_, editor) = server.paired(name); let (_, viewer) = server.paired(name); let (_, stranger) = server.paired(name); let (status, code) = server.invite(&vault, &owner, "editor"); assert_eq!(status, 200); let (status, _) = server.accept(&editor, &code); assert_eq!(status, 200); let (status, code) = server.invite(&vault, &owner, "viewer"); assert_eq!(status, 200); let (status, _) = server.accept(&viewer, &code); assert_eq!(status, 200); // The owner writes one file every role can read. assert_eq!(server.put(&vault, &owner, "a.md", "shared", 0).0, 201); // Member ids for the management routes come from the member listing. let (_, members) = server.json("GET", &format!("/v1/vaults/{vault}/members"), Some(&owner), &[], None); let member_of = |role: &str| { members.as_array().expect("members").iter().find(|row| row["role"] == role).unwrap_or_else(|| panic!("no {role} member"))["user_id"] .as_str() .expect("user id") .to_owned() }; Fixture { vault, owner, editor, viewer, stranger, editor_user: member_of("editor"), viewer_user: member_of("viewer") } } /// Expected status of a read for `role`: members 200, non-members 404. fn read_expect(role: &str) -> u16 { if role == "stranger" { 404 } else { 200 } } /// Expected status of a file write for `role`. fn write_expect(role: &str) -> u16 { match role { "stranger" => 404, "editor" | "owner" => 201, _ => 403, // viewers } } /// Expected status of an admin operation for `role` when the owner has it working. fn admin_expect(role: &str, owner_status: u16) -> u16 { match role { "stranger" => 404, "owner" => owner_status, _ => 403, } } #[test] fn roles_match_the_matrix_on_every_vault_endpoint() { let server = Server::start_temp("roles-matrix"); let fix = fixture(&server, "matrix"); let vault = fix.vault.as_str(); let tokens: [(&str, &String); 4] = [("owner", &fix.owner), ("editor", &fix.editor), ("viewer", &fix.viewer), ("stranger", &fix.stranger)]; // (No pre-created files: each role deletes the file it wrote, on top of its // own sequence, so the delete case exercises application, not the missing-file 404.) for (role, token) in tokens { // Reads: every member (viewer included) 200, a stranger 404. assert_eq!(server.send("GET", &format!("/v1/vaults/{vault}/changes?since=0"), Some(token), &[], None).0, read_expect(role), "changes {role}"); // Writes, on role-named paths so the roles do not interfere; whoever wrote // (201) deletes the same file on top of the sequence the write got. let (status, record) = server.put(vault, token, &format!("write-{role}.md"), "x", 0); assert_eq!(status, write_expect(role), "file put {role}"); if let Some(seq) = record["seq"].as_u64() { let (status, _) = server.send("DELETE", &format!("/v1/vaults/{vault}/files/write-{role}.md"), Some(token), &[("X-Base-Seq", &seq.to_string())], None); assert_eq!(status, 201, "file delete {role}"); } // Admin: rename, invite listing, minting. assert_eq!( server.json("PATCH", &format!("/v1/vaults/{vault}"), Some(token), &[], Some(&json!({"name": "matrix"}))).0, admin_expect(role, 204), "rename {role}" ); assert_eq!(server.json("GET", &format!("/v1/vaults/{vault}/invites"), Some(token), &[], None).0, admin_expect(role, 200), "invite list {role}"); assert_eq!( server.json("POST", &format!("/v1/vaults/{vault}/invites"), Some(token), &[], Some(&json!({"role": "viewer"}))).0, admin_expect(role, 200), "invite mint {role}" ); } } #[test] fn members_are_listed_changed_removed_and_left() { let server = Server::start_temp("roles-members"); let fix = fixture(&server, "members"); let base = format!("/v1/vaults/{}", fix.vault); // The listing names every member with the fields the task lists. let (_, members) = server.json("GET", &format!("{base}/members"), Some(&fix.viewer), &[], None); let rows = members.as_array().expect("members"); assert_eq!(rows.len(), 3); for row in rows { assert!(row.get("user_id").is_some() && row.get("name").is_some() && row.get("color").is_some() && row.get("role").is_some()); } assert_eq!(rows.iter().filter(|row| row["role"] == "owner").count(), 1); // Role changes are owner-only; the last owner can never be demoted. let owner_user = rows.iter().find(|row| row["role"] == "owner").expect("owner")["user_id"].as_str().expect("id").to_owned(); let change = |token: &str, user: &str, role: &str| server.json("PATCH", &format!("{base}/members/{user}"), Some(token), &[], Some(&json!({"role": role}))).0; assert_eq!(change(&fix.editor, &fix.viewer_user, "owner"), 403, "only the owner changes roles"); assert_eq!(change(&fix.owner, &owner_user, "viewer"), 409, "the last owner cannot be demoted"); assert_eq!(change(&fix.owner, &fix.editor_user, "owner"), 204); assert_eq!(change(&fix.owner, &fix.editor_user, "editor"), 204, "a role change back works with two owners"); assert_eq!(change(&fix.owner, "u-unknown", "viewer"), 404, "an unknown member"); // Removal: owner removes anyone; a member leaves alone; the last owner stays. let drop = |token: &str, user: &str| server.send("DELETE", &format!("{base}/members/{user}"), Some(token), &[], None).0; assert_eq!(drop(&fix.viewer, &fix.editor_user), 403, "a non-owner removes nobody but themselves"); assert_eq!(drop(&fix.owner, &fix.editor_user), 204, "the owner removes anyone"); assert_eq!(server.json("GET", &format!("{base}/changes?since=0"), Some(&fix.editor), &[], None).0, 404, "removed at once"); assert_eq!(drop(&fix.owner, "u-unknown"), 404); assert_eq!(drop(&fix.owner, &owner_user), 409, "the last owner cannot leave"); assert_eq!(drop(&fix.viewer, &fix.viewer_user), 204, "a member leaves themselves"); assert_eq!(drop(&fix.owner, &fix.viewer_user), 404, "already left"); } #[test] fn invites_are_single_use_role_keeping_and_owner_managed() { let server = Server::start_temp("roles-invites"); let fix = fixture(&server, "single"); let base = format!("/v1/vaults/{}", fix.vault); // Minting and listing are owner-only; rows never contain a code or its hash. let (status, viewer_code) = server.invite(&fix.vault, &fix.owner, "viewer"); assert_eq!(status, 200); let rows = server.invites(&fix.vault, &fix.owner); assert_eq!(rows.len(), 1, "only the fresh viewer invite"); for row in &rows { assert!(row.get("code").is_none(), "no code in listings"); assert!(row.get("id").is_some() && row.get("role").is_some() && row.get("created_ms").is_some() && row.get("expires_ms").is_some()); } assert_eq!(server.invite(&fix.vault, &fix.editor, "editor").0, 403, "only the owner mints"); assert_eq!(server.json("GET", &format!("{base}/invites"), Some(&fix.viewer), &[], None).0, 403); // An invite keeps the higher role of an existing member and is consumed either way. let (status, editor_reply) = server.accept(&fix.viewer, &server.invite(&fix.vault, &fix.owner, "editor").1); assert_eq!(status, 200, "{editor_reply}"); assert_eq!(editor_reply["role"], "editor", "a viewer accepting an editor invite is upgraded"); let (status, kept) = server.accept(&fix.viewer, &viewer_code); assert_eq!(status, 200, "{kept}"); assert_eq!(kept["role"], "editor", "an editor accepting a viewer invite stays editor"); assert_eq!(server.invites(&fix.vault, &fix.owner).len(), 0, "every accept consumed its invite"); let (status, owner_reply) = server.accept(&fix.owner, &server.invite(&fix.vault, &fix.owner, "viewer").1); assert_eq!(status, 200, "{owner_reply}"); assert_eq!(owner_reply["role"], "owner", "an owner accepting stays owner, never demoted"); assert_eq!(server.accept(&fix.editor, &viewer_code).0, 401, "an invite is single use"); // Deleting an invite withdraws it before use. let (status, one_time) = server.invite(&fix.vault, &fix.owner, "editor"); assert_eq!(status, 200); let id = server.invites(&fix.vault, &fix.owner)[0]["id"].as_str().expect("id").to_owned(); assert_eq!(server.send("DELETE", &format!("{base}/invites/{id}"), Some(&fix.owner), &[], None).0, 204); assert_eq!(server.send("DELETE", &format!("{base}/invites/{id}"), Some(&fix.owner), &[], None).0, 404); assert_eq!(server.accept(&fix.stranger, &one_time).0, 401, "a withdrawn code is refused"); } #[test] fn invite_lockout_shares_the_pairing_counter_and_expires() { let server = Server::start_temp("roles-lockout"); let fix = fixture(&server, "lockout"); // Five bad accepts poison the shared counter; the next good code is refused at 429. for _ in 0..5 { let (status, _) = server.accept(&fix.stranger, "no-such-code"); assert_eq!(status, 401); } let (status, code) = server.invite(&fix.vault, &fix.owner, "editor"); assert_eq!(status, 200); let (status, reply) = server.accept(&fix.stranger, &code); assert_eq!(status, 429, "locked out: {reply}"); // The state-level window uses the injectable clock, so the lockout clears. let state = server.state.clone(); let granted = state.accept_invite(&code, "d-test", "u-test", notescraft_sync_server::now_ms() + notescraft_sync_server::PAIRING_WINDOW_MS + 1); assert!(granted.as_ref().is_ok_and(|reply| reply.role == "editor"), "the lockout cleared: {granted:?}"); assert_eq!(granted.expect("granted").role, "editor"); // An expired code is dead: the same code again, now beyond its 48 hours. let (status, fresh) = server.invite(&fix.vault, &fix.owner, "viewer"); assert_eq!(status, 200); let late = notescraft_sync_server::now_ms() + notescraft_sync_server::INVITE_TTL_MS + 1; assert!(state.accept_invite(&fresh, "d-test", "u-late", late).is_err(), "expired"); let _ = fresh; } #[test] fn a_removed_member_gets_404_at_once_and_the_last_owner_cannot_leave() { let server = Server::start_temp("roles-last-owner"); let fix = fixture(&server, "last-owner"); let base = format!("/v1/vaults/{}", fix.vault); // A second owner only arrives through an account-bound pairing code. let second_owner_user = server.create_user("co-owner"); let (status, second_owner) = server.pair_user(&second_owner_user, &fix.vault, "co-phone"); assert_eq!(status, 200); // An owner removes anyone; the removed member's very next request is 404. let (status, _) = server.send("DELETE", &format!("{base}/members/{second_owner_user}"), Some(&fix.owner), &[], None); assert_eq!(status, 204); assert_eq!(server.json("GET", &format!("{base}/changes?since=0"), Some(&second_owner), &[], None).0, 404); // With one owner left, neither leaving nor demotion works for the account owner. let (_, members) = server.json("GET", &format!("{base}/members"), Some(&fix.owner), &[], None); let owner_user = members.as_array().expect("members").iter().find(|row| row["role"] == "owner").expect("owner")["user_id"].as_str().expect("id").to_owned(); assert_eq!(server.send("DELETE", &format!("{base}/members/{owner_user}"), Some(&fix.owner), &[], None).0, 409, "the last owner cannot leave"); assert_eq!(server.json("PATCH", &format!("{base}/members/{owner_user}"), Some(&fix.owner), &[], Some(&json!({"role": "editor"}))).0, 409); }