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>
142 lines
7.4 KiB
Rust
142 lines
7.4 KiB
Rust
//! End-to-end over real HTTP: a server on an ephemeral port and the `ureq` client.
|
|
|
|
mod common;
|
|
|
|
use std::time::{Duration, Instant};
|
|
|
|
use common::{ADMIN, Server, temp_dir};
|
|
use serde_json::json;
|
|
|
|
#[test]
|
|
fn two_devices_converge_and_conflicts_are_reported() {
|
|
let server = Server::start_temp("flow");
|
|
let (vault, phone) = server.paired("phone");
|
|
let laptop = server.pair_to(&vault, "laptop");
|
|
|
|
let (status, record) = server.put(&vault, &phone, "notes/a.md", "# one\n", 0);
|
|
assert_eq!((status, record["seq"].as_u64()), (201, Some(1)));
|
|
|
|
let (_, page) = server.json("GET", &format!("/v1/vaults/{vault}/changes?since=0"), Some(&laptop), &[], None);
|
|
assert_eq!(page["head"], 1);
|
|
assert_eq!(page["records"][0]["path"], "notes/a.md");
|
|
let (status, bytes) = server.send("GET", &format!("/v1/vaults/{vault}/files/notes/a.md"), Some(&laptop), &[], None);
|
|
assert_eq!((status, bytes.as_slice()), (200, b"# one\n".as_slice()));
|
|
|
|
// The laptop edits on top of seq 1; the phone then writes from the same stale base.
|
|
assert_eq!(server.put(&vault, &laptop, "notes/a.md", "# laptop\n", 1).0, 201);
|
|
let (status, conflict) = server.put(&vault, &phone, "notes/a.md", "# phone\n", 1);
|
|
assert_eq!(status, 409);
|
|
assert_eq!(conflict["current"]["seq"], 2);
|
|
|
|
// An identical write is a no-op.
|
|
assert_eq!(server.put(&vault, &laptop, "notes/a.md", "# laptop\n", 2).0, 200);
|
|
|
|
// Delete, then a stale edit over the delete wins.
|
|
let (status, _) = server.json("DELETE", &format!("/v1/vaults/{vault}/files/notes/a.md"), Some(&laptop), &[("X-Base-Seq", "2")], None);
|
|
assert_eq!(status, 201);
|
|
assert_eq!(server.send("GET", &format!("/v1/vaults/{vault}/files/notes/a.md"), Some(&phone), &[], None).0, 404);
|
|
assert_eq!(server.put(&vault, &phone, "notes/a.md", "# phone again\n", 2).0, 201);
|
|
}
|
|
|
|
#[test]
|
|
fn etag_and_batch_check() {
|
|
let server = Server::start_temp("etag");
|
|
let (vault, token) = server.paired("pc");
|
|
let (_, record) = server.put(&vault, &token, "a.md", "x", 0);
|
|
let hash = record["hash"].as_str().expect("hash").to_owned();
|
|
let path = format!("/v1/vaults/{vault}/files/a.md");
|
|
assert_eq!(server.send("GET", &path, Some(&token), &[("If-None-Match", &hash)], None).0, 304);
|
|
let unknown = "0".repeat(64);
|
|
let (status, missing) = server.json("POST", &format!("/v1/vaults/{vault}/batch/check"), Some(&token), &[], Some(&json!({"hashes": [hash, unknown]})));
|
|
assert_eq!((status, missing["missing"].as_array().map(Vec::len)), (200, Some(1)));
|
|
let (status, _) = server.json("POST", &format!("/v1/vaults/{vault}/batch/check"), Some(&token), &[], Some(&json!({"hashes": ["nope"]})));
|
|
assert_eq!(status, 400);
|
|
}
|
|
|
|
#[test]
|
|
fn auth_and_input_are_enforced() {
|
|
let server = Server::start_temp("auth");
|
|
let (vault, token) = server.paired("pc");
|
|
let (_, other_token) = server.paired("intruder");
|
|
let changes = format!("/v1/vaults/{vault}/changes");
|
|
|
|
assert_eq!(server.send("GET", &changes, None, &[], None).0, 401);
|
|
assert_eq!(server.send("GET", &changes, Some("wrong"), &[], None).0, 401);
|
|
// A token of another account is a non-member: 404, so the vault stays invisible.
|
|
assert_eq!(server.send("GET", &changes, Some(&other_token), &[], None).0, 404);
|
|
// Vault creation is a device call now: the admin token is no special guest, a
|
|
// missing body is a malformed request, a real token with a name creates.
|
|
assert_eq!(server.json("POST", "/v1/vaults", Some("wrong-admin"), &[], Some(&json!({"name": "mine"}))).0, 401);
|
|
assert_eq!(server.json("POST", "/v1/vaults", Some(ADMIN), &[], Some(&json!({"name": "mine"}))).0, 401);
|
|
assert_eq!(server.send("POST", "/v1/vaults", Some(&token), &[], None).0, 415);
|
|
|
|
for bad in ["../x.md", ".obsidian/app.json", ".notescraft/x", "a/../b.md"] {
|
|
let (status, _) = server.put(&vault, &token, bad, "x", 0);
|
|
assert!(status == 400 || status == 404, "{bad} -> {status}");
|
|
}
|
|
assert_eq!(server.put(&vault, &token, ".obsidian/app.json", "x", 0).0, 400);
|
|
let (status, _) = server.send("PUT", &format!("/v1/vaults/{vault}/files/a.md"), Some(&token), &[("X-Base-Seq", "-1")], Some(b"x"));
|
|
assert_eq!(status, 400);
|
|
assert_eq!(server.send("DELETE", &format!("/v1/vaults/{vault}/files/missing.md"), Some(&token), &[], None).0, 404);
|
|
}
|
|
|
|
#[test]
|
|
fn pairing_codes_work_once_and_names_are_required() {
|
|
let server = Server::start_temp("pair");
|
|
let (vault, _) = server.paired("first");
|
|
let (_, code) = server.json("POST", &format!("/v1/vaults/{vault}/pairing"), Some(common::ADMIN), &[], None);
|
|
let body = json!({"code": code["code"], "name": "second"});
|
|
assert_eq!(server.json("POST", "/v1/devices", None, &[], Some(&body)).0, 200);
|
|
assert_eq!(server.json("POST", "/v1/devices", None, &[], Some(&body)).0, 401);
|
|
let (_, code) = server.json("POST", &format!("/v1/vaults/{vault}/pairing"), Some(common::ADMIN), &[], None);
|
|
assert_eq!(server.json("POST", "/v1/devices", None, &[], Some(&json!({"code": code["code"], "name": " "}))).0, 400);
|
|
// Admin bootstrap: a well-formed new vault id is created by its first pairing code; a malformed id is 404.
|
|
assert_eq!(server.json("POST", "/v1/vaults/bad%20id/pairing", Some(common::ADMIN), &[], None).0, 404);
|
|
}
|
|
|
|
#[test]
|
|
fn long_poll_wakes_on_a_write() {
|
|
let server = Server::start_temp("poll");
|
|
let (vault, token) = server.paired("pc");
|
|
let writer = {
|
|
let (base, token, vault) = (server.base.clone(), token.clone(), vault.clone());
|
|
std::thread::spawn(move || {
|
|
std::thread::sleep(Duration::from_millis(400));
|
|
let agent: ureq::Agent = ureq::Agent::config_builder().http_status_as_error(false).build().into();
|
|
agent
|
|
.put(format!("{base}/v1/vaults/{vault}/files/late.md"))
|
|
.header("Authorization", format!("Bearer {token}"))
|
|
.send(b"late".as_slice())
|
|
.expect("put");
|
|
})
|
|
};
|
|
let started = Instant::now();
|
|
let (_, page) = server.json("GET", &format!("/v1/vaults/{vault}/changes?since=0&wait=10"), Some(&token), &[], None);
|
|
assert_eq!(page["records"][0]["path"], "late.md");
|
|
assert!(started.elapsed() < Duration::from_secs(5), "returned after {:?}", started.elapsed());
|
|
writer.join().expect("writer");
|
|
// Nothing new: the wait ends empty at the deadline.
|
|
let started = Instant::now();
|
|
let (_, page) = server.json("GET", &format!("/v1/vaults/{vault}/changes?since=1&wait=1"), Some(&token), &[], None);
|
|
assert_eq!(page["records"].as_array().map(Vec::len), Some(0));
|
|
assert!(started.elapsed() >= Duration::from_millis(900));
|
|
}
|
|
|
|
#[test]
|
|
fn state_survives_a_restart() {
|
|
let data = temp_dir("restart");
|
|
let (vault, token, base_seq) = {
|
|
let server = common::start(&data);
|
|
let (vault, token) = server.paired("pc");
|
|
let (_, record) = server.put(&vault, &token, "keep.md", "kept", 0);
|
|
(vault, token, record["seq"].as_u64().expect("seq"))
|
|
};
|
|
// A second server over the same folder (the first keeps running; SQLite WAL allows it).
|
|
let server = common::start(&data);
|
|
let (_, page) = server.json("GET", &format!("/v1/vaults/{vault}/changes?since=0"), Some(&token), &[], None);
|
|
assert_eq!(page["records"][0]["seq"], base_seq);
|
|
let (status, bytes) = server.send("GET", &format!("/v1/vaults/{vault}/files/keep.md"), Some(&token), &[], None);
|
|
assert_eq!((status, bytes.as_slice()), (200, b"kept".as_slice()));
|
|
assert_eq!(server.put(&vault, &token, "keep.md", "kept 2", base_seq).1["seq"], base_seq + 1);
|
|
}
|