use assert_cmd::cargo::CommandCargoExt as _; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use tempfile::TempDir; /// A throwaway org vault with a device signing key wired via XDG_CONFIG_HOME. struct OrgFixture { _config: TempDir, vault: TempDir, xdg: PathBuf, } impl OrgFixture { /// Generate an ed25519 signing key in OpenSSH format using ssh-keygen and /// register it as the current device, then `org init`. fn new() -> Self { let config = TempDir::new().unwrap(); let xdg = config.path().to_path_buf(); let devices = xdg.join("relicario").join("devices").join("laptop"); std::fs::create_dir_all(&devices).unwrap(); // Generate an OpenSSH ed25519 keypair without a passphrase. let keyfile = devices.join("signing.key"); let status = Command::new("ssh-keygen") .args(["-t", "ed25519", "-N", "", "-C", "relicario-test", "-f"]) .arg(&keyfile) .stdout(Stdio::null()) .stderr(Stdio::null()) .status() .expect("ssh-keygen"); assert!(status.success(), "ssh-keygen failed"); // ssh-keygen writes signing.key + signing.key.pub; rename the .pub to signing.pub. std::fs::rename(devices.join("signing.key.pub"), devices.join("signing.pub")).unwrap(); // Mark this device current. std::fs::write( xdg.join("relicario").join("devices").join("current"), "laptop\n", ) .unwrap(); let vault = TempDir::new().unwrap(); let f = OrgFixture { _config: config, vault, xdg }; let out = f.run(&["org", "init", "--dir", f.vault_str(), "--name", "Acme"]); assert!(out.status.success(), "org init failed: {}", String::from_utf8_lossy(&out.stderr)); f } fn vault_path(&self) -> &Path { self.vault.path() } fn vault_str(&self) -> &str { self.vault.path().to_str().unwrap() } fn run(&self, args: &[&str]) -> std::process::Output { let mut cmd = Command::cargo_bin("relicario").unwrap(); cmd.env("XDG_CONFIG_HOME", &self.xdg) .env("RELICARIO_ORG_DIR", self.vault.path()) .args(args) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); cmd.output().unwrap() } /// Owner member id printed by `org init`/`org status`. We read it from /// members.json directly to avoid parsing stdout. fn owner_member_id(&self) -> String { let s = std::fs::read_to_string(self.vault.path().join("members.json")).unwrap(); let v: serde_json::Value = serde_json::from_str(&s).unwrap(); v["members"][0]["member_id"].as_str().unwrap().to_string() } } #[test] fn org_add_get_list_round_trip() { let f = OrgFixture::new(); let owner = f.owner_member_id(); // Create a collection and grant the owner access to it. let out = f.run(&["org", "create-collection", "prod", "--name", "Production"]); assert!(out.status.success(), "create-collection: {}", String::from_utf8_lossy(&out.stderr)); let out = f.run(&["org", "grant", &owner, "prod"]); assert!(out.status.success(), "grant: {}", String::from_utf8_lossy(&out.stderr)); // Add a login into the prod collection. let out = f.run(&[ "org", "add", "login", "--collection", "prod", "--title", "GitHub", "--username", "alice", "--url", "https://github.com", "--password", "hunter2", ]); assert!(out.status.success(), "org add: {}", String::from_utf8_lossy(&out.stderr)); // The blob must live under items/prod/, NOT flat items/. let prod_dir = f.vault_path().join("items").join("prod"); let blobs: Vec<_> = std::fs::read_dir(&prod_dir).unwrap().collect(); assert_eq!(blobs.len(), 1, "expected one blob under items/prod/"); // list shows it. let out = f.run(&["org", "list"]); let stdout = String::from_utf8_lossy(&out.stdout).to_string(); assert!(stdout.contains("GitHub"), "list missing GitHub: {stdout}"); // get masks by default. let out = f.run(&["org", "get", "GitHub"]); let stdout = String::from_utf8_lossy(&out.stdout).to_string(); assert!(stdout.contains("********"), "expected masked secret: {stdout}"); assert!(!stdout.contains("hunter2"), "leaked plaintext: {stdout}"); // get --show reveals. let out = f.run(&["org", "get", "GitHub", "--show"]); let stdout = String::from_utf8_lossy(&out.stdout).to_string(); assert!(stdout.contains("hunter2"), "expected plaintext with --show: {stdout}"); // The commit trailer records the action + collection + item. let log = Command::new("git") .args(["-C", f.vault_str(), "log", "-1", "--format=%B"]) .output() .unwrap(); let body = String::from_utf8_lossy(&log.stdout).to_string(); assert!(body.contains("Relicario-Action: item-create"), "missing action trailer: {body}"); assert!(body.contains("Relicario-Collection: prod"), "missing collection trailer: {body}"); assert!(body.contains("Relicario-Item: "), "missing item trailer: {body}"); } #[test] fn org_add_rejects_ungranted_collection() { let f = OrgFixture::new(); // Create the collection but do NOT grant the owner. let out = f.run(&["org", "create-collection", "secret", "--name", "Secret"]); assert!(out.status.success(), "create-collection: {}", String::from_utf8_lossy(&out.stderr)); let out = f.run(&[ "org", "add", "login", "--collection", "secret", "--title", "X", "--username", "u", "--password", "p", ]); assert!(!out.status.success(), "add into ungranted collection must fail"); let stderr = String::from_utf8_lossy(&out.stderr).to_string(); assert!(stderr.contains("access denied") || stderr.contains("grant"), "unexpected error: {stderr}"); } #[test] fn org_add_rejects_unknown_collection() { let f = OrgFixture::new(); let out = f.run(&[ "org", "add", "login", "--collection", "ghost", "--title", "X", "--username", "u", "--password", "p", ]); assert!(!out.status.success(), "add into nonexistent collection must fail"); let stderr = String::from_utf8_lossy(&out.stderr).to_string(); assert!(stderr.contains("does not exist") || stderr.contains("ghost"), "unexpected error: {stderr}"); } #[test] fn org_edit_updates_fields_and_commits_update_trailer() { let f = OrgFixture::new(); let owner = f.owner_member_id(); assert!(f.run(&["org", "create-collection", "prod", "--name", "Production"]).status.success()); assert!(f.run(&["org", "grant", &owner, "prod"]).status.success()); assert!(f.run(&[ "org", "add", "login", "--collection", "prod", "--title", "Mail", "--username", "old", "--password", "pw", ]).status.success()); // Edit the username. let out = f.run(&[ "org", "edit", "Mail", "--username", "new-user", ]); assert!(out.status.success(), "org edit: {}", String::from_utf8_lossy(&out.stderr)); // get --show reflects the new username. let out = f.run(&["org", "get", "Mail", "--show"]); let stdout = String::from_utf8_lossy(&out.stdout).to_string(); assert!(stdout.contains("new-user"), "edit did not take: {stdout}"); let log = Command::new("git") .args(["-C", f.vault_str(), "log", "-1", "--format=%B"]) .output().unwrap(); let body = String::from_utf8_lossy(&log.stdout).to_string(); assert!(body.contains("Relicario-Action: item-update"), "missing update trailer: {body}"); assert!(body.contains("Relicario-Collection: prod"), "missing collection trailer: {body}"); } #[test] fn org_rm_restore_purge_cycle() { let f = OrgFixture::new(); let owner = f.owner_member_id(); assert!(f.run(&["org", "create-collection", "prod", "--name", "Production"]).status.success()); assert!(f.run(&["org", "grant", &owner, "prod"]).status.success()); assert!(f.run(&[ "org", "add", "secure-note", "--collection", "prod", "--title", "Recovery", "--body", "codes-here", ]).status.success()); // rm → appears only with --trashed. assert!(f.run(&["org", "rm", "Recovery"]).status.success()); let listed = String::from_utf8_lossy(&f.run(&["org", "list"]).stdout).to_string(); assert!(!listed.contains("Recovery"), "trashed item still in default list: {listed}"); let trashed = String::from_utf8_lossy(&f.run(&["org", "list", "--trashed"]).stdout).to_string(); assert!(trashed.contains("Recovery"), "trashed item not in --trashed list: {trashed}"); // restore → back in default list. assert!(f.run(&["org", "restore", "Recovery"]).status.success()); let listed = String::from_utf8_lossy(&f.run(&["org", "list"]).stdout).to_string(); assert!(listed.contains("Recovery"), "restore did not bring it back: {listed}"); // purge → blob gone, entry gone, item-purge trailer. assert!(f.run(&["org", "purge", "Recovery"]).status.success()); let prod_dir = f.vault_path().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 listed = String::from_utf8_lossy(&f.run(&["org", "list", "--trashed"]).stdout).to_string(); assert!(!listed.contains("Recovery"), "purged item still listed: {listed}"); let log = Command::new("git") .args(["-C", f.vault_str(), "log", "-1", "--format=%B"]) .output().unwrap(); let body = String::from_utf8_lossy(&log.stdout).to_string(); assert!(body.contains("Relicario-Action: item-purge"), "missing purge trailer: {body}"); }