mod common; use assert_cmd::Command; use predicates::prelude::PredicateBooleanExt; use predicates::str::contains; #[test] fn completions_bash_emits_script() { Command::cargo_bin("relicario").unwrap() .args(["completions", "bash"]) .assert() .success() .stdout(contains("_relicario")) .stdout(contains("complete -F")); } #[test] fn completions_zsh_emits_script() { Command::cargo_bin("relicario").unwrap() .args(["completions", "zsh"]) .assert() .success() .stdout(contains("#compdef relicario")); } #[test] fn completions_fish_emits_script() { Command::cargo_bin("relicario").unwrap() .args(["completions", "fish"]) .assert() .success() .stdout(contains("complete -c relicario")); } #[test] fn list_command_refreshes_groups_cache() { let v = common::TestVault::init(); let out = v.run(&[ "add", "login", "--title", "T", "--username", "u", "--group", "work", "--password", "hunter2", ]); assert!(out.status.success(), "add failed: {:?}", out); let out = v.run(&["list"]); assert!(out.status.success(), "list failed: {:?}", out); let cache_path = v.path().join(".relicario/groups.cache"); let cache = std::fs::read_to_string(&cache_path) .unwrap_or_else(|e| panic!("groups.cache not found at {}: {e}", cache_path.display())); assert!( cache.lines().any(|l| l == "work"), "expected 'work' in groups.cache, got: {cache:?}" ); } #[test] fn no_groups_cache_env_var_suppresses_write() { use std::process::{Command as StdCommand, Stdio}; use assert_cmd::cargo::CommandCargoExt as _; let v = common::TestVault::init(); // Add with the env var set so no cache is created by add either. let out = StdCommand::cargo_bin("relicario").unwrap() .current_dir(v.path()) .env("RELICARIO_IMAGE", &v.reference_image) .env("RELICARIO_TEST_PASSPHRASE", &v.passphrase) .env("RELICARIO_NO_GROUPS_CACHE", "1") .args([ "add", "login", "--title", "T2", "--username", "u", "--group", "personal", "--password", "hunter2", ]) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .output() .unwrap(); assert!(out.status.success(), "add failed: {:?}", out); // Run list with RELICARIO_NO_GROUPS_CACHE=1 — cache must NOT be written. let out = StdCommand::cargo_bin("relicario").unwrap() .current_dir(v.path()) .env("RELICARIO_IMAGE", &v.reference_image) .env("RELICARIO_TEST_PASSPHRASE", &v.passphrase) .env("RELICARIO_NO_GROUPS_CACHE", "1") .args(["list"]) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .output() .unwrap(); assert!(out.status.success(), "list failed: {:?}", out); let cache_path = v.path().join(".relicario/groups.cache"); assert!( !cache_path.exists(), "groups.cache should not exist when RELICARIO_NO_GROUPS_CACHE=1" ); } #[test] fn rate_strong_passphrase_prints_score_and_guesses() { Command::cargo_bin("relicario").unwrap() .args(["rate", "correct horse battery staple table cocoa rocket spirit ferment"]) .assert() .success() .stdout(contains("score:")) .stdout(contains("guesses:")) .stdout(contains("strong")); } #[test] fn rate_weak_passphrase_exits_zero_with_weak_label() { // `rate` is informational — does NOT exit nonzero on weak input. // The hard gate lives at `init` (Plan 2B Task 10). Command::cargo_bin("relicario").unwrap() .args(["rate", "password"]) .assert() .success() .stdout(contains("very weak").or(contains("weak"))); } #[test] fn rate_reads_from_stdin_when_arg_is_dash() { Command::cargo_bin("relicario").unwrap() .args(["rate", "-"]) .write_stdin("correcthorsebatterystaple\n") .assert() .success() .stdout(contains("score:")); } fn make_test_qr(uri: &str, dest: &std::path::Path) { use image::{ImageBuffer, Luma}; let code = qrcode::QrCode::new(uri).expect("QR encode failed"); let img: ImageBuffer, Vec> = code .render::>() .module_dimensions(8, 8) .build(); img.save(dest).expect("save QR PNG"); } #[test] fn add_login_totp_qr_decodes_otpauth_uri() { use tempfile::TempDir; let tmp = TempDir::new().unwrap(); let qr_path = tmp.path().join("test.png"); make_test_qr( "otpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example", &qr_path, ); let v = common::TestVault::init(); let out = v.run(&[ "add", "login", "--title", "TotpTest", "--password", "hunter2", "--totp-qr", qr_path.to_str().unwrap(), ]); assert!(out.status.success(), "add failed:\nstdout: {}\nstderr: {}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr)); let out = v.run(&["get", "TotpTest", "--show"]); assert!(out.status.success(), "get failed:\nstdout: {}\nstderr: {}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr)); let stdout = String::from_utf8_lossy(&out.stdout); // BASE32.encode(BASE32.decode("JBSWY3DPEHPK3PXP")) should round-trip. // The secret bytes from JBSWY3DPEHPK3PXP decode to specific bytes, // then re-encode to JBSWY3DPEHPK3PXP====; we check for the core chars. assert!( stdout.contains("JBSWY3DPEHPK3PXP"), "expected TOTP secret in get output, got:\n{stdout}" ); } #[test] fn add_login_totp_qr_errors_on_non_otpauth_qr() { use tempfile::TempDir; let tmp = TempDir::new().unwrap(); let qr_path = tmp.path().join("nottotp.png"); make_test_qr("https://example.com", &qr_path); let v = common::TestVault::init(); let out = v.run(&[ "add", "login", "--title", "BadQR", "--password", "hunter2", "--totp-qr", qr_path.to_str().unwrap(), ]); assert!( !out.status.success(), "expected nonzero exit for non-otpauth QR, but command succeeded" ); let stderr = String::from_utf8_lossy(&out.stderr); assert!( stderr.contains("not a TOTP URI"), "expected 'not a TOTP URI' in stderr, got:\n{stderr}" ); }