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>
225 lines
11 KiB
Rust
225 lines
11 KiB
Rust
//! File history over real HTTP: every applied write is a revision, listing and reading
|
|
//! old revisions, restore, and the retention rules the GC applies. Non-members are 404,
|
|
//! viewers read but never restore.
|
|
|
|
mod common;
|
|
|
|
use std::path::PathBuf;
|
|
|
|
use common::{ADMIN, Server, temp_dir};
|
|
use notescraft_sync_server::{AppState, GcReport, Store, TOMBSTONE_GRACE_MS, now_ms};
|
|
use serde_json::{Value, json};
|
|
|
|
/// A server over a fresh temp folder; the folder comes back for blob assertions.
|
|
fn server(name: &str) -> (Server, PathBuf) {
|
|
let data = temp_dir(name);
|
|
let state = AppState::new(Store::open(&data.join("sync.db")).expect("db"), data.join("blobs"), ADMIN);
|
|
(common::start_with_state(state), data)
|
|
}
|
|
|
|
/// A fresh vault with an owner token.
|
|
fn vault(server: &Server, name: &str) -> (String, String) {
|
|
server.paired(name)
|
|
}
|
|
|
|
/// Writes `count` successive versions of one file, the old sequence as the base each
|
|
/// time; `(seq, body, hash)` in write order.
|
|
fn edits(server: &Server, vault: &str, token: &str, count: u64) -> Vec<(u64, String, String)> {
|
|
let mut base_seq = 0;
|
|
(1..=count)
|
|
.map(|index| {
|
|
let body = format!("body-{index}");
|
|
let (status, record) = server.put(vault, token, "a.md", &body, base_seq);
|
|
assert_eq!(status, 201, "write {index}");
|
|
let seq = record["seq"].as_u64().expect("seq");
|
|
let hash = record["hash"].as_str().expect("hash").to_owned();
|
|
base_seq = seq;
|
|
(seq, body, hash)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// The vault's own row of `GET /v1/vaults`.
|
|
fn listing(server: &Server, token: &str, vault: &str) -> Value {
|
|
let (status, rows) = server.json("GET", "/v1/vaults", Some(token), &[], None);
|
|
assert_eq!(status, 200);
|
|
rows.as_array().expect("vaults").iter().find(|row| row["id"] == *vault).expect("vault row").clone()
|
|
}
|
|
|
|
/// The bytes and headers of a `GET` of one file with optional `?seq=`.
|
|
fn read(server: &Server, vault: &str, token: &str, query: &str) -> (u16, String, Vec<u8>) {
|
|
let (status, headers, body) = server.send_full("GET", &format!("/v1/vaults/{vault}/files/a.md{query}"), Some(token), &[], None);
|
|
let etag = headers.iter().find(|(name, _)| name == "etag").map(|(_, value)| value.clone()).unwrap_or_default();
|
|
(status, etag, body)
|
|
}
|
|
|
|
fn history(server: &Server, vault: &str, token: &str, query: &str) -> Vec<Value> {
|
|
let (status, rows) = server.json("GET", &format!("/v1/vaults/{vault}/files-history/a.md{query}"), Some(token), &[], None);
|
|
assert_eq!(status, 200);
|
|
rows.as_array().expect("history rows").clone()
|
|
}
|
|
|
|
fn restore(server: &Server, vault: &str, token: &str, seq: u64) -> (u16, Value) {
|
|
server.json("POST", &format!("/v1/vaults/{vault}/files-restore/a.md"), Some(token), &[], Some(&json!({"seq": seq})))
|
|
}
|
|
|
|
#[test]
|
|
fn forty_edits_list_forty_revisions_newest_first_with_a_limit() {
|
|
let (server, _) = server("history-list");
|
|
let (vault, token) = vault(&server, "hist");
|
|
let writes = edits(&server, &vault, &token, 40);
|
|
assert_eq!(writes.iter().map(|(seq, _, _)| *seq).max(), Some(40));
|
|
|
|
let rows = history(&server, &vault, &token, "");
|
|
assert_eq!(rows.len(), 40);
|
|
assert_eq!(rows[0]["seq"], 40, "newest first");
|
|
assert_eq!(rows[39]["seq"], 1);
|
|
assert_eq!(rows[0]["deleted"], false);
|
|
assert!(rows[0]["size"].as_u64().is_some_and(|n| n > 0), "sizes come along");
|
|
assert!(rows[0]["mtime_ms"].as_u64().is_some_and(|n| n > 0));
|
|
assert!(rows[0]["recorded_ms"].as_u64().is_some_and(|n| n > 0));
|
|
assert!(rows[0]["editor_user"].as_str().is_some(), "the editor is stamped");
|
|
assert!(rows[0]["editor_name"].as_str().is_some(), "the editor's name resolves");
|
|
|
|
let limited = history(&server, &vault, &token, "?limit=10");
|
|
assert_eq!(limited.len(), 10);
|
|
assert_eq!(limited.iter().map(|row| row["seq"].as_u64().expect("seq")).collect::<Vec<_>>(), (31..=40).rev().collect::<Vec<_>>());
|
|
assert_eq!(history(&server, &vault, &token, "?limit=0").len(), 1, "0 clamps up to one entry");
|
|
}
|
|
|
|
#[test]
|
|
fn get_by_seq_returns_that_revisions_bytes() {
|
|
let (server, _) = server("history-read");
|
|
let (vault, token) = vault(&server, "hist");
|
|
let writes = edits(&server, &vault, &token, 12);
|
|
let (_, body12, hash12) = writes.iter().find(|(seq, _, _)| *seq == 12).expect("revision 12").clone();
|
|
|
|
let (status, etag, bytes) = read(&server, &vault, &token, "?seq=12");
|
|
assert_eq!(status, 200);
|
|
assert_eq!(etag, hash12);
|
|
assert_eq!(bytes, body12.as_bytes());
|
|
assert_eq!(read(&server, &vault, &token, "").1, hash12, "without a seq it reads the live record");
|
|
assert_eq!(read(&server, &vault, &token, "?seq=999").0, 404, "unknown revision");
|
|
}
|
|
|
|
#[test]
|
|
fn restore_of_revision_12_creates_revision_41_with_those_bytes() {
|
|
let (server, _) = server("history-restore");
|
|
let (vault, token) = vault(&server, "hist");
|
|
let writes = edits(&server, &vault, &token, 40);
|
|
let (_, body12, hash12) = writes.iter().find(|(seq, _, _)| *seq == 12).expect("revision 12").clone();
|
|
|
|
let (status, record) = restore(&server, &vault, &token, 12);
|
|
assert_eq!(status, 201);
|
|
assert_eq!(record["seq"].as_u64(), Some(41));
|
|
assert_eq!(record["hash"].as_str(), Some(hash12.as_str()));
|
|
assert_eq!(record["size"].as_u64(), Some(u64::try_from(body12.len()).expect("small")));
|
|
|
|
let rows = history(&server, &vault, &token, "");
|
|
assert_eq!(rows.len(), 41, "history is never rewritten: 41 entries");
|
|
assert_eq!(rows[0]["seq"], 41);
|
|
|
|
let (status, etag, bytes) = read(&server, &vault, &token, "");
|
|
assert_eq!((status, etag, bytes), (200, hash12.clone(), body12.into_bytes()), "the file reads back as revision 12");
|
|
|
|
let audit = server.audit();
|
|
assert!(audit.iter().any(|row| row["kind"] == "file_restored"), "the restore is audited");
|
|
}
|
|
|
|
#[test]
|
|
fn restore_of_a_deleted_file_recreates_it() {
|
|
let (server, _) = server("history-recreate");
|
|
let (vault, token) = vault(&server, "hist");
|
|
assert_eq!(server.put(&vault, &token, "a.md", "body-1", 0).0, 201);
|
|
let (status, _) = server.json("DELETE", &format!("/v1/vaults/{vault}/files/a.md"), Some(&token), &[("X-Base-Seq", "1")], None);
|
|
assert_eq!(status, 201);
|
|
assert_eq!(read(&server, &vault, &token, "").0, 404, "the file is gone");
|
|
|
|
let (status, record) = restore(&server, &vault, &token, 1);
|
|
assert_eq!(status, 201, "the tombstone yields to an edit");
|
|
assert_eq!(record["seq"], 3, "one for the write, one for the delete");
|
|
assert_eq!(record["deleted"], false);
|
|
assert_eq!((read(&server, &vault, &token, "").0, read(&server, &vault, &token, "").2), (200, b"body-1".to_vec()));
|
|
assert_eq!(history(&server, &vault, &token, "").len(), 3, "both the tombstone and the recreate stay listed");
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_seq_is_404_viewer_reads_but_cannot_restore_and_a_non_member_is_404() {
|
|
let (server, _) = server("history-roles");
|
|
let (vault, owner) = vault(&server, "hist");
|
|
edits(&server, &vault, &owner, 3);
|
|
|
|
let (_, viewer) = server.paired("hist-viewer");
|
|
let (status, code) = server.invite(&vault, &owner, "viewer");
|
|
assert_eq!(status, 200);
|
|
assert_eq!(server.accept(&viewer, &code).0, 200);
|
|
let (_, stranger) = server.paired("hist-stranger");
|
|
|
|
assert_eq!(restore(&server, &vault, &viewer, 1).0, 403, "a viewer cannot restore");
|
|
assert_eq!(restore(&server, &vault, &viewer, 999).0, 403, "role first, never existence");
|
|
assert_eq!(restore(&server, &vault, &owner, 999).0, 404, "unknown revision");
|
|
assert_eq!(read(&server, &vault, &owner, "?seq=999").0, 404, "unknown revision read");
|
|
assert_eq!(server.json("GET", &format!("/v1/vaults/{vault}/files-history/a.md"), Some(&stranger), &[], None).0, 404, "a non-member");
|
|
assert_eq!(restore_status(&server, &vault, &stranger, 1), 404);
|
|
}
|
|
|
|
/// The raw restore status, for assertions where the body does not parse as JSON.
|
|
fn restore_status(server: &Server, vault: &str, token: &str, seq: u64) -> u16 {
|
|
server
|
|
.send(
|
|
"POST",
|
|
&format!("/v1/vaults/{vault}/files-restore/a.md"),
|
|
Some(token),
|
|
&[("Content-Type", "application/json")],
|
|
Some(serde_json::to_vec(&json!({"seq": seq})).expect("body").as_slice()),
|
|
)
|
|
.0
|
|
}
|
|
|
|
#[test]
|
|
fn retention_days_0_and_366_are_refused() {
|
|
let (server, _) = server("history-bounds");
|
|
let (vault, token) = vault(&server, "hist");
|
|
for days in [0, 366] {
|
|
let (status, _) = server.json("PATCH", &format!("/v1/vaults/{vault}"), Some(&token), &[], Some(&json!({"retention_days": days})));
|
|
assert_eq!(status, 400, "retention_days = {days}");
|
|
}
|
|
assert_eq!(server.json("PATCH", &format!("/v1/vaults/{vault}"), Some(&token), &[], Some(&json!({"retention_days": 365}))).0, 204, "the range top is fine");
|
|
}
|
|
|
|
#[test]
|
|
fn retention_gc_removes_expired_unique_blobs_and_keeps_the_newest_20() {
|
|
let (server, data) = server("history-retention");
|
|
let (vault, token) = vault(&server, "hist");
|
|
// Revision 2 shares its content with revision 30: its blob must survive.
|
|
let bodies: Vec<String> = (1..=40).map(|index| if index == 2 { "body-30".to_owned() } else { format!("body-{index}") }).collect();
|
|
let mut writes = Vec::new();
|
|
let mut base_seq = 0;
|
|
for body in &bodies {
|
|
let (status, record) = server.put(&vault, &token, "a.md", body, base_seq);
|
|
assert_eq!(status, 201);
|
|
base_seq = record["seq"].as_u64().expect("seq");
|
|
writes.push((base_seq, body.clone(), record["hash"].as_str().expect("hash").to_owned()));
|
|
}
|
|
|
|
let dir = data.join("blobs").join(&vault);
|
|
let (hash2, hash30) = (writes[1].2.clone(), writes[29].2.clone());
|
|
assert_eq!(hash2, hash30, "the shared content is one blob");
|
|
assert_eq!(listing(&server, &token, &vault)["bytes_used"].as_u64(), Some(265), "39 distinct contents");
|
|
|
|
let (status, _) = server.json("PATCH", &format!("/v1/vaults/{vault}"), Some(&token), &[], Some(&json!({"retention_days": 1})));
|
|
assert_eq!(status, 204);
|
|
let aged = now_ms() + 2 * 24 * 60 * 60 * 1000;
|
|
let report = server.state.gc(&vault, TOMBSTONE_GRACE_MS, aged).expect("gc");
|
|
assert_eq!(report, GcReport { removed: 19, bytes: 125 }, "the expired unique blobs went, the shared one stayed");
|
|
|
|
let rows = history(&server, &vault, &token, "");
|
|
assert_eq!(rows.len(), 20, "the current one and the newest 20 stay");
|
|
assert_eq!(rows.first().and_then(|row| row["seq"].as_u64()), Some(40));
|
|
assert_eq!(rows.last().and_then(|row| row["seq"].as_u64()), Some(21));
|
|
assert!(dir.join(&hash2).exists(), "a blob shared by a kept revision survives");
|
|
assert!(dir.join(&writes[39].2).exists(), "the current content stays");
|
|
assert!(!dir.join(&writes[0].2).exists(), "an expired revision's unique blob is gone");
|
|
assert_eq!(listing(&server, &token, &vault)["bytes_used"].as_u64(), Some(140), "20 distinct contents of the kept revisions");
|
|
}
|