diff --git a/crates/relicario-cli/src/commands/org.rs b/crates/relicario-cli/src/commands/org.rs index b0f1bf8..3b40610 100644 --- a/crates/relicario-cli/src/commands/org.rs +++ b/crates/relicario-cli/src/commands/org.rs @@ -329,6 +329,503 @@ fn resolve_member_id(members: &OrgMembers, prefix: &str) -> Result { } } +// ═══════════ Item CRUD (B9-B13) ═══════════ +// +// `org add` / `get` / `list` / `edit` / `rm` / `restore` / `purge` for items +// stored under `items//.enc`. Each public `run_org_*` +// wrapper opens the org vault, resolves the calling member by device key, then +// delegates the actual work to an inner `*_with` fn that takes an already-opened +// `UnlockedOrgVault` + the caller's `OrgMember`. The split keeps the CRUD logic +// testable in-process without device-fingerprint plumbing. +// +// Supported builders for `org add`/`org edit`: Login, SecureNote, Identity. +// Card / Key / Document / Totp parity is deferred (those read secrets via +// rpassword/stdin); see the follow-up note in the plan after B13. + +use relicario_core::{Item, ItemCore}; + +use crate::org_session::UnlockedOrgVault; + +/// Item kinds `org add` supports without interactive prompts. This is the +/// handler-side enum (no clap attributes, no `collection`/`tags` — those are +/// threaded separately by B14's dispatch). Deliberately distinct from any +/// clap-side enum so the handler stays unaware of clap. +pub enum OrgAddKind { + Login { + title: String, + username: Option, + url: Option, + password: Option, + }, + SecureNote { + title: String, + body: String, + }, + Identity { + title: String, + full_name: Option, + email: Option, + phone: Option, + }, +} + +/// Build a typed `Item` from a non-interactive `OrgAddKind` plus tags. +fn build_org_item(kind: OrgAddKind, tags: Vec) -> Result { + use relicario_core::item_types::{IdentityCore, LoginCore, SecureNoteCore}; + use zeroize::Zeroizing; + + let mut item = match kind { + OrgAddKind::Login { title, username, url, password } => { + let parsed_url = match url { + Some(s) => Some(url::Url::parse(&s).with_context(|| format!("invalid URL: {s}"))?), + None => None, + }; + let password = password.map(Zeroizing::new); + Item::new(title, ItemCore::Login(LoginCore { + username, + password, + url: parsed_url, + totp: None, + })) + } + OrgAddKind::SecureNote { title, body } => { + Item::new(title, ItemCore::SecureNote(SecureNoteCore { + body: Zeroizing::new(body), + })) + } + OrgAddKind::Identity { title, full_name, email, phone } => { + Item::new(title, ItemCore::Identity(IdentityCore { + full_name, + address: None, + phone, + email, + date_of_birth: None, + })) + } + }; + item.tags = tags; + Ok(item) +} + +/// Insert-or-replace an `OrgManifestEntry` (keyed by item id), mirroring the +/// personal-vault `Manifest::upsert`. The collection slug is stored in plaintext +/// inside the encrypted manifest. +fn upsert_org_entry( + manifest: &mut relicario_core::OrgManifest, + item: &Item, + collection: &str, +) { + let entry = relicario_core::OrgManifestEntry { + id: item.id.clone(), + r#type: item.r#type, + title: item.title.clone(), + tags: item.tags.clone(), + modified: item.modified, + trashed_at: item.trashed_at, + collection: collection.to_string(), + }; + if let Some(slot) = manifest.entries.iter_mut().find(|e| e.id == item.id) { + *slot = entry; + } else { + manifest.entries.push(entry); + } +} + +/// Resolve a query (exact id, else case-insensitive title substring) against an +/// already-grant-filtered manifest. +fn resolve_org_query<'a>( + manifest: &'a relicario_core::OrgManifest, + query: &str, +) -> Result<&'a relicario_core::OrgManifestEntry> { + if let Some(entry) = manifest.entries.iter().find(|e| e.id.as_str() == query) { + return Ok(entry); + } + let needle = query.to_lowercase(); + let hits: Vec<&relicario_core::OrgManifestEntry> = manifest.entries.iter() + .filter(|e| e.title.to_lowercase().contains(&needle)) + .collect(); + match hits.len() { + 0 => anyhow::bail!("no item matches `{query}`"), + 1 => Ok(hits[0]), + _ => { + let titles: Vec<&str> = hits.iter().map(|e| e.title.as_str()).collect(); + anyhow::bail!("ambiguous — {} matches: {}", hits.len(), titles.join(", ")) + } + } +} + +// ── add ────────────────────────────────────────────────────────────────────── + +/// `org add`: create a typed item in a collection the caller holds a grant for. +pub fn run_org_add(dir: &Path, collection: &str, kind: OrgAddKind, tags: Vec) -> Result<()> { + let vault = crate::org_session::open_org_vault(Some(dir))?; + let caller = vault.current_member()?; + run_org_add_with(&vault, &caller, collection, kind, tags) +} + +fn run_org_add_with( + vault: &UnlockedOrgVault, + caller: &OrgMember, + collection: &str, + kind: OrgAddKind, + tags: Vec, +) -> Result<()> { + // The slug must exist in collections.json… + let collections = vault.load_collections()?; + if !collections.contains_slug(collection) { + anyhow::bail!("collection `{collection}` does not exist — create it with `relicario org create-collection`"); + } + // …and the caller must hold a grant for it. + UnlockedOrgVault::ensure_grant(caller, collection)?; + + let item = build_org_item(kind, tags)?; + let item_rel = vault.save_item(collection, &item)?; + + // Upsert the manifest entry, then re-encrypt the manifest. + let mut manifest = vault.load_manifest()?; + upsert_org_entry(&mut manifest, &item, collection); + vault.save_manifest(&manifest)?; + + let subject = format!( + "org add: {} ({})", + crate::helpers::sanitize_for_commit(&item.title), + item.id.as_str() + ); + let commit_msg = format!( + "{subject}\n\nRelicario-Actor: {} {}\nRelicario-Action: item-create\nRelicario-Collection: {}\nRelicario-Item: {}", + caller.display_name, + caller.member_id.as_str(), + collection, + item.id.as_str() + ); + crate::org_session::org_git_run( + &vault.root, + &["add", &item_rel, "manifest.enc"], + "org add: git add", + )?; + crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org add: git commit")?; + + println!("Added {} ({}) to `{}`", item.title, item.id.as_str(), collection); + Ok(()) +} + +// ── list ───────────────────────────────────────────────────────────────────── + +/// `org list`: list items in the caller's granted collections (filtered by +/// `OrgManifest::filter_for_member`). `trashed` toggles between live + trashed. +pub fn run_org_list(dir: &Path, trashed: bool) -> Result<()> { + let vault = crate::org_session::open_org_vault(Some(dir))?; + let caller = vault.current_member()?; + run_org_list_with(&vault, &caller, trashed) +} + +fn run_org_list_with(vault: &UnlockedOrgVault, caller: &OrgMember, trashed: bool) -> Result<()> { + let manifest = vault.load_manifest()?; + + // filter_for_member restricts to the caller's granted collections. + let visible = manifest.filter_for_member(caller); + + let mut entries: Vec<_> = visible.entries.iter() + .filter(|e| if trashed { e.trashed_at.is_some() } else { e.trashed_at.is_none() }) + .collect(); + entries.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase())); + + if entries.is_empty() { + eprintln!("(no items match)"); + return Ok(()); + } + + println!("{:<16} {:<14} {:<12} TITLE", "ID", "TYPE", "COLLECTION"); + for e in entries { + println!( + "{:<16} {:<14} {:<12} {}", + e.id.as_str(), + format!("{:?}", e.r#type), + e.collection, + e.title + ); + } + Ok(()) +} + +// ── get ────────────────────────────────────────────────────────────────────── + +/// `org get`: print one item, masking secrets unless `show`. The query resolves +/// over the caller-visible manifest only; the resolved collection's grant is +/// re-checked (defense in depth). +pub fn run_org_get(dir: &Path, query: &str, show: bool) -> Result<()> { + let vault = crate::org_session::open_org_vault(Some(dir))?; + let caller = vault.current_member()?; + run_org_get_with(&vault, &caller, query, show) +} + +fn run_org_get_with(vault: &UnlockedOrgVault, caller: &OrgMember, query: &str, show: bool) -> Result<()> { + use zeroize::Zeroizing; + + let manifest = vault.load_manifest()?; + let visible = manifest.filter_for_member(caller); + + let entry = resolve_org_query(&visible, query)?; + UnlockedOrgVault::ensure_grant(caller, &entry.collection)?; + + let item = vault.load_item(&entry.collection, &entry.id)?; + + println!("ID: {}", item.id.as_str()); + println!("Title: {}", item.title); + println!("Type: {:?}", item.r#type); + println!("Collection: {}", entry.collection); + if !item.tags.is_empty() { println!("Tags: {}", item.tags.join(", ")); } + println!("Modified: {}", crate::helpers::iso8601(item.modified)); + if let Some(t) = item.trashed_at { println!("Trashed: {}", crate::helpers::iso8601(t)); } + println!(); + + let primary_secret: Option> = match &item.core { + ItemCore::Login(l) => { + if let Some(u) = &l.username { println!("Username: {u}"); } + if let Some(u) = &l.url { println!("URL: {u}"); } + l.password.clone() + } + ItemCore::SecureNote(n) => { + if show { println!("Body:\n{}", n.body.as_str()); } + else { println!("Body: ********"); } + None + } + ItemCore::Identity(i) => { + if let Some(v) = &i.full_name { println!("Name: {v}"); } + if let Some(v) = &i.email { println!("Email: {v}"); } + if let Some(v) = &i.phone { println!("Phone: {v}"); } + None + } + ItemCore::Card(c) => { + if let Some(h) = &c.holder { println!("Holder: {h}"); } + c.number.clone() + } + ItemCore::Key(k) => { + if let Some(l) = &k.label { println!("Label: {l}"); } + Some(k.key_material.clone()) + } + ItemCore::Document(d) => { + println!("Filename: {}", d.filename); + println!("MIME: {}", d.mime_type); + None + } + ItemCore::Totp(t) => { + if let Some(i) = &t.issuer { println!("Issuer: {i}"); } + if let Some(l) = &t.label { println!("Label: {l}"); } + None + } + }; + + if let Some(secret) = primary_secret { + if show { + println!("Secret: {}", secret.as_str()); + } else { + println!("Secret: ******** (use --show to reveal)"); + } + } + Ok(()) +} + +// ── edit ───────────────────────────────────────────────────────────────────── + +/// `org edit`: flag-driven field update for login / secure-note / identity. +/// Blank flags keep their current value. The blob is re-saved in place, the +/// manifest upserted, and the commit carries `Relicario-Action: item-update`. +#[allow(clippy::too_many_arguments)] +pub fn run_org_edit( + dir: &Path, + query: &str, + title: Option, + username: Option, + url: Option, + password: Option, + body: Option, + email: Option, + phone: Option, + full_name: Option, +) -> Result<()> { + let vault = crate::org_session::open_org_vault(Some(dir))?; + let caller = vault.current_member()?; + run_org_edit_with( + &vault, &caller, query, title, username, url, password, body, email, phone, full_name, + ) +} + +#[allow(clippy::too_many_arguments)] +fn run_org_edit_with( + vault: &UnlockedOrgVault, + caller: &OrgMember, + query: &str, + title: Option, + username: Option, + url: Option, + password: Option, + body: Option, + email: Option, + phone: Option, + full_name: Option, +) -> Result<()> { + use relicario_core::now_unix; + use zeroize::Zeroizing; + + let manifest = vault.load_manifest()?; + let visible = manifest.filter_for_member(caller); + let entry = resolve_org_query(&visible, query)?; + let collection = entry.collection.clone(); + let id = entry.id.clone(); + UnlockedOrgVault::ensure_grant(caller, &collection)?; + + let mut item = vault.load_item(&collection, &id)?; + + if let Some(t) = title { item.title = t; } + + match &mut item.core { + ItemCore::Login(l) => { + if let Some(u) = username { l.username = Some(u); } + if let Some(u) = url { + l.url = Some(url::Url::parse(&u).with_context(|| format!("invalid URL: {u}"))?); + } + if let Some(p) = password { l.password = Some(Zeroizing::new(p)); } + } + ItemCore::SecureNote(n) => { + if let Some(b) = body { n.body = Zeroizing::new(b); } + } + ItemCore::Identity(i) => { + if let Some(v) = full_name { i.full_name = Some(v); } + if let Some(v) = email { i.email = Some(v); } + if let Some(v) = phone { i.phone = Some(v); } + } + _ => anyhow::bail!("org edit currently supports login, secure-note, and identity items"), + } + + item.modified = now_unix(); + let item_rel = vault.save_item(&collection, &item)?; + + let mut manifest = vault.load_manifest()?; + upsert_org_entry(&mut manifest, &item, &collection); + vault.save_manifest(&manifest)?; + + let subject = format!( + "org edit: {} ({})", + crate::helpers::sanitize_for_commit(&item.title), + item.id.as_str() + ); + let commit_msg = format!( + "{subject}\n\nRelicario-Actor: {} {}\nRelicario-Action: item-update\nRelicario-Collection: {}\nRelicario-Item: {}", + caller.display_name, caller.member_id.as_str(), collection, item.id.as_str() + ); + crate::org_session::org_git_run(&vault.root, &["add", &item_rel, "manifest.enc"], "org edit: git add")?; + crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org edit: git commit")?; + + println!("Updated {}", item.id.as_str()); + Ok(()) +} + +// ── trash lifecycle: rm / restore / purge ──────────────────────────────────── + +/// Resolve a query to (collection, item) with grant enforcement. Shared by the +/// trash-lifecycle commands. +fn open_org_item( + vault: &UnlockedOrgVault, + caller: &OrgMember, + query: &str, +) -> Result<(String, Item)> { + let manifest = vault.load_manifest()?; + let visible = manifest.filter_for_member(caller); + let entry = resolve_org_query(&visible, query)?; + let collection = entry.collection.clone(); + let id = entry.id.clone(); + UnlockedOrgVault::ensure_grant(caller, &collection)?; + let item = vault.load_item(&collection, &id)?; + Ok((collection, item)) +} + +/// `org rm`: soft-delete (sets `trashed_at`); reversible via `org restore`. +pub fn run_org_rm(dir: &Path, query: &str) -> Result<()> { + let vault = crate::org_session::open_org_vault(Some(dir))?; + let caller = vault.current_member()?; + run_org_rm_with(&vault, &caller, query) +} + +fn run_org_rm_with(vault: &UnlockedOrgVault, caller: &OrgMember, query: &str) -> Result<()> { + let (collection, mut item) = open_org_item(vault, caller, query)?; + + item.soft_delete(); + let item_rel = vault.save_item(&collection, &item)?; + let mut manifest = vault.load_manifest()?; + upsert_org_entry(&mut manifest, &item, &collection); + vault.save_manifest(&manifest)?; + + let commit_msg = format!( + "org trash: {} ({})\n\nRelicario-Actor: {} {}\nRelicario-Action: item-delete\nRelicario-Collection: {}\nRelicario-Item: {}", + crate::helpers::sanitize_for_commit(&item.title), item.id.as_str(), + caller.display_name, caller.member_id.as_str(), collection, item.id.as_str() + ); + crate::org_session::org_git_run(&vault.root, &["add", &item_rel, "manifest.enc"], "org rm: git add")?; + crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org rm: git commit")?; + println!("Moved to trash: {}", item.title); + Ok(()) +} + +/// `org restore`: clear `trashed_at`, bringing the item back into the live list. +pub fn run_org_restore(dir: &Path, query: &str) -> Result<()> { + let vault = crate::org_session::open_org_vault(Some(dir))?; + let caller = vault.current_member()?; + run_org_restore_with(&vault, &caller, query) +} + +fn run_org_restore_with(vault: &UnlockedOrgVault, caller: &OrgMember, query: &str) -> Result<()> { + let (collection, mut item) = open_org_item(vault, caller, query)?; + + item.restore(); + let item_rel = vault.save_item(&collection, &item)?; + let mut manifest = vault.load_manifest()?; + upsert_org_entry(&mut manifest, &item, &collection); + vault.save_manifest(&manifest)?; + + let commit_msg = format!( + "org restore: {} ({})\n\nRelicario-Actor: {} {}\nRelicario-Action: item-restore\nRelicario-Collection: {}\nRelicario-Item: {}", + crate::helpers::sanitize_for_commit(&item.title), item.id.as_str(), + caller.display_name, caller.member_id.as_str(), collection, item.id.as_str() + ); + crate::org_session::org_git_run(&vault.root, &["add", &item_rel, "manifest.enc"], "org restore: git add")?; + crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org restore: git commit")?; + println!("Restored: {}", item.title); + Ok(()) +} + +/// `org purge`: permanently delete the blob (git rm) and drop the manifest entry. +pub fn run_org_purge(dir: &Path, query: &str) -> Result<()> { + let vault = crate::org_session::open_org_vault(Some(dir))?; + let caller = vault.current_member()?; + run_org_purge_with(&vault, &caller, query) +} + +fn run_org_purge_with(vault: &UnlockedOrgVault, caller: &OrgMember, query: &str) -> Result<()> { + let (collection, item) = open_org_item(vault, caller, query)?; + let title = item.title.clone(); + let id = item.id.clone(); + + // Remove the blob from disk, drop the manifest entry, stage with git rm. + vault.remove_item(&collection, &id)?; + let mut manifest = vault.load_manifest()?; + manifest.entries.retain(|e| e.id != id); + vault.save_manifest(&manifest)?; + + let item_rel = format!("items/{}/{}.enc", collection, id.as_str()); + crate::helpers::git_rm(&vault.root, &[item_rel], "org purge: git rm")?; + crate::org_session::org_git_run(&vault.root, &["add", "manifest.enc"], "org purge: git add manifest")?; + + let commit_msg = format!( + "org purge: {} ({})\n\nRelicario-Actor: {} {}\nRelicario-Action: item-purge\nRelicario-Collection: {}\nRelicario-Item: {}", + crate::helpers::sanitize_for_commit(&title), id.as_str(), + caller.display_name, caller.member_id.as_str(), collection, id.as_str() + ); + crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org purge: git commit")?; + println!("Purged: {title}"); + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -386,3 +883,390 @@ mod tests { assert!(members.find_by_id(&id).unwrap().collections.contains(&"dev".to_string())); } } + +// ═══════════ Item CRUD tests (B9-B13) ═══════════ +// +// `relicario-cli` is a binary-only crate, so integration tests in `tests/` +// can only drive the compiled binary — and the item subcommands are not wired +// into `Commands::Org` dispatch yet (that is B14). These in-process unit tests +// therefore exercise the CRUD logic through the inner `*_with` helpers against a +// directly-constructed `UnlockedOrgVault` over a real git repo, which needs no +// device-fingerprint plumbing. The public `run_org_*` wrappers add only the +// open-vault + resolve-caller preamble, which the `tests/org_*` integration +// suites in the plan cover once B14 lands the CLI dispatch. +#[cfg(test)] +mod crud_tests { + use super::*; + use relicario_core::{ + encrypt_org_manifest, CollectionDef, ItemId, MemberId, OrgCollections, OrgManifest, + OrgMember, OrgRole, + }; + use std::path::Path; + use std::process::Command; + use tempfile::TempDir; + use zeroize::Zeroizing; + + /// A throwaway org vault: a real (unsigned-commit) git repo with the org + /// scaffold written and an `UnlockedOrgVault` holding a known key. + struct Fixture { + _dir: TempDir, + vault: UnlockedOrgVault, + } + + fn git(root: &Path, args: &[&str]) { + let out = Command::new("git").current_dir(root).args(args).output().unwrap(); + assert!(out.status.success(), "git {:?} failed: {}", args, String::from_utf8_lossy(&out.stderr)); + } + + impl Fixture { + fn new() -> Self { + let dir = TempDir::new().unwrap(); + let root = dir.path().to_path_buf(); + std::fs::create_dir_all(root.join("items")).unwrap(); + std::fs::create_dir_all(root.join("keys")).unwrap(); + + let org_key = Zeroizing::new([7u8; 32]); + + // Scaffold the non-encrypted control files. + std::fs::write( + root.join("collections.json"), + serde_json::to_string_pretty(&OrgCollections::new()).unwrap(), + ) + .unwrap(); + // Empty encrypted manifest. + let manifest = OrgManifest::new(); + std::fs::write( + root.join("manifest.enc"), + encrypt_org_manifest(&manifest, &org_key).unwrap(), + ) + .unwrap(); + + // A real git repo, but with signing disabled so commits succeed + // without a device key (signature verification is Dev-C's hook). + git(&root, &["init", "-q"]); + git(&root, &["config", "user.name", "Test"]); + git(&root, &["config", "user.email", "test@relicario.test"]); + git(&root, &["config", "commit.gpgsign", "false"]); + git(&root, &["add", "."]); + git(&root, &["commit", "-q", "-m", "scaffold"]); + + let vault = UnlockedOrgVault { root, org_key }; + Fixture { _dir: dir, vault } + } + + /// Add a collection to collections.json and return a member granted it. + fn with_collection(&self, slug: &str) -> OrgMember { + let mut collections = self.vault.load_collections().unwrap(); + collections.collections.push(CollectionDef { + slug: slug.to_string(), + display_name: slug.to_string(), + created_by: MemberId::new(), + created_at: 0, + }); + self.vault.save_collections(&collections).unwrap(); + self.member(vec![slug.to_string()]) + } + + fn member(&self, collections: Vec) -> OrgMember { + OrgMember { + member_id: MemberId("0123456789abcdef".into()), + display_name: "Alice".into(), + role: OrgRole::Owner, + ed25519_pubkey: "ssh-ed25519 AAAA fake".into(), + collections, + added_at: 0, + added_by: MemberId("0123456789abcdef".into()), + } + } + + fn head_body(&self) -> String { + let out = Command::new("git") + .current_dir(&self.vault.root) + .args(["log", "-1", "--format=%B"]) + .output() + .unwrap(); + String::from_utf8_lossy(&out.stdout).to_string() + } + + fn manifest_entry_for<'a>( + &self, + m: &'a OrgManifest, + title: &str, + ) -> Option<&'a relicario_core::OrgManifestEntry> { + m.entries.iter().find(|e| e.title == title) + } + } + + fn login(title: &str, user: &str, pw: &str) -> OrgAddKind { + OrgAddKind::Login { + title: title.into(), + username: Some(user.into()), + url: Some("https://example.com".into()), + password: Some(pw.into()), + } + } + + // ── B10: add ────────────────────────────────────────────────────────────── + + #[test] + fn add_writes_collection_scoped_blob_and_manifest_and_trailers() { + let f = Fixture::new(); + let caller = f.with_collection("prod"); + + run_org_add_with(&f.vault, &caller, "prod", login("GitHub", "alice", "hunter2"), vec![]) + .unwrap(); + + // Blob lives under items/prod/, not flat items/. + let prod_dir = f.vault.root.join("items").join("prod"); + let blobs: Vec<_> = std::fs::read_dir(&prod_dir).unwrap().collect(); + assert_eq!(blobs.len(), 1, "expected exactly one blob under items/prod/"); + assert!(!f.vault.root.join("items").join("GitHub.enc").exists()); + + // Manifest entry recorded with the collection. + let manifest = f.vault.load_manifest().unwrap(); + let entry = f.manifest_entry_for(&manifest, "GitHub").expect("manifest entry"); + assert_eq!(entry.collection, "prod"); + + // Commit trailers. + let body = f.head_body(); + assert!(body.contains("Relicario-Action: item-create"), "body: {body}"); + assert!(body.contains("Relicario-Collection: prod"), "body: {body}"); + assert!(body.contains(&format!("Relicario-Item: {}", entry.id.as_str())), "body: {body}"); + assert!(body.contains("Relicario-Actor: Alice 0123456789abcdef"), "body: {body}"); + } + + #[test] + fn add_secure_note_and_identity_round_trip() { + let f = Fixture::new(); + let caller = f.with_collection("prod"); + + run_org_add_with( + &f.vault, + &caller, + "prod", + OrgAddKind::SecureNote { title: "Notes".into(), body: "secret-body".into() }, + vec!["tag1".into()], + ) + .unwrap(); + run_org_add_with( + &f.vault, + &caller, + "prod", + OrgAddKind::Identity { + title: "Me".into(), + full_name: Some("Alice Anderson".into()), + email: Some("a@example.com".into()), + phone: None, + }, + vec![], + ) + .unwrap(); + + let manifest = f.vault.load_manifest().unwrap(); + assert_eq!(manifest.entries.len(), 2); + let note = f.manifest_entry_for(&manifest, "Notes").unwrap(); + assert_eq!(note.tags, vec!["tag1".to_string()]); + let note_item = f.vault.load_item("prod", ¬e.id).unwrap(); + match ¬e_item.core { + ItemCore::SecureNote(n) => assert_eq!(n.body.as_str(), "secret-body"), + _ => panic!("expected secure note"), + } + } + + #[test] + fn add_rejects_ungranted_collection() { + let f = Fixture::new(); + // Collection exists, but the caller holds no grant for it. + let _ = f.with_collection("secret"); + let caller = f.member(vec![]); // no grants + + let err = run_org_add_with(&f.vault, &caller, "secret", login("X", "u", "p"), vec![]) + .unwrap_err(); + let msg = format!("{err:#}"); + assert!(msg.contains("access denied") || msg.contains("grant"), "msg: {msg}"); + } + + #[test] + fn add_rejects_unknown_collection() { + let f = Fixture::new(); + let caller = f.member(vec!["ghost".into()]); // grant for a slug that doesn't exist + + let err = run_org_add_with(&f.vault, &caller, "ghost", login("X", "u", "p"), vec![]) + .unwrap_err(); + let msg = format!("{err:#}"); + assert!(msg.contains("does not exist") || msg.contains("ghost"), "msg: {msg}"); + } + + // ── B11: get + list ─────────────────────────────────────────────────────── + + #[test] + fn list_filters_to_granted_collections() { + let f = Fixture::new(); + // Two collections exist; caller is granted only `prod`. + let _ = f.with_collection("prod"); + let _ = f.with_collection("secret"); + let prod_caller = f.member(vec!["prod".into()]); + let secret_caller = f.member(vec!["secret".into()]); + + run_org_add_with(&f.vault, &prod_caller, "prod", login("InProd", "u", "p"), vec![]).unwrap(); + run_org_add_with(&f.vault, &secret_caller, "secret", login("InSecret", "u", "p"), vec![]) + .unwrap(); + + // The prod caller's visible manifest excludes the secret entry. + let manifest = f.vault.load_manifest().unwrap(); + let visible = manifest.filter_for_member(&prod_caller); + let titles: Vec<&str> = visible.entries.iter().map(|e| e.title.as_str()).collect(); + assert!(titles.contains(&"InProd")); + assert!(!titles.contains(&"InSecret"), "leaked ungranted entry: {titles:?}"); + + // run_org_list_with returns Ok and prints only granted entries. + run_org_list_with(&f.vault, &prod_caller, false).unwrap(); + } + + #[test] + fn get_resolves_by_id_and_title_substring() { + let f = Fixture::new(); + let caller = f.with_collection("prod"); + run_org_add_with(&f.vault, &caller, "prod", login("GitHub", "alice", "hunter2"), vec![]) + .unwrap(); + + let manifest = f.vault.load_manifest().unwrap(); + let id = manifest.entries[0].id.as_str().to_string(); + + // exact id, case-insensitive substring, masked default + --show all OK. + run_org_get_with(&f.vault, &caller, &id, false).unwrap(); + run_org_get_with(&f.vault, &caller, "github", false).unwrap(); + run_org_get_with(&f.vault, &caller, "GitHub", true).unwrap(); + } + + #[test] + fn get_unknown_query_errors() { + let f = Fixture::new(); + let caller = f.with_collection("prod"); + run_org_add_with(&f.vault, &caller, "prod", login("GitHub", "alice", "hunter2"), vec![]) + .unwrap(); + let err = run_org_get_with(&f.vault, &caller, "nope", false).unwrap_err(); + assert!(format!("{err:#}").contains("no item matches")); + } + + #[test] + fn resolve_org_query_reports_ambiguity() { + let mut manifest = OrgManifest::new(); + for title in ["Mail Personal", "Mail Work"] { + manifest.entries.push(relicario_core::OrgManifestEntry { + id: ItemId::new(), + r#type: relicario_core::ItemType::Login, + title: title.into(), + tags: vec![], + modified: 0, + trashed_at: None, + collection: "prod".into(), + }); + } + let err = resolve_org_query(&manifest, "mail").unwrap_err(); + assert!(format!("{err:#}").contains("ambiguous"), "{err:#}"); + } + + // ── B12: edit ───────────────────────────────────────────────────────────── + + #[test] + fn edit_updates_login_field_and_writes_update_trailer() { + let f = Fixture::new(); + let caller = f.with_collection("prod"); + run_org_add_with(&f.vault, &caller, "prod", login("Mail", "old", "pw"), vec![]).unwrap(); + + run_org_edit_with( + &f.vault, &caller, "Mail", + None, Some("new-user".into()), None, None, None, None, None, None, + ) + .unwrap(); + + // The blob now carries the new username. + let manifest = f.vault.load_manifest().unwrap(); + let entry = f.manifest_entry_for(&manifest, "Mail").unwrap(); + let item = f.vault.load_item("prod", &entry.id).unwrap(); + match &item.core { + ItemCore::Login(l) => assert_eq!(l.username.as_deref(), Some("new-user")), + _ => panic!("expected login"), + } + + let body = f.head_body(); + assert!(body.contains("Relicario-Action: item-update"), "body: {body}"); + assert!(body.contains("Relicario-Collection: prod"), "body: {body}"); + } + + #[test] + fn edit_can_retitle_and_keeps_unset_fields() { + let f = Fixture::new(); + let caller = f.with_collection("prod"); + run_org_add_with(&f.vault, &caller, "prod", login("Mail", "old", "pw"), vec![]).unwrap(); + + run_org_edit_with( + &f.vault, &caller, "Mail", + Some("Webmail".into()), None, None, None, None, None, None, None, + ) + .unwrap(); + + let manifest = f.vault.load_manifest().unwrap(); + assert!(f.manifest_entry_for(&manifest, "Webmail").is_some()); + let entry = f.manifest_entry_for(&manifest, "Webmail").unwrap(); + let item = f.vault.load_item("prod", &entry.id).unwrap(); + match &item.core { + // username untouched (we passed None), password untouched. + ItemCore::Login(l) => assert_eq!(l.username.as_deref(), Some("old")), + _ => panic!("expected login"), + } + } + + // ── B13: rm / restore / purge ───────────────────────────────────────────── + + #[test] + fn rm_restore_purge_cycle() { + let f = Fixture::new(); + let caller = f.with_collection("prod"); + run_org_add_with( + &f.vault, + &caller, + "prod", + OrgAddKind::SecureNote { title: "Recovery".into(), body: "codes-here".into() }, + vec![], + ) + .unwrap(); + + // rm → trashed_at set, item drops out of the live list, shows in --trashed. + run_org_rm_with(&f.vault, &caller, "Recovery").unwrap(); + let manifest = f.vault.load_manifest().unwrap(); + let entry = f.manifest_entry_for(&manifest, "Recovery").unwrap(); + assert!(entry.trashed_at.is_some(), "rm should set trashed_at"); + assert!(f.head_body().contains("Relicario-Action: item-delete")); + + // restore → trashed_at cleared. + run_org_restore_with(&f.vault, &caller, "Recovery").unwrap(); + let manifest = f.vault.load_manifest().unwrap(); + let entry = f.manifest_entry_for(&manifest, "Recovery").unwrap(); + assert!(entry.trashed_at.is_none(), "restore should clear trashed_at"); + assert!(f.head_body().contains("Relicario-Action: item-restore")); + + // purge → blob gone from disk, manifest entry dropped, purge trailer. + run_org_purge_with(&f.vault, &caller, "Recovery").unwrap(); + let prod_dir = f.vault.root.join("items").join("prod"); + let count = std::fs::read_dir(&prod_dir).map(|d| d.count()).unwrap_or(0); + assert_eq!(count, 0, "blob not purged from items/prod/"); + let manifest = f.vault.load_manifest().unwrap(); + assert!(f.manifest_entry_for(&manifest, "Recovery").is_none(), "manifest entry not dropped"); + assert!(f.head_body().contains("Relicario-Action: item-purge")); + } + + #[test] + fn rm_enforces_grant_via_visible_manifest() { + let f = Fixture::new(); + // owner adds into prod + let owner = f.with_collection("prod"); + run_org_add_with(&f.vault, &owner, "prod", login("Secret", "u", "p"), vec![]).unwrap(); + + // a caller with no grant cannot even resolve the item (filtered out). + let outsider = f.member(vec![]); + let err = run_org_rm_with(&f.vault, &outsider, "Secret").unwrap_err(); + assert!(format!("{err:#}").contains("no item matches"), "{err:#}"); + } +}