diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index fd30c0d..5cd7362 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -256,6 +256,16 @@ fn main() -> Result<()> { } } +/// `rpassword::prompt_password` wrapper that honours `RELICARIO_TEST_ITEM_SECRET` +/// for integration-test use (rpassword reads /dev/tty by default, which is +/// unavailable in assert_cmd-spawned children). +fn prompt_secret(label: &str) -> Result { + if let Ok(s) = std::env::var("RELICARIO_TEST_ITEM_SECRET") { + return Ok(s); + } + rpassword::prompt_password(label).map_err(Into::into) +} + fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> { use std::fs; use rand::{rngs::OsRng, RngCore}; @@ -381,7 +391,7 @@ fn cmd_add(kind: AddKind) -> Result<()> { let password = if let Some(p) = password { Some(Zeroizing::new(p)) } else if password_prompt { - Some(Zeroizing::new(rpassword::prompt_password("Password: ")?)) + Some(Zeroizing::new(prompt_secret("Password: ")?)) } else { None }; @@ -447,10 +457,10 @@ fn cmd_add(kind: AddKind) -> Result<()> { use zeroize::Zeroizing; let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?; - let number = Zeroizing::new(rpassword::prompt_password("Card number: ")?); - let cvv = Zeroizing::new(rpassword::prompt_password("CVV (blank to skip): ")?); + let number = Zeroizing::new(prompt_secret("Card number: ")?); + let cvv = Zeroizing::new(prompt_secret("CVV (blank to skip): ")?); let cvv = if cvv.is_empty() { None } else { Some(cvv) }; - let pin = Zeroizing::new(rpassword::prompt_password("PIN (blank to skip): ")?); + let pin = Zeroizing::new(prompt_secret("PIN (blank to skip): ")?); let pin = if pin.is_empty() { None } else { Some(pin) }; let parsed_expiry = match expiry { @@ -552,7 +562,7 @@ fn cmd_add(kind: AddKind) -> Result<()> { let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?; let secret_b32 = match secret { Some(s) => s, - None => rpassword::prompt_password("TOTP secret (base32): ")?, + None => prompt_secret("TOTP secret (base32): ")?, }; let secret_bytes = base32_decode_lenient(&secret_b32)?; let algo = match algorithm.to_ascii_lowercase().as_str() { @@ -862,7 +872,7 @@ fn cmd_edit(query: String) -> Result<()> { } if prompt_yesno("Change password?")? { let old = l.password.clone(); - let new_pw = Zeroizing::new(rpassword::prompt_password("New password: ")?); + let new_pw = Zeroizing::new(prompt_secret("New password: ")?); l.password = Some(new_pw); if let Some(old_pw) = old { push_history(&mut item.field_history, "login_password", @@ -890,7 +900,7 @@ fn cmd_edit(query: String) -> Result<()> { if let Some(v) = prompt_keep_opt("Holder", c.holder.as_deref())? { c.holder = Some(v); } if prompt_yesno("Change card number?")? { let old = c.number.clone(); - c.number = Some(Zeroizing::new(rpassword::prompt_password("New number: ")?)); + c.number = Some(Zeroizing::new(prompt_secret("New number: ")?)); if let Some(o) = old { push_history(&mut item.field_history, "card_number", Zeroizing::new(o.as_str().to_string())); diff --git a/crates/relicario-cli/tests/attachments.rs b/crates/relicario-cli/tests/attachments.rs new file mode 100644 index 0000000..d590d38 --- /dev/null +++ b/crates/relicario-cli/tests/attachments.rs @@ -0,0 +1,45 @@ +mod common; + +use common::TestVault; + +#[test] +fn attach_list_extract_round_trip() { + let v = TestVault::init(); + v.run(&["add", "login", "--title", "thing", + "--username", "u", "--password", "p"]); + + let payload_path = v.path().join("payload.txt"); + std::fs::write(&payload_path, b"attached-bytes").unwrap(); + + let attach = v.run(&["attach", "thing", payload_path.to_str().unwrap()]); + assert!(attach.status.success(), "attach failed: {:?}", attach); + + let list = v.run(&["attachments", "thing"]); + let stdout = String::from_utf8(list.stdout).unwrap(); + assert!(stdout.contains("payload.txt"), "missing payload: {stdout}"); + + let aid = stdout.lines() + .find(|l| l.contains("payload.txt")) + .and_then(|l| l.split_whitespace().next()) + .expect("aid token"); + + let out_path = v.path().join("extracted.txt"); + let ex = v.run(&["extract", "thing", aid, "--out", out_path.to_str().unwrap()]); + assert!(ex.status.success(), "extract failed: {:?}", ex); + assert_eq!(std::fs::read(out_path).unwrap(), b"attached-bytes"); +} + +#[test] +fn attach_rejects_over_cap() { + let v = TestVault::init(); + v.run(&["add", "login", "--title", "thing", + "--username", "u", "--password", "p"]); + + v.run(&["settings", "attachment-cap", "--per-attachment-max-bytes", "10"]); + + let big = v.path().join("big.bin"); + std::fs::write(&big, vec![0u8; 100]).unwrap(); + let out = v.run(&["attach", "thing", big.to_str().unwrap()]); + assert!(!out.status.success(), "expected failure; got {:?}", out); + assert!(String::from_utf8(out.stderr).unwrap().to_lowercase().contains("attachment")); +} diff --git a/crates/relicario-cli/tests/edit_and_history.rs b/crates/relicario-cli/tests/edit_and_history.rs new file mode 100644 index 0000000..077c31c --- /dev/null +++ b/crates/relicario-cli/tests/edit_and_history.rs @@ -0,0 +1,59 @@ +mod common; + +use common::TestVault; + +#[test] +fn edit_password_captures_history() { + let v = TestVault::init(); + v.run(&["add", "login", "--title", "bank", + "--username", "u", "--password", "first-pw"]); + + // edit: accept defaults on title/group/tags/username/url, then change pw. + let out = run_edit_with_pw_change(&v, "bank", "second-pw"); + assert!(out.status.success(), "edit failed:\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr)); + + // Verify the edit commit exists in git log. + let log = std::process::Command::new("git") + .current_dir(v.path()).args(["log", "--oneline"]) + .output().unwrap(); + let log_str = String::from_utf8(log.stdout).unwrap(); + assert!(log_str.contains("edit: bank"), "missing edit commit: {log_str}"); + + // And the item file has been re-written (there's a single items/.enc). + let items_dir = v.path().join("items"); + let entries: Vec<_> = std::fs::read_dir(&items_dir).unwrap() + .map(|e| e.unwrap().path()).collect(); + assert_eq!(entries.len(), 1); +} + +/// Drives the interactive `edit` flow end-to-end: +/// 1. passphrase via env var. +/// 2. blank lines for title, group, tags, username, url. +/// 3. "y" for "Change password?" +/// 4. new password via RELICARIO_TEST_ITEM_SECRET env var. +fn run_edit_with_pw_change(v: &TestVault, query: &str, new_pw: &str) -> std::process::Output { + use assert_cmd::cargo::CommandCargoExt; + use std::io::Write; + use std::process::{Command, Stdio}; + + let mut cmd = Command::cargo_bin("relicario").unwrap(); + cmd.current_dir(v.path()) + .env("RELICARIO_IMAGE", &v.reference_image) + .env("RELICARIO_TEST_PASSPHRASE", &v.passphrase) + .env("RELICARIO_TEST_ITEM_SECRET", new_pw) + .args(["edit", query]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut child = cmd.spawn().unwrap(); + { + let stdin = child.stdin.as_mut().unwrap(); + // title, group, tags, username, url (keep defaults), then yes-to-change-pw. + for line in ["", "", "", "", "", "y"] { + writeln!(stdin, "{line}").unwrap(); + } + } + child.wait_with_output().unwrap() +} diff --git a/crates/relicario-cli/tests/settings.rs b/crates/relicario-cli/tests/settings.rs new file mode 100644 index 0000000..2a5b490 --- /dev/null +++ b/crates/relicario-cli/tests/settings.rs @@ -0,0 +1,23 @@ +mod common; + +use common::TestVault; + +#[test] +fn settings_roundtrip_trash_retention() { + let v = TestVault::init(); + let out = v.run(&["settings", "show"]); + assert!(String::from_utf8(out.stdout).unwrap().contains("trash_retention")); + + let out = v.run(&["settings", "trash-retention", "--days", "60"]); + assert!(out.status.success(), "set failed: {:?}", out); + let out = v.run(&["settings", "show"]); + let stdout = String::from_utf8(out.stdout).unwrap(); + assert!(stdout.contains("60"), "expected 60: {stdout}"); +} + +#[test] +fn settings_rejects_conflicting_retention_flags() { + let v = TestVault::init(); + let out = v.run(&["settings", "trash-retention", "--days", "30", "--forever"]); + assert!(!out.status.success()); +}