//! 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 { let mut ids: Vec = 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]); }