181aad91e6
Sync and the server - crates/sync: revision ledger (stale writers conflict, edit beats delete), client planner, three-way merge, conflict names, path and hash gates - apps/notescraft-sync-server: pairing, device tokens, change log with long poll, blobs, accounts with many vaults, roles, invites, audit log, quota, GC, readiness, request ids - server history (revisions, restore, retention), chunked resumable uploads, live editing endpoints (SSE rooms, presence, flush through the ledger) - engine: HttpRemote, sync_once, SyncWorker, sync.* commands, share.* and server.* commands, live session client, note.capture, vault catalog and vaults.* commands Editing together - crates/collab: operational transformation core (TextOp, transform, client state machine, room, presence) - ui-egui: Settings > Sync, sync pill, conflicts, Share dialog, live pill and remote carets, launcher Connect and Join flows App shell - vault launcher (desktop and phone layouts) wired into the app host (--launcher, File > Vaults...) - compact (phone) layout with a bottom tab bar Verified with cargo xtask ci (fmt, clippy -D warnings, workspace tests, layers, tree, i18n). Android and iOS shells are not built: the build machine has no NDK or Xcode. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
238 lines
12 KiB
Rust
238 lines
12 KiB
Rust
//! 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);
|
|
}
|