Merge feature/fullscreen-ux-phase-2a: smart-input affordances
Phase 2A of the fullscreen UX redesign — 8 form-level smart-input affordances (URL fill-from-tab + hostname chip, group autocomplete, password reveal + strength bar, TOTP live preview + QR decode, notes monospace toggle), shared between popup and fullscreen vault tabs via the new extension/src/shared/form-affordances/ module set. CLI parity: - relicario rate <passphrase> (zxcvbn score / guess estimate) - relicario completions <SHELL> (bash/zsh/fish via clap_complete) - --group <TAB> dynamic enumeration via .relicario/groups.cache (plaintext leak surface; opt out with RELICARIO_NO_GROUPS_CACHE=1) - --totp-qr <path> on add login + edit (rqrr decode) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
210
crates/relicario-cli/tests/smart_inputs.rs
Normal file
210
crates/relicario-cli/tests/smart_inputs.rs
Normal file
@@ -0,0 +1,210 @@
|
||||
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<Luma<u8>, Vec<u8>> = code
|
||||
.render::<Luma<u8>>()
|
||||
.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}"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user