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