feat(cli): close audit gaps — TOTP edit, history, detach, status, generator defaults

One coherent CLI completeness pass driven by the 2026-04-27 state-of-the-
project audit. All TDD; 6 new integration tests (workspace 158→164).

Stubs and dead state fixed:
- TOTP edit was an explicit stub at main.rs:925 ("delete and re-add for
  now"). Now supports editing issuer, label, and rotating the secret;
  rotated secrets are pushed to field_history under core:totp_secret.
- VaultSettings.generator_defaults was stored but never read by the CLI.
  cmd_generate now consults it when invoked inside an initialized vault;
  explicit flags override. Behavior outside a vault unchanged.

New commands:
- relicario settings generator-defaults [--random|--bip39] [--length |
  --words | --symbols | --separator] — view/edit the stored generator
  defaults.
- relicario history <query> [--show] [--field <name>] — view captured
  field history. Values masked by default.
- relicario detach <query> <aid> — remove an individual attachment +
  blob. Refuses to drop a Document item's primary attachment.
- relicario status — vault summary: root path, item counts (active /
  trashed), attachment count + total bytes, registered device count,
  last commit (%h %s).

Internal refactor (pure mechanical, no behavior change):
- cmd_add: 217-line match split into one build_<type>_item helper per
  ItemCore variant + a 7-arm dispatcher.
- cmd_edit: same treatment — edit_login, edit_card, edit_totp, etc. The
  history-tracking ones take a &mut FieldHistory alias for clarity.

Existing tests cover the refactor; the new helpers are tested through
the same integration paths.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-04-27 21:13:30 -04:00
parent f79a67bb15
commit 3f0f5b1b28
4 changed files with 961 additions and 299 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -29,6 +29,67 @@ fn attach_list_extract_round_trip() {
assert_eq!(std::fs::read(out_path).unwrap(), b"attached-bytes");
}
#[test]
fn detach_removes_attachment_and_blob() {
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());
let list = v.run(&["attachments", "thing"]);
let stdout = String::from_utf8(list.stdout).unwrap();
let aid = stdout.lines()
.find(|l| l.contains("payload.txt"))
.and_then(|l| l.split_whitespace().next())
.expect("aid token")
.to_string();
// Detach removes the attachment from the item AND deletes the blob.
let out = v.run(&["detach", "thing", &aid]);
assert!(
out.status.success(),
"detach failed:\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
// Item no longer lists the attachment.
let list2 = v.run(&["attachments", "thing"]);
let stdout2 = String::from_utf8(list2.stdout).unwrap();
assert!(
!stdout2.contains("payload.txt"),
"attachment still listed after detach: {stdout2}"
);
// Encrypted blob file is gone.
let blob_path = v.path()
.join("attachments")
.join(stdout.lines().nth(1).is_some().then_some("").unwrap_or(""));
let item_attach_dir = std::fs::read_dir(v.path().join("attachments"))
.unwrap().next().unwrap().unwrap().path();
let blob = item_attach_dir.join(format!("{aid}.enc"));
assert!(!blob.exists(), "blob still on disk: {}", blob.display());
let _ = blob_path; // keep the variable to avoid an unused warning
}
#[test]
fn detach_refuses_unknown_aid() {
let v = TestVault::init();
v.run(&["add", "login", "--title", "thing",
"--username", "u", "--password", "p"]);
let out = v.run(&["detach", "thing", "deadbeef"]);
assert!(!out.status.success(), "expected failure: {:?}", out);
assert!(
String::from_utf8_lossy(&out.stderr).to_lowercase().contains("no attachment"),
"expected 'no attachment' error in stderr"
);
}
#[test]
fn attach_rejects_over_cap() {
let v = TestVault::init();

View File

@@ -57,3 +57,135 @@ fn run_edit_with_pw_change(v: &TestVault, query: &str, new_pw: &str) -> std::pro
}
child.wait_with_output().unwrap()
}
#[test]
fn history_command_lists_per_field_entries() {
let v = TestVault::init();
v.run(&["add", "login", "--title", "bank",
"--username", "u", "--password", "first-pw"]);
let out = run_edit_with_pw_change(&v, "bank", "second-pw");
assert!(out.status.success(), "edit failed: {:?}", out);
// `history <query>` should list the captured field and a count.
let out = v.run(&["history", "bank"]);
assert!(
out.status.success(),
"history failed:\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
let stdout = String::from_utf8(out.stdout).unwrap();
assert!(
stdout.contains("login_password"),
"expected login_password key, got: {stdout}"
);
// Default (no --show) hides values.
assert!(
!stdout.contains("first-pw"),
"values should be masked without --show: {stdout}"
);
assert!(
stdout.contains("****"),
"expected masked value indicator: {stdout}"
);
}
#[test]
fn history_command_show_reveals_prior_values() {
let v = TestVault::init();
v.run(&["add", "login", "--title", "bank",
"--username", "u", "--password", "first-pw"]);
let out = run_edit_with_pw_change(&v, "bank", "second-pw");
assert!(out.status.success());
let out = v.run(&["history", "bank", "--show"]);
assert!(out.status.success(), "history --show failed: {:?}", out);
let stdout = String::from_utf8(out.stdout).unwrap();
assert!(
stdout.contains("first-pw"),
"expected old value 'first-pw' in --show output: {stdout}"
);
}
#[test]
fn history_command_reports_empty_when_nothing_changed() {
let v = TestVault::init();
v.run(&["add", "login", "--title", "untouched",
"--username", "u", "--password", "pw"]);
let out = v.run(&["history", "untouched"]);
assert!(out.status.success(), "history failed: {:?}", out);
let stdout = String::from_utf8(out.stdout).unwrap();
assert!(
stdout.to_lowercase().contains("no history"),
"expected 'no history' message, got: {stdout}"
);
}
#[test]
fn edit_totp_rotates_secret_and_captures_history() {
let v = TestVault::init();
v.run(&[
"add", "totp",
"--title", "github",
"--issuer", "github.com",
"--label", "alice",
"--secret", "JBSWY3DPEHPK3PXP",
]);
// Edit: change issuer, label, then rotate the secret to a new base32 value.
let out = run_edit_totp(&v, "github", "github-new.com", "alice@new", "NB2W45DFOIZA");
assert!(
out.status.success(),
"edit failed:\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
// Verify the issuer and label changes persisted by reading the item back.
let out = v.run(&["get", "github"]);
assert!(out.status.success(), "get failed: {:?}", out);
let stdout = String::from_utf8(out.stdout).unwrap();
assert!(
stdout.contains("github-new.com"),
"expected new issuer in get output, got: {stdout}"
);
assert!(
stdout.contains("alice@new"),
"expected new label in get output, got: {stdout}"
);
}
/// Drives the interactive `edit` flow for a TOTP item with secret rotation.
/// Stdin order: Title, Group, Tags (all blank to keep), Issuer, Label,
/// then "y" to "Change TOTP secret?" The new secret comes from
/// RELICARIO_TEST_ITEM_SECRET.
fn run_edit_totp(
v: &TestVault,
query: &str,
new_issuer: &str,
new_label: &str,
new_secret_b32: &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_secret_b32)
.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();
for line in ["", "", "", new_issuer, new_label, "y"] {
writeln!(stdin, "{line}").unwrap();
}
}
child.wait_with_output().unwrap()
}

