test(cli): integration tests for edit/history, attachments, settings
Adds RELICARIO_TEST_ITEM_SECRET env hatch for rpassword calls in cmd_add / cmd_edit so piped-stdin tests can exercise the password prompt paths without a TTY. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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<String> {
|
||||||
|
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<()> {
|
fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> {
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use rand::{rngs::OsRng, RngCore};
|
use rand::{rngs::OsRng, RngCore};
|
||||||
@@ -381,7 +391,7 @@ fn cmd_add(kind: AddKind) -> Result<()> {
|
|||||||
let password = if let Some(p) = password {
|
let password = if let Some(p) = password {
|
||||||
Some(Zeroizing::new(p))
|
Some(Zeroizing::new(p))
|
||||||
} else if password_prompt {
|
} else if password_prompt {
|
||||||
Some(Zeroizing::new(rpassword::prompt_password("Password: ")?))
|
Some(Zeroizing::new(prompt_secret("Password: ")?))
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
@@ -447,10 +457,10 @@ fn cmd_add(kind: AddKind) -> Result<()> {
|
|||||||
use zeroize::Zeroizing;
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
|
let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
|
||||||
let number = Zeroizing::new(rpassword::prompt_password("Card number: ")?);
|
let number = Zeroizing::new(prompt_secret("Card number: ")?);
|
||||||
let cvv = Zeroizing::new(rpassword::prompt_password("CVV (blank to skip): ")?);
|
let cvv = Zeroizing::new(prompt_secret("CVV (blank to skip): ")?);
|
||||||
let cvv = if cvv.is_empty() { None } else { Some(cvv) };
|
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 pin = if pin.is_empty() { None } else { Some(pin) };
|
||||||
|
|
||||||
let parsed_expiry = match expiry {
|
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 title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
|
||||||
let secret_b32 = match secret {
|
let secret_b32 = match secret {
|
||||||
Some(s) => s,
|
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 secret_bytes = base32_decode_lenient(&secret_b32)?;
|
||||||
let algo = match algorithm.to_ascii_lowercase().as_str() {
|
let algo = match algorithm.to_ascii_lowercase().as_str() {
|
||||||
@@ -862,7 +872,7 @@ fn cmd_edit(query: String) -> Result<()> {
|
|||||||
}
|
}
|
||||||
if prompt_yesno("Change password?")? {
|
if prompt_yesno("Change password?")? {
|
||||||
let old = l.password.clone();
|
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);
|
l.password = Some(new_pw);
|
||||||
if let Some(old_pw) = old {
|
if let Some(old_pw) = old {
|
||||||
push_history(&mut item.field_history, "login_password",
|
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 let Some(v) = prompt_keep_opt("Holder", c.holder.as_deref())? { c.holder = Some(v); }
|
||||||
if prompt_yesno("Change card number?")? {
|
if prompt_yesno("Change card number?")? {
|
||||||
let old = c.number.clone();
|
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 {
|
if let Some(o) = old {
|
||||||
push_history(&mut item.field_history, "card_number",
|
push_history(&mut item.field_history, "card_number",
|
||||||
Zeroizing::new(o.as_str().to_string()));
|
Zeroizing::new(o.as_str().to_string()));
|
||||||
|
|||||||
45
crates/relicario-cli/tests/attachments.rs
Normal file
45
crates/relicario-cli/tests/attachments.rs
Normal file
@@ -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"));
|
||||||
|
}
|
||||||
59
crates/relicario-cli/tests/edit_and_history.rs
Normal file
59
crates/relicario-cli/tests/edit_and_history.rs
Normal file
@@ -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/<id>.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()
|
||||||
|
}
|
||||||
23
crates/relicario-cli/tests/settings.rs
Normal file
23
crates/relicario-cli/tests/settings.rs
Normal file
@@ -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());
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user