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>
161 lines
9.3 KiB
Rust
161 lines
9.3 KiB
Rust
//! Vault lifecycle over real HTTP: soft delete hides and blocks, restore undoes it
|
|
//! inside the window, an injected clock drives the purge (rows and blob folder gone),
|
|
//! file authorship is readable, and the audit log holds every lifecycle event without
|
|
//! a single secret.
|
|
|
|
mod common;
|
|
|
|
use common::{ADMIN, Server, temp_dir};
|
|
use notescraft_sync_server::{PurgeReport, TOMBSTONE_GRACE_MS};
|
|
use serde_json::{Value, json};
|
|
|
|
#[test]
|
|
fn a_soft_deleted_vault_serves_404_and_restores() {
|
|
let server = Server::start_temp("lifecycle-delete");
|
|
let (vault, owner, _) = server.owned_vault("delete");
|
|
assert_eq!(server.put(&vault, &owner, "a.md", "x", 0).0, 201);
|
|
|
|
// Views and writes are 404 while deleted (owner included; restore is the exception).
|
|
assert_eq!(server.send("DELETE", &format!("/v1/vaults/{vault}"), Some(&owner), &[], None).0, 204);
|
|
assert_eq!(server.send("DELETE", &format!("/v1/vaults/{vault}"), Some(&owner), &[], None).0, 404, "never twice");
|
|
assert_eq!(server.json("GET", "/v1/vaults", Some(&owner), &[], None).1.as_array().expect("rows").len(), 0, "hidden from lists");
|
|
assert_eq!(server.json("GET", &format!("/v1/vaults/{vault}/changes?since=0"), Some(&owner), &[], None).0, 404);
|
|
assert_eq!(server.put(&vault, &owner, "a.md", "y", 1).0, 404);
|
|
assert_eq!(server.json("PATCH", &format!("/v1/vaults/{vault}"), Some(&owner), &[], Some(&json!({"name": "again"}))).0, 404);
|
|
|
|
// A stranger still cannot see it; the admin listing shows the deletion mark.
|
|
let (intruder_vault, intruder) = server.paired("intruder");
|
|
let _ = intruder_vault;
|
|
assert_eq!(server.json("GET", &format!("/v1/vaults/{vault}/changes?since=0"), Some(&intruder), &[], None).0, 404);
|
|
let (status, admin_rows) = server.json("GET", "/v1/admin/vaults", Some(ADMIN), &[], None);
|
|
assert_eq!(status, 200);
|
|
let rows = admin_rows.as_array().expect("rows");
|
|
let row = rows.iter().find(|row| row["id"] == vault.as_str()).expect("row");
|
|
assert!(row["deleted_ms"].as_u64().is_some(), "marked deleted: {row}");
|
|
|
|
// The owner restores inside the window; writes work again.
|
|
assert_eq!(server.json("POST", &format!("/v1/vaults/{vault}/restore"), Some(&owner), &[], None).0, 204);
|
|
let (_, mine) = server.json("GET", "/v1/vaults", Some(&owner), &[], None);
|
|
assert_eq!(mine.as_array().expect("rows").len(), 1);
|
|
assert_eq!(server.put(&vault, &owner, "a.md", "y", 1).0, 201);
|
|
let (status, admin_rows) = server.json("GET", "/v1/admin/vaults", Some(ADMIN), &[], None);
|
|
assert_eq!(status, 200);
|
|
let row = admin_rows.as_array().expect("rows").iter().find(|row| row["id"] == vault.as_str()).expect("row");
|
|
assert_eq!(row["deleted_ms"], Value::Null, "restored: {row}");
|
|
}
|
|
|
|
#[test]
|
|
fn restore_window_closes_when_the_purge_removes_the_vault() {
|
|
let dir = temp_dir("lifecycle-purge");
|
|
let server = common::start(&dir);
|
|
let (vault, owner, _) = server.owned_vault("purge");
|
|
assert_eq!(server.put(&vault, &owner, "gone.md", "blob content", 0).0, 201);
|
|
let blobs = dir.join("blobs").join(&vault);
|
|
assert_eq!(std::fs::read_dir(&blobs).expect("blob dir").count(), 1, "the blob exists");
|
|
|
|
assert_eq!(server.send("DELETE", &format!("/v1/vaults/{vault}"), Some(&owner), &[], None).0, 204);
|
|
// The admin route at the real clock: the vault is minutes old, nothing to purge.
|
|
let (status, report) = server.json("POST", "/v1/admin/purge", Some(ADMIN), &[], None);
|
|
assert_eq!((status, report["removed"].as_u64()), (200, Some(0)));
|
|
|
|
// With the injected clock past the window the vault, its rows and folder are gone.
|
|
let aged = notescraft_sync_server::now_ms() + TOMBSTONE_GRACE_MS + 1;
|
|
let report = server.state.purge(aged).expect("purge");
|
|
assert_eq!(report, PurgeReport { removed: 1 });
|
|
assert!(!blobs.exists(), "the blob folder is gone");
|
|
assert_eq!(server.state.purge(aged + 1).expect("purge"), PurgeReport { removed: 0 }, "purging twice is quiet");
|
|
assert_eq!(server.json("POST", &format!("/v1/vaults/{vault}/restore"), Some(&owner), &[], None).0, 404, "restore is too late");
|
|
// The purged vault vanished from the admin listing as well.
|
|
let (_, rows) = server.json("GET", "/v1/admin/vaults", Some(ADMIN), &[], None);
|
|
assert!(rows.as_array().expect("rows").iter().all(|row| row["id"] != vault.as_str()));
|
|
}
|
|
|
|
#[test]
|
|
fn files_meta_records_the_last_editor() {
|
|
let server = Server::start_temp("lifecycle-meta");
|
|
let (vault, owner, owner_user) = server.owned_vault("meta");
|
|
assert_eq!(server.put(&vault, &owner, "a.md", "first", 0).0, 201);
|
|
let (_, meta) = server.json("GET", &format!("/v1/vaults/{vault}/files-meta/a.md"), Some(&owner), &[], None);
|
|
assert_eq!(meta["editor_user"], owner_user);
|
|
assert_eq!(meta["seq"], 1);
|
|
assert_eq!(meta["editor_name"].as_str().expect("name"), "meta"); // the bootstrapped user's device name
|
|
|
|
// An invited editor's write carries their user id and name.
|
|
let (stranger_vault, stranger) = server.paired("member");
|
|
let _ = stranger_vault;
|
|
let (status, code) = server.invite(&vault, &owner, "editor");
|
|
assert_eq!(status, 200);
|
|
assert_eq!(server.accept(&stranger, &code).0, 200);
|
|
assert_eq!(server.put(&vault, &stranger, "b.md", "theirs", 0).0, 201);
|
|
let (_, meta) = server.json("GET", &format!("/v1/vaults/{vault}/files-meta/b.md"), Some(&owner), &[], None);
|
|
assert_ne!(meta["editor_user"], owner_user);
|
|
assert_eq!(meta["editor_name"], "member");
|
|
assert_eq!(meta["seq"], 2);
|
|
|
|
// Reads: any member; strangers 404; missing and deleted files 404.
|
|
assert_eq!(server.json("GET", &format!("/v1/vaults/{vault}/files-meta/b.md"), Some(&stranger), &[], None).0, 200);
|
|
let (ghost_vault, ghost) = server.paired("ghost");
|
|
let _ = ghost_vault;
|
|
assert_eq!(server.json("GET", &format!("/v1/vaults/{vault}/files-meta/b.md"), Some(&ghost), &[], None).0, 404, "a stranger");
|
|
assert_eq!(server.json("GET", &format!("/v1/vaults/{vault}/files-meta/missing.md"), Some(&owner), &[], None).0, 404);
|
|
let (status, record) = server.json("DELETE", &format!("/v1/vaults/{vault}/files/b.md"), Some(&stranger), &[("X-Base-Seq", "2")], None);
|
|
assert_eq!(status, 201, "{record}");
|
|
assert_eq!(server.json("GET", &format!("/v1/vaults/{vault}/files-meta/b.md"), Some(&owner), &[], None).0, 404, "a tombstone has no meta");
|
|
}
|
|
|
|
#[test]
|
|
fn the_audit_log_has_every_lifecycle_event_and_no_secrets() {
|
|
let server = Server::start_temp("lifecycle-audit");
|
|
// A user id is not a secret (it names rows); tokens, codes and hashes are.
|
|
let (vault, owner, owner_user) = server.owned_vault("audited");
|
|
server.leak(&owner);
|
|
let _ = owner_user;
|
|
|
|
// Rename the owner's own vault; mint, accept and withdraw an invite on it.
|
|
assert_eq!(server.json("PATCH", &format!("/v1/vaults/{vault}"), Some(&owner), &[], Some(&json!({"name": "renamed"}))).0, 204);
|
|
let (status, code) = server.invite(&vault, &owner, "viewer");
|
|
assert_eq!(status, 200);
|
|
let (guest_vault, guest) = server.paired("guest");
|
|
let _ = guest_vault;
|
|
assert_eq!(server.accept(&guest, &code).0, 200);
|
|
let (status, withdraw) = server.invite(&vault, &owner, "viewer");
|
|
assert_eq!(status, 200);
|
|
assert!(withdraw.len() > 3);
|
|
let id = server.invites(&vault, &owner)[0]["id"].as_str().expect("id").to_owned();
|
|
assert_eq!(server.send("DELETE", &format!("/v1/vaults/{vault}/invites/{id}"), Some(&owner), &[], None).0, 204);
|
|
assert_eq!(server.send("DELETE", &format!("/v1/vaults/{vault}"), Some(&owner), &[], None).0, 204);
|
|
assert_eq!(server.json("POST", &format!("/v1/vaults/{vault}/restore"), Some(&owner), &[], None).0, 204);
|
|
server.leak(&code);
|
|
server.leak(&withdraw);
|
|
|
|
// A role change and a removal are logged too; the guest's user id from the rows.
|
|
let (_, members) = server.json("GET", &format!("/v1/vaults/{vault}/members"), Some(&owner), &[], None);
|
|
let guest_user =
|
|
members.as_array().expect("members").iter().find(|row| row["role"] == "viewer").expect("guest row")["user_id"].as_str().expect("id").to_owned();
|
|
assert_eq!(server.json("PATCH", &format!("/v1/vaults/{vault}/members/{guest_user}"), Some(&owner), &[], Some(&json!({"role": "editor"}))).0, 204);
|
|
assert_eq!(server.json("DELETE", &format!("/v1/vaults/{vault}/members/{guest_user}"), Some(&owner), &[], None).0, 204);
|
|
|
|
let kinds: Vec<String> = server.audit().iter().map(|row| row["kind"].as_str().expect("kind").to_owned()).collect();
|
|
for wanted in [
|
|
"user_created",
|
|
"member_added",
|
|
"vault_created",
|
|
"vault_renamed",
|
|
"invite_created",
|
|
"invite_accepted",
|
|
"invite_deleted",
|
|
"vault_deleted",
|
|
"vault_restored",
|
|
] {
|
|
assert!(kinds.iter().any(|k| k == wanted), "no {wanted} in {kinds:?}");
|
|
}
|
|
// Invite lockout and purge events come from a dedicated flow on a fresh server.
|
|
let purged = Server::start_temp("lifecycle-audit-purge");
|
|
let (purge_vault, purge_owner, _) = purged.owned_vault("doomed");
|
|
assert_eq!(purged.send("DELETE", &format!("/v1/vaults/{purge_vault}"), Some(&purge_owner), &[], None).0, 204);
|
|
assert_eq!(purged.state.purge(notescraft_sync_server::now_ms() + TOMBSTONE_GRACE_MS + 1).expect("purge").removed, 1);
|
|
assert!(purged.audit().iter().any(|row| row["kind"] == "vault_purged"));
|
|
purged.assert_no_secrets(&serde_json::to_string(&purged.audit()).expect("rows text"));
|
|
server.assert_no_secrets(&serde_json::to_string(&server.audit()).expect("rows text"));
|
|
}
|