test(cli): integration harness + basic flow tests

Uses assert_cmd + tempfile to spin up a fresh vault per test.
Covers init layout, add/list/get mask semantics, rm/restore/purge cycle,
and generate smoke. Adds RELICARIO_TEST_PASSPHRASE env-var hatch in
unlock_interactive and cmd_init so tests don't need a TTY.

Also fixes read_params in session.rs to correctly parse the nested
params.json format (kdf sub-object) rather than trying to deserialize
the whole file as KdfParams.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-04-20 18:32:45 -04:00
parent 494eedbbb8
commit b263c27da9
6 changed files with 741 additions and 42 deletions

View File

@@ -0,0 +1,136 @@
mod common;
use assert_cmd::cargo::CommandCargoExt as _;
use common::TestVault;
#[test]
fn init_creates_expected_layout() {
let v = TestVault::init();
assert!(v.path().join(".relicario/salt").exists());
assert!(v.path().join(".relicario/params.json").exists());
assert!(v.path().join(".relicario/devices.json").exists());
assert!(v.path().join("manifest.enc").exists());
assert!(v.path().join("settings.enc").exists());
assert!(v.path().join("reference.jpg").exists());
assert!(v.path().join(".gitignore").exists());
assert!(v.path().join(".git").is_dir());
}
#[test]
fn init_params_json_is_format_v2() {
let v = TestVault::init();
let s = std::fs::read_to_string(v.path().join(".relicario/params.json")).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&s).unwrap();
assert_eq!(parsed["format_version"], 2);
assert_eq!(parsed["kdf"]["algorithm"], "argon2id-v0x13");
assert_eq!(parsed["aead"], "xchacha20poly1305");
}
#[test]
fn add_login_then_list_shows_it() {
let v = TestVault::init();
let out = v.run(&[
"add",
"login",
"--title",
"GitHub",
"--username",
"alice",
"--url",
"https://github.com",
"--password",
"hunter2",
]);
assert!(out.status.success(), "add failed: {:?}", out);
let out = v.run(&["list"]);
let stdout = String::from_utf8(out.stdout).unwrap();
assert!(stdout.contains("GitHub"), "list missing GitHub: {stdout}");
}
#[test]
fn get_masks_by_default_shows_with_flag() {
let v = TestVault::init();
v.run(&[
"add",
"login",
"--title",
"gmail",
"--username",
"u",
"--password",
"super-secret",
]);
let masked = v.run(&["get", "gmail"]);
let stdout = String::from_utf8(masked.stdout).unwrap();
assert!(stdout.contains("********"), "expected masked: {stdout}");
assert!(
!stdout.contains("super-secret"),
"leaked plaintext: {stdout}"
);
let shown = v.run(&["get", "gmail", "--show"]);
let stdout = String::from_utf8(shown.stdout).unwrap();
assert!(stdout.contains("super-secret"), "expected plaintext: {stdout}");
}
#[test]
fn rm_restore_purge_cycle() {
let v = TestVault::init();
v.run(&[
"add",
"login",
"--title",
"target",
"--username",
"u",
"--password",
"p",
]);
let rm = v.run(&["rm", "target"]);
assert!(rm.status.success());
let out = v.run(&["list"]);
assert!(!String::from_utf8(out.stdout).unwrap().contains("target"));
let out = v.run(&["list", "--trashed"]);
assert!(String::from_utf8(out.stdout).unwrap().contains("target"));
let restore = v.run(&["restore", "target"]);
assert!(restore.status.success());
let out = v.run(&["list"]);
assert!(String::from_utf8(out.stdout).unwrap().contains("target"));
let purge = v.run(&["purge", "target"]);
assert!(purge.status.success());
let out = v.run(&["list"]);
assert!(!String::from_utf8(out.stdout).unwrap().contains("target"));
}
#[test]
fn generate_random_and_bip39() {
let dir = tempfile::TempDir::new().unwrap();
let out = std::process::Command::cargo_bin("relicario")
.unwrap()
.current_dir(dir.path())
.args(["generate", "--length", "32"])
.output()
.unwrap();
assert!(out.status.success());
assert_eq!(
String::from_utf8(out.stdout).unwrap().trim().len(),
32
);
let out = std::process::Command::cargo_bin("relicario")
.unwrap()
.current_dir(dir.path())
.args(["generate", "--bip39", "--words", "5"])
.output()
.unwrap();
assert!(out.status.success());
let phrase = String::from_utf8(out.stdout).unwrap();
assert_eq!(phrase.trim().split(' ').count(), 5);
}

