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

164 lines
8.4 KiB
Rust

//! The share commands against the real sync server: two accounts, one shared
//! vault and the roles that guard it.
//!
//! The owner pairs, makes two more vaults and invites an editor; the editor
//! is a second account, accepts the code, binds a folder and syncs a file.
//! Then the roles are pulled: a viewer cannot write, a removed member sees
//! nothing, and a used code is refused.
mod common;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU32, Ordering};
use common::Server;
use notescraft_engine::{HttpRemote, Registry, Session, SyncState, bind_folder, register_core, sync_once};
use notescraft_vault::Vault;
use serde_json::{Value, json};
/// Counter making two folders of the same test different.
static COUNTER: AtomicU32 = AtomicU32::new(0);
/// The stamp the sync pass carries.
const STAMP: &str = "2026-10-20T00:00:00Z";
/// A fresh folder for a vault.
fn folder(name: &str) -> PathBuf {
let serial = COUNTER.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!("nc-share-{name}-{serial}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&path);
std::fs::create_dir_all(&path).expect("create the folder");
path
}
/// The ids of the vaults the answer lists.
fn ids(answer: &Value) -> Vec<String> {
let mut ids: Vec<String> =
answer["vaults"].as_array().expect("the vaults").iter().map(|vault| vault["id"].as_str().unwrap_or_default().to_owned()).collect();
ids.sort();
ids
}
/// Two accounts share a vault, and the roles guard it.
#[test]
fn two_accounts_share_a_vault() {
let server = Server::start_temp("share-e2e");
let (vault, owner_token, owner_user) = server.owned_vault("share-e2e");
// The owner opens a vault bound to the server.
let owner_folder = folder("owner");
bind_folder(&owner_folder, &server.base, &vault, &owner_token, "device-owner", Some(&owner_user)).expect("bind the owner");
let mut reg = Registry::new();
register_core(&mut reg).expect("register the core commands");
let mut owner = Session::open(&owner_folder).expect("open the owner vault");
// The account owns two more vaults.
let first = reg.run("server.create_vault", &mut owner, &json!({ "name": "Work" })).expect("server.create_vault");
let second = reg.run("server.create_vault", &mut owner, &json!({ "name": "Personal" })).expect("server.create_vault");
let first_id = first["vault"]["id"].as_str().expect("the id of the first vault").to_owned();
let second_id = second["vault"]["id"].as_str().expect("the id of the second vault").to_owned();
// The list holds exactly the three vaults of the account, and no other.
let listed = reg.run("server.vaults", &mut owner, &Value::Null).expect("server.vaults");
let mut expected = vec![vault.clone(), first_id.clone(), second_id.clone()];
expected.sort();
assert_eq!(ids(&listed), expected);
assert_eq!(listed["vaults"][0]["role"], json!("owner"), "the creator owns its vault");
// The owner invites an editor; the code is in the answer and nowhere else.
let invite = reg.run("share.invite", &mut owner, &json!({ "role": "editor" })).expect("share.invite");
let code = invite["code"].as_str().expect("the invite code").to_owned();
let open = reg.run("share.invites", &mut owner, &Value::Null).expect("share.invites");
assert!(!open.to_string().contains(&code), "the listing never carries the code");
// A second account: a new user, paired under its own vault.
let editor_vault = Server::fresh_vault_id("editor");
let editor_token = server.pair_to(&editor_vault, "Editor");
let editor_folder = folder("editor");
bind_folder(&editor_folder, &server.base, &editor_vault, &editor_token, "device-editor", None).expect("bind the editor");
let mut editor = Session::open(&editor_folder).expect("open the editor vault");
// The editor redeems the code and binds a folder to the shared vault.
let shared_folder = folder("shared");
let accepted = reg.run("share.accept", &mut editor, &json!({ "code": code, "folder": shared_folder.display().to_string() })).expect("share.accept");
assert_eq!(accepted["vault_id"], json!(vault));
assert_eq!(accepted["role"], json!("editor"));
let binding = std::fs::read_to_string(shared_folder.join(".notescraft/sync/binding.json")).expect("the binding");
assert!(binding.contains(&vault), "{binding}");
assert!(!binding.contains(&editor_token), "the token never reaches the binding");
// The owner sees the editor among the members.
let members = reg.run("share.members", &mut owner, &Value::Null).expect("share.members");
let rows = members["members"].as_array().expect("the members").clone();
assert_eq!(rows.len(), 2, "{rows:?}");
let editor_row = rows.iter().find(|row| row["user_id"] != json!(owner_user)).expect("the editor");
assert_eq!(editor_row["role"], json!("editor"));
let editor_user = editor_row["user_id"].as_str().expect("the user of the editor").to_owned();
// The editor syncs a file of the shared vault.
std::fs::write(shared_folder.join("note.md"), "# Shared\n").expect("write the note");
let shared = Vault::open(&shared_folder).expect("open the shared folder");
let remote = HttpRemote::new(&server.base, &vault, &editor_token);
let mut state = SyncState::default();
let report = sync_once(&shared, &remote, &mut state, "device-editor", STAMP).expect("sync");
assert!(report.uploaded.contains(&"note.md".to_owned()), "{report:?}");
let (status, body) = server.send("GET", &format!("/v1/vaults/{vault}/files/note.md"), Some(&editor_token), &[], None);
assert_eq!(status, 200);
assert_eq!(String::from_utf8_lossy(&body), "# Shared\n", "the note reached the server");
// A viewer cannot write any more.
reg.run("share.set_role", &mut owner, &json!({ "user": editor_user, "role": "viewer" })).expect("share.set_role");
let (status, _) = server.put(&vault, &editor_token, "note.md", "changed\n", 0);
assert_eq!(status, 403, "a viewer cannot write");
// A removed member sees nothing of the vault any more.
reg.run("share.remove", &mut owner, &json!({ "user": editor_user })).expect("share.remove");
let (status, _) = server.put(&vault, &editor_token, "note.md", "again\n", 0);
assert_eq!(status, 404, "a removed member is a stranger to the vault");
// The code is used: it fails the second time, with a message.
let again = reg.run("share.accept", &mut editor, &json!({ "code": code, "folder": shared_folder.display().to_string() }));
let Err(notescraft_engine::Error::Command { source, .. }) = again else {
panic!("a used code must be refused");
};
assert!(source.message.contains("401"), "{}", source.message);
}
/// A one-time code connects a folder to a server, from an unbound vault.
#[test]
fn a_code_connects_a_folder_to_a_server() {
let server = Server::start_temp("share-connect");
let vault = Server::fresh_vault_id("connect");
let (status, minted) = server.json("POST", &format!("/v1/vaults/{vault}/pairing"), Some(common::ADMIN), &[], None);
assert_eq!(status, 200);
let code = minted["code"].as_str().expect("the pairing code").to_owned();
let mut reg = Registry::new();
register_core(&mut reg).expect("register the core commands");
let unbound = folder("unbound");
let mut session = Session::open(&unbound).expect("open the vault");
let target = folder("connected");
let answer = reg
.run("server.connect", &mut session, &json!({ "server": server.base, "code": code, "folder": target.display().to_string() }))
.expect("server.connect");
assert_eq!(answer["vault_id"], json!(vault));
let binding = std::fs::read_to_string(target.join(".notescraft/sync/binding.json")).expect("the binding");
assert!(binding.contains(&vault), "{binding}");
assert!(binding.contains(&server.base), "{binding}");
let credentials = target.join(".notescraft/sync/credentials.json");
assert!(std::fs::read_to_string(&credentials).expect("the credentials").contains("token"));
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&credentials).expect("stat").permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "the token file is private");
}
// The bound folder sees the vaults of its account.
let mut connected = Session::open(&target).expect("open the connected folder");
let listed = reg.run("server.vaults", &mut connected, &Value::Null).expect("server.vaults");
assert_eq!(ids(&listed), vec![vault]);
}