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>
192 lines
6.6 KiB
Rust
192 lines
6.6 KiB
Rust
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()
|
|
}
|
|
|
|
#[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()
|
|
}
|