Files
Marcos Paulo 181aad91e6 feat: sync, accounts, live editing, vault launcher, compact layout
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>
2026-10-20 07:46:24 -03:00

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);
}