Files
Marcos Paulo 181aad91e6 feat: sync, accounts, live editing, vault launcher, compact layout
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>
2026-10-20 07:46:24 -03:00

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"));
}