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:
136
crates/relicario-cli/tests/basic_flows.rs
Normal file
136
crates/relicario-cli/tests/basic_flows.rs
Normal 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);
|
||||
}
|
||||
117
crates/relicario-cli/tests/common/mod.rs
Normal file
117
crates/relicario-cli/tests/common/mod.rs
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user