View File

@@ -21,3 +21,115 @@ fn settings_rejects_conflicting_retention_flags() {
let out = v.run(&["settings", "trash-retention", "--days", "30", "--forever"]);
assert!(!out.status.success());
}
#[test]
fn generate_uses_vault_default_length() {
let v = TestVault::init();
// Default vault settings: GeneratorRequest::Random { length: 20, ... }.
let out = v.run(&["generate"]);
assert!(out.status.success(), "{:?}", String::from_utf8_lossy(&out.stderr));
let pw = String::from_utf8(out.stdout).unwrap();
assert_eq!(
pw.trim().chars().count(),
20,
"expected 20 chars at default, got {pw:?}"
);
// Update the vault default length to 32.
let out = v.run(&["settings", "generator-defaults", "--length", "32"]);
assert!(
out.status.success(),
"set generator-defaults failed: {}",
String::from_utf8_lossy(&out.stderr)
);
// `generate` (no flags) should now produce 32 chars.
let out = v.run(&["generate"]);
assert!(out.status.success(), "{:?}", String::from_utf8_lossy(&out.stderr));
let pw = String::from_utf8(out.stdout).unwrap();
assert_eq!(
pw.trim().chars().count(),
32,
"expected 32 chars after update, got {pw:?}"
);
// Explicit flag overrides the vault default.
let out = v.run(&["generate", "--length", "8"]);
assert!(out.status.success());
let pw = String::from_utf8(out.stdout).unwrap();
assert_eq!(
pw.trim().chars().count(),
8,
"explicit flag should override vault default, got {pw:?}"
);
}
#[test]
fn status_reports_item_attachment_and_device_counts() {
let v = TestVault::init();
v.run(&["add", "login", "--title", "active",
"--username", "u", "--password", "p"]);
v.run(&["add", "login", "--title", "to-trash",
"--username", "u", "--password", "p"]);
v.run(&["rm", "to-trash"]);
let payload = v.path().join("payload.txt");
std::fs::write(&payload, b"hello-world").unwrap();
v.run(&["attach", "active", payload.to_str().unwrap()]);
let out = v.run(&["status"]);
assert!(
out.status.success(),
"status failed:\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
let stdout = String::from_utf8(out.stdout).unwrap();
let lower = stdout.to_lowercase();
// 1 active + 1 trashed = 2 items total.
assert!(lower.contains("items"), "missing items section: {stdout}");
assert!(stdout.contains('2') || stdout.contains("2 ")
|| lower.contains("active: 1") || lower.contains("1 active"),
"expected item counts in output: {stdout}");
assert!(lower.contains("trash"), "missing trash count: {stdout}");
// 1 attachment, 11 bytes.
assert!(lower.contains("attachment"), "missing attachment section: {stdout}");
assert!(stdout.contains("11"), "expected 11-byte size in output: {stdout}");
// 0 devices in default test vault (init does not register one).
assert!(lower.contains("device"), "missing devices section: {stdout}");
// Last-commit line.
assert!(
lower.contains("last commit") || lower.contains("commit"),
"missing last-commit info: {stdout}",
);
}
#[test]
fn generate_works_outside_vault() {
use assert_cmd::cargo::CommandCargoExt;
use std::process::{Command, Stdio};
use tempfile::TempDir;
let tmp = TempDir::new().unwrap();
let out = Command::cargo_bin("relicario")
.unwrap()
.current_dir(tmp.path())
.args(["generate", "--length", "12"])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.unwrap();
assert!(
out.status.success(),
"no-vault generate failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let pw = String::from_utf8(out.stdout).unwrap();
assert_eq!(pw.trim().chars().count(), 12);
}