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>
413 lines
18 KiB
Rust
413 lines
18 KiB
Rust
//! The `server.*` and `share.*` commands against a stub server: the requests
|
|
//! they make, the answers they give and the inputs they refuse.
|
|
//!
|
|
//! The stub is a plain TCP listener that answers canned JSON and remembers
|
|
//! every request line, so the test can assert on the path, the method, the
|
|
//! bearer header and the body the server would receive.
|
|
|
|
mod common;
|
|
|
|
use std::io::{BufRead, BufReader, Read, Write};
|
|
use std::net::{TcpListener, TcpStream};
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
use std::sync::{Arc, Mutex};
|
|
use std::time::Duration;
|
|
|
|
use common::temp_vault;
|
|
use notescraft_engine::{Error, Registry, ServerClient, Session, adversarial_params, bind_folder, register_core};
|
|
use serde_json::{Value, json};
|
|
|
|
/// The token of the bound vault; it must never reach an answer or an error.
|
|
const TOKEN: &str = "secret-token";
|
|
|
|
/// One request the stub received.
|
|
#[derive(Debug, Clone)]
|
|
struct Request {
|
|
/// The HTTP method.
|
|
method: String,
|
|
/// The path of the request line.
|
|
path: String,
|
|
/// The `Authorization` header, when there was one.
|
|
auth: Option<String>,
|
|
/// The body of the request.
|
|
body: String,
|
|
}
|
|
|
|
/// A canned answer of the stub.
|
|
struct Answer {
|
|
/// The status of the answer.
|
|
status: u16,
|
|
/// The body of the answer.
|
|
body: String,
|
|
}
|
|
|
|
/// A stub server: it answers canned JSON and remembers what it was asked.
|
|
struct Stub {
|
|
/// The base URL of the server.
|
|
base: String,
|
|
/// Every request the stub received.
|
|
seen: Arc<Mutex<Vec<Request>>>,
|
|
/// The canned answers, by method and path.
|
|
routes: Arc<Mutex<Vec<(String, String, Answer)>>>,
|
|
/// Whether the listener thread should stop.
|
|
stop: Arc<AtomicBool>,
|
|
}
|
|
|
|
impl Stub {
|
|
/// Starts a stub on an ephemeral port.
|
|
fn spawn() -> Self {
|
|
let listener = TcpListener::bind("127.0.0.1:0").expect("bind the stub");
|
|
listener.set_nonblocking(true).expect("nonblocking");
|
|
let port = listener.local_addr().expect("addr").port();
|
|
let seen: Arc<Mutex<Vec<Request>>> = Arc::new(Mutex::new(Vec::new()));
|
|
let routes: Arc<Mutex<Vec<(String, String, Answer)>>> = Arc::new(Mutex::new(Vec::new()));
|
|
let stop = Arc::new(AtomicBool::new(false));
|
|
let thread_seen = Arc::clone(&seen);
|
|
let thread_routes = Arc::clone(&routes);
|
|
let thread_stop = Arc::clone(&stop);
|
|
std::thread::spawn(move || {
|
|
while !thread_stop.load(Ordering::Relaxed) {
|
|
match listener.accept() {
|
|
Ok((stream, _)) => {
|
|
// An accepted socket inherits the listener's non-blocking mode on some platforms:
|
|
// a read that beats the request bytes would then end the connection.
|
|
let _ = stream.set_nonblocking(false);
|
|
// One thread per connection: the connection stays open, so the
|
|
// client may reuse it and no request is ever lost in a close.
|
|
let Ok(mut writer) = stream.try_clone() else { continue };
|
|
let mut reader = BufReader::new(stream);
|
|
let _ = reader.get_ref().set_read_timeout(Some(Duration::from_secs(30)));
|
|
let seen = Arc::clone(&thread_seen);
|
|
let routes = Arc::clone(&thread_routes);
|
|
std::thread::spawn(move || {
|
|
while let Some(request) = read_request(&mut reader) {
|
|
let answer = {
|
|
let routes = routes.lock().expect("routes");
|
|
routes
|
|
.iter()
|
|
.find(|(method, path, _)| *method == request.method && *path == request.path)
|
|
.map(|(_, _, answer)| Answer { status: answer.status, body: answer.body.clone() })
|
|
};
|
|
seen.lock().expect("seen").push(request);
|
|
let answer = answer.unwrap_or(Answer { status: 404, body: "{\"error\":\"no such route\"}".to_owned() });
|
|
write_answer(&mut writer, &answer);
|
|
}
|
|
});
|
|
}
|
|
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
|
|
std::thread::sleep(Duration::from_millis(2));
|
|
}
|
|
Err(_) => break,
|
|
}
|
|
}
|
|
});
|
|
Self { base: format!("http://127.0.0.1:{port}"), seen, routes, stop }
|
|
}
|
|
|
|
/// Adds a canned answer.
|
|
fn route(&self, method: &str, path: &str, status: u16, body: &str) {
|
|
self.routes.lock().expect("routes").push((method.to_owned(), path.to_owned(), Answer { status, body: body.to_owned() }));
|
|
}
|
|
|
|
/// Every request the stub received.
|
|
fn requests(&self) -> Vec<Request> {
|
|
self.seen.lock().expect("seen").clone()
|
|
}
|
|
}
|
|
|
|
impl Drop for Stub {
|
|
fn drop(&mut self) {
|
|
self.stop.store(true, Ordering::Relaxed);
|
|
}
|
|
}
|
|
|
|
/// Reads one request of a connection.
|
|
fn read_request(reader: &mut BufReader<TcpStream>) -> Option<Request> {
|
|
let mut line = String::new();
|
|
reader.read_line(&mut line).ok()?;
|
|
let mut parts = line.split_whitespace();
|
|
let method = parts.next()?.to_owned();
|
|
let path = parts.next()?.to_owned();
|
|
let mut auth = None;
|
|
let mut length = 0_usize;
|
|
loop {
|
|
let mut header = String::new();
|
|
if reader.read_line(&mut header).ok()? == 0 {
|
|
break;
|
|
}
|
|
let trimmed = header.trim_end();
|
|
if trimmed.is_empty() {
|
|
break;
|
|
}
|
|
if let Some((name, value)) = trimmed.split_once(':') {
|
|
let name = name.trim().to_ascii_lowercase();
|
|
let value = value.trim().to_owned();
|
|
if name == "content-length" {
|
|
length = value.parse().unwrap_or(0);
|
|
}
|
|
if name == "authorization" {
|
|
auth = Some(value);
|
|
}
|
|
}
|
|
}
|
|
let mut body = vec![0_u8; length];
|
|
if length > 0 {
|
|
reader.read_exact(&mut body).ok()?;
|
|
}
|
|
Some(Request { method, path, auth, body: String::from_utf8_lossy(&body).into_owned() })
|
|
}
|
|
|
|
/// Writes one answer of the stub.
|
|
fn write_answer(stream: &mut TcpStream, answer: &Answer) {
|
|
let reason = match answer.status {
|
|
200 => "OK",
|
|
204 => "No Content",
|
|
400 => "Bad Request",
|
|
401 => "Unauthorized",
|
|
403 => "Forbidden",
|
|
404 => "Not Found",
|
|
_ => "Status",
|
|
};
|
|
let head = format!("HTTP/1.1 {} {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n", answer.status, reason, answer.body.len());
|
|
let _ = stream.write_all(head.as_bytes());
|
|
let _ = stream.write_all(answer.body.as_bytes());
|
|
let _ = stream.flush();
|
|
}
|
|
|
|
/// A vault bound to the stub, with the registry and the session.
|
|
fn bound(name: &str, stub: &Stub) -> (Registry<Session>, Session, common::TempVault) {
|
|
let vault = temp_vault(name);
|
|
bind_folder(vault.path(), &stub.base, "v-1", TOKEN, "d-1", Some("u-1")).expect("bind the vault");
|
|
let mut reg = Registry::new();
|
|
register_core(&mut reg).expect("register the core commands");
|
|
let session = Session::open(vault.path()).expect("open the vault");
|
|
(reg, session, vault)
|
|
}
|
|
|
|
/// The code of a command error.
|
|
fn code(result: &Result<Value, Error>) -> String {
|
|
match result {
|
|
Err(Error::Command { source, .. }) => source.code.clone(),
|
|
other => panic!("expected a command error, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// The message of a command error.
|
|
fn message(result: &Result<Value, Error>) -> String {
|
|
match result {
|
|
Err(Error::Command { source, .. }) => source.message.clone(),
|
|
other => panic!("expected a command error, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// The vault list goes to the server with the token of the binding.
|
|
#[test]
|
|
fn the_vault_list_goes_to_the_server_with_the_token() {
|
|
let stub = Stub::spawn();
|
|
stub.route(
|
|
"GET",
|
|
"/v1/vaults",
|
|
200,
|
|
r#"[{"id":"v-1","name":"Notes","role":"owner","members":2,"bytes_used":10,"quota_bytes":null,"head_seq":7,"last_change_ms":1000}]"#,
|
|
);
|
|
let (reg, mut session, _vault) = bound("vaults", &stub);
|
|
|
|
let answer = reg.run("server.vaults", &mut session, &Value::Null).expect("server.vaults");
|
|
let vaults = answer.get("vaults").and_then(Value::as_array).expect("the vaults");
|
|
assert_eq!(vaults.len(), 1);
|
|
assert_eq!(vaults.first().and_then(|vault| vault.get("id")), Some(&json!("v-1")));
|
|
assert_eq!(vaults.first().and_then(|vault| vault.get("role")), Some(&json!("owner")));
|
|
assert_eq!(vaults.first().and_then(|vault| vault.get("members")), Some(&json!(2)));
|
|
|
|
let requests = stub.requests();
|
|
assert_eq!(requests.len(), 1);
|
|
assert_eq!(requests.first().map(|request| request.method.as_str()), Some("GET"));
|
|
assert_eq!(requests.first().map(|request| request.path.as_str()), Some("/v1/vaults"));
|
|
assert_eq!(requests.first().and_then(|request| request.auth.clone()), Some(format!("Bearer {TOKEN}")));
|
|
assert!(!answer.to_string().contains(TOKEN), "the token never reaches the answer");
|
|
}
|
|
|
|
/// Every request the commands make is the one the server expects.
|
|
#[test]
|
|
fn the_requests_are_the_ones_the_server_expects() {
|
|
let stub = Stub::spawn();
|
|
stub.route("POST", "/v1/vaults", 200, r#"{"vault_id":"v-2","name":"Second"}"#);
|
|
stub.route("PATCH", "/v1/vaults/v-2", 204, "");
|
|
stub.route("DELETE", "/v1/vaults/v-2", 204, "");
|
|
stub.route("GET", "/v1/vaults/v-1/members", 200, r#"[{"user_id":"u-2","name":"Ana","color":"red","role":"editor","last_seen_ms":5}]"#);
|
|
stub.route("PATCH", "/v1/vaults/v-1/members/u-2", 204, "");
|
|
stub.route("DELETE", "/v1/vaults/v-1/members/u-2", 204, "");
|
|
stub.route("POST", "/v1/vaults/v-1/invites", 200, r#"{"code":"abcd-efgh-ijkl","expires_in_s":172800}"#);
|
|
stub.route("GET", "/v1/vaults/v-1/invites", 200, r#"[{"id":"i-1","role":"editor","created_ms":1,"expires_ms":2}]"#);
|
|
stub.route("DELETE", "/v1/vaults/v-1/invites/i-1", 204, "");
|
|
stub.route("POST", "/v1/invites/accept", 200, r#"{"vault_id":"v-9","name":"Shared","role":"editor"}"#);
|
|
let (reg, mut session, _vault) = bound("requests", &stub);
|
|
|
|
let created = reg.run("server.create_vault", &mut session, &json!({ "name": "Second" })).expect("server.create_vault");
|
|
assert_eq!(created.get("vault").and_then(|vault| vault.get("id")), Some(&json!("v-2")));
|
|
reg.run("server.rename_vault", &mut session, &json!({ "id": "v-2", "name": "Renamed" })).expect("server.rename_vault");
|
|
reg.run("server.delete_vault", &mut session, &json!({ "id": "v-2" })).expect("server.delete_vault");
|
|
let members = reg.run("share.members", &mut session, &Value::Null).expect("share.members");
|
|
assert_eq!(members.get("members").and_then(Value::as_array).map(Vec::len), Some(1));
|
|
reg.run("share.set_role", &mut session, &json!({ "user": "u-2", "role": "viewer" })).expect("share.set_role");
|
|
reg.run("share.remove", &mut session, &json!({ "user": "u-2" })).expect("share.remove");
|
|
let invite = reg.run("share.invite", &mut session, &json!({ "role": "editor" })).expect("share.invite");
|
|
assert_eq!(invite.get("code"), Some(&json!("abcd-efgh-ijkl")));
|
|
let listed = reg.run("share.invites", &mut session, &Value::Null).expect("share.invites");
|
|
assert!(!listed.to_string().contains("abcd"), "the listing never carries a code");
|
|
reg.run("share.revoke_invite", &mut session, &json!({ "id": "i-1" })).expect("share.revoke_invite");
|
|
|
|
let requests = stub.requests();
|
|
let lines: Vec<(String, String)> = requests.iter().map(|request| (request.method.clone(), request.path.clone())).collect();
|
|
assert_eq!(
|
|
lines,
|
|
vec![
|
|
("POST".to_owned(), "/v1/vaults".to_owned()),
|
|
("PATCH".to_owned(), "/v1/vaults/v-2".to_owned()),
|
|
("DELETE".to_owned(), "/v1/vaults/v-2".to_owned()),
|
|
("GET".to_owned(), "/v1/vaults/v-1/members".to_owned()),
|
|
("PATCH".to_owned(), "/v1/vaults/v-1/members/u-2".to_owned()),
|
|
("DELETE".to_owned(), "/v1/vaults/v-1/members/u-2".to_owned()),
|
|
("POST".to_owned(), "/v1/vaults/v-1/invites".to_owned()),
|
|
("GET".to_owned(), "/v1/vaults/v-1/invites".to_owned()),
|
|
("DELETE".to_owned(), "/v1/vaults/v-1/invites/i-1".to_owned()),
|
|
]
|
|
);
|
|
let bodies: Vec<Value> =
|
|
requests.iter().map(|request| if request.body.is_empty() { Value::Null } else { serde_json::from_str(&request.body).expect("a JSON body") }).collect();
|
|
assert_eq!(bodies.first(), Some(&json!({ "name": "Second" })));
|
|
assert_eq!(bodies.get(1), Some(&json!({ "name": "Renamed" })));
|
|
assert_eq!(bodies.get(4), Some(&json!({ "role": "viewer" })));
|
|
assert_eq!(bodies.get(6), Some(&json!({ "role": "editor" })));
|
|
for request in &requests {
|
|
assert_eq!(request.auth.as_deref(), Some(format!("Bearer {TOKEN}").as_str()), "every request carries the token");
|
|
}
|
|
}
|
|
|
|
/// A role that is not offered never reaches the server.
|
|
#[test]
|
|
fn a_role_that_is_not_offered_never_reaches_the_server() {
|
|
let stub = Stub::spawn();
|
|
let (reg, mut session, _vault) = bound("roles", &stub);
|
|
|
|
for role in ["owner", "admin", "Owner", "", "editor "] {
|
|
let result = reg.run("share.set_role", &mut session, &json!({ "user": "u-2", "role": role }));
|
|
assert_eq!(code(&result), "invalid_value", "the role `{role}` must be refused");
|
|
let result = reg.run("share.invite", &mut session, &json!({ "role": role }));
|
|
assert_eq!(code(&result), "invalid_value", "the role `{role}` must be refused");
|
|
}
|
|
assert!(stub.requests().is_empty(), "no request was made");
|
|
}
|
|
|
|
/// The message of the server reaches the user, the token never does.
|
|
#[test]
|
|
fn the_message_of_the_server_reaches_the_user() {
|
|
let stub = Stub::spawn();
|
|
stub.route("GET", "/v1/vaults/v-1/members", 403, r#"{"error":"token is not valid for this vault"}"#);
|
|
let (reg, mut session, _vault) = bound("message", &stub);
|
|
|
|
let result = reg.run("share.members", &mut session, &Value::Null);
|
|
assert_eq!(code(&result), "share");
|
|
let text = message(&result);
|
|
assert!(text.contains("token is not valid for this vault"), "{text}");
|
|
assert!(text.contains("403"), "{text}");
|
|
assert!(!text.contains(TOKEN), "{text}");
|
|
}
|
|
|
|
/// An unbound vault says so instead of asking the server.
|
|
#[test]
|
|
fn an_unbound_vault_says_so() {
|
|
let stub = Stub::spawn();
|
|
let mut reg = Registry::new();
|
|
register_core(&mut reg).expect("register the core commands");
|
|
let vault = temp_vault("unbound");
|
|
let mut session = Session::open(vault.path()).expect("open the vault");
|
|
|
|
let result = reg.run("server.vaults", &mut session, &Value::Null);
|
|
assert_eq!(code(&result), "share");
|
|
assert!(message(&result).contains("not bound"), "{}", message(&result));
|
|
assert!(stub.requests().is_empty(), "no request was made");
|
|
}
|
|
|
|
/// The binding of a folder holds the vault and keeps the token away.
|
|
#[test]
|
|
fn the_binding_of_a_folder_holds_the_vault_and_keeps_the_token_away() {
|
|
let vault = temp_vault("binding");
|
|
bind_folder(vault.path(), "https://sync.example", "v-1", TOKEN, "d-1", Some("u-1")).expect("bind the folder");
|
|
|
|
let binding = std::fs::read_to_string(vault.path().join(".notescraft/sync/binding.json")).expect("the binding");
|
|
assert!(binding.contains("v-1"), "{binding}");
|
|
assert!(binding.contains("https://sync.example"), "{binding}");
|
|
assert!(!binding.contains(TOKEN), "the token never reaches the binding");
|
|
|
|
let credentials = vault.path().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");
|
|
}
|
|
}
|
|
|
|
/// A folder that is missing or is not a directory is refused.
|
|
#[test]
|
|
fn a_folder_that_is_not_a_folder_is_refused() {
|
|
let vault = temp_vault("folders");
|
|
assert!(bind_folder(&vault.path().join("nope"), "https://sync.example", "v-1", TOKEN, "d-1", None).is_err());
|
|
let file = vault.path().join("a-file");
|
|
std::fs::write(&file, b"x").expect("write the file");
|
|
assert!(bind_folder(&file, "https://sync.example", "v-1", TOKEN, "d-1", None).is_err());
|
|
assert!(!vault.path().join(".notescraft/sync/binding.json").exists(), "nothing was written");
|
|
}
|
|
|
|
/// Ids with a slash or a space are percent-encoded, never walked out of.
|
|
#[test]
|
|
fn ids_are_percent_encoded() {
|
|
let stub = Stub::spawn();
|
|
stub.route("PATCH", "/v1/vaults/v-1/members/u%2F1%202", 204, "");
|
|
let client = ServerClient::new(&stub.base, TOKEN);
|
|
|
|
client.set_role("v-1", "u/1 2", "editor").expect("set the role");
|
|
let requests = stub.requests();
|
|
assert_eq!(requests.first().map(|request| request.path.as_str()), Some("/v1/vaults/v-1/members/u%2F1%202"));
|
|
let body: Value = serde_json::from_str(&requests.first().expect("a request").body).expect("a JSON body");
|
|
assert_eq!(body, json!({ "role": "editor" }));
|
|
}
|
|
|
|
/// Hostile params are refused, never a panic.
|
|
#[test]
|
|
fn hostile_params_never_panic_a_share_command() {
|
|
let stub = Stub::spawn();
|
|
let mut reg = Registry::new();
|
|
register_core(&mut reg).expect("register the core commands");
|
|
let vault = temp_vault("hostile");
|
|
let mut session = Session::open(vault.path()).expect("open the vault");
|
|
let previous = std::panic::take_hook();
|
|
std::panic::set_hook(Box::new(|_| {}));
|
|
let mut panicked = Vec::new();
|
|
for id in [
|
|
"server.vaults",
|
|
"server.create_vault",
|
|
"server.rename_vault",
|
|
"server.delete_vault",
|
|
"server.connect",
|
|
"share.members",
|
|
"share.set_role",
|
|
"share.remove",
|
|
"share.invite",
|
|
"share.invites",
|
|
"share.revoke_invite",
|
|
"share.accept",
|
|
] {
|
|
for params in adversarial_params() {
|
|
if let Err(Error::Panicked(other)) = reg.run(id, &mut session, ¶ms) {
|
|
panicked.push(other);
|
|
}
|
|
}
|
|
}
|
|
std::panic::set_hook(previous);
|
|
assert!(panicked.is_empty(), "commands panicked: {panicked:?}");
|
|
assert!(stub.requests().is_empty(), "a hostile param never reached the server");
|
|
}
|