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