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:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user