View File

@@ -0,0 +1,117 @@
//! Shared helpers for CLI integration tests.
//!
//! `TestVault::init()` spins up a fresh vault in a `TempDir` using
//! `RELICARIO_TEST_PASSPHRASE` as the escape hatch (bypasses TTY prompts).
//! Every `run()` / `run_with_input()` call sets both `RELICARIO_IMAGE` and
//! `RELICARIO_TEST_PASSPHRASE`, so vault-mutating commands unlock without
//! interactive input.
//!
//! Note for Task 23 implementers: commands that prompt for a *new item
//! password* (i.e. `edit` when changing a Login password) also use
//! `rpassword`. Plumb `RELICARIO_TEST_ITEM_PASSWORD` through `cmd_edit` in
//! main.rs, or use an item type / edit path that avoids the rpassword call.
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use assert_cmd::cargo::CommandCargoExt;
use tempfile::TempDir;
pub struct TestVault {
pub dir: TempDir,
pub reference_image: PathBuf,
pub passphrase: String,
}
impl TestVault {
pub fn init() -> Self {
let dir = TempDir::new().expect("tempdir");
let carrier = make_test_jpeg(400, 300);
let carrier_path = dir.path().join("carrier.jpg");
std::fs::write(&carrier_path, &carrier).unwrap();
let passphrase = "correct horse battery staple 2026".to_string();
let ref_path = dir.path().join("reference.jpg");
let mut cmd = Command::cargo_bin("relicario").unwrap();
cmd.current_dir(dir.path())
.env("RELICARIO_TEST_PASSPHRASE", &passphrase)
.args([
"init",
"--image",
carrier_path.to_str().unwrap(),
"--output",
ref_path.to_str().unwrap(),
])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let out = cmd.output().unwrap();
assert!(
out.status.success(),
"init failed:\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
Self {
dir,
reference_image: ref_path,
passphrase,
}
}
pub fn path(&self) -> &Path {
self.dir.path()
}
pub fn run(&self, args: &[&str]) -> std::process::Output {
let mut cmd = Command::cargo_bin("relicario").unwrap();
cmd.current_dir(self.dir.path())
.env("RELICARIO_IMAGE", &self.reference_image)
.env("RELICARIO_TEST_PASSPHRASE", &self.passphrase)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
cmd.output().unwrap()
}
pub fn run_with_input(&self, args: &[&str], extra: &[&str]) -> std::process::Output {
let mut cmd = Command::cargo_bin("relicario").unwrap();
cmd.current_dir(self.dir.path())
.env("RELICARIO_IMAGE", &self.reference_image)
.env("RELICARIO_TEST_PASSPHRASE", &self.passphrase)
.args(args)
.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 extra {
writeln!(stdin, "{line}").unwrap();
}
}
child.wait_with_output().unwrap()
}
}
pub fn make_test_jpeg(w: u32, h: u32) -> Vec<u8> {
use image::codecs::jpeg::JpegEncoder;
use image::{ExtendedColorType, ImageBuffer, ImageEncoder, Rgb};
let img = ImageBuffer::from_fn(w, h, |x, y| {
Rgb([
((x * 7 + y * 13) % 256) as u8,
((x * 11 + y * 3) % 256) as u8,
((x * 5 + y * 17) % 256) as u8,
])
});
let mut out = Vec::new();
JpegEncoder::new_with_quality(&mut out, 92)
.write_image(img.as_raw(), w, h, ExtendedColorType::Rgb8)
.unwrap();
out
}