use assert_cmd::Command;
use predicates::prelude::PredicateBooleanExt;
use predicates::str::contains;
use std::path::{Path, PathBuf};
const MASTER: &str = "master password";
struct Fixture {
_directory: tempfile::TempDir,
path: PathBuf,
}
impl Fixture {
fn empty() -> Self {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("notes.bak");
Self {
_directory: directory,
path,
}
}
fn with_vault() -> Self {
let fixture = Self::empty();
fixture.sefy().arg("init").assert().success();
fixture
}
fn sefy(&self) -> Command {
let mut command = Command::cargo_bin("sefy").unwrap();
command
.env("SEFY_VAULT", &self.path)
.env("SEFY_TEST_PASSWORD", MASTER)
.env_remove("SEFY_AUTO_SYNC")
.arg("--password-env")
.arg("SEFY_TEST_PASSWORD");
command
}
fn directory(&self) -> &Path {
self.path.parent().unwrap()
}
}
fn add_note(fixture: &Fixture, title: &str, text: &str, tags: &[&str]) {
let mut command = fixture.sefy();
command.args(["add", "note", title, "--text", text]);
if !tags.is_empty() {
command.arg("--tag").arg(tags.join(","));
}
command.assert().success();
}
#[test]
fn init_creates_a_vault_and_refuses_to_overwrite_one() {
let fixture = Fixture::empty();
fixture
.sefy()
.arg("init")
.assert()
.success()
.stdout(contains("created"));
assert!(fixture.path.exists());
fixture
.sefy()
.arg("init")
.assert()
.failure()
.stderr(contains("already exists"));
}
#[test]
fn without_a_vault_path_the_error_explains_the_options() {
let mut command = Command::cargo_bin("sefy").unwrap();
command
.env_remove("SEFY_VAULT")
.arg("ls")
.assert()
.failure()
.stderr(contains("--vault"))
.stderr(contains("SEFY_VAULT"));
}
#[test]
fn a_wrong_password_is_reported_without_guessing_why() {
let fixture = Fixture::with_vault();
Command::cargo_bin("sefy")
.unwrap()
.env("SEFY_VAULT", &fixture.path)
.env("WRONG", "not the password")
.args(["--password-env", "WRONG", "ls"])
.assert()
.failure()
.stderr(contains("wrong password"));
}
#[test]
fn a_note_survives_a_round_trip_through_the_file() {
let fixture = Fixture::with_vault();
add_note(&fixture, "bank", "vault code 4815", &["money", "home"]);
fixture
.sefy()
.args(["get", "bank", "--stdout"])
.assert()
.success()
.stdout(contains("vault code 4815"));
fixture
.sefy()
.args(["show", "bank"])
.assert()
.success()
.stdout(contains("home, money"));
}
#[test]
fn a_credential_stores_its_password_and_hides_it_in_show() {
let fixture = Fixture::with_vault();
fixture
.sefy()
.env("ITEM_PASSWORD", "hunter2")
.args([
"add",
"login",
"mail",
"--login",
"someone",
"--url",
"https://example.invalid",
"--item-password-env",
"ITEM_PASSWORD",
])
.assert()
.success();
fixture
.sefy()
.args(["get", "mail", "--stdout"])
.assert()
.success()
.stdout(contains("hunter2"));
fixture
.sefy()
.args(["get", "mail", "--field", "login", "--stdout"])
.assert()
.success()
.stdout(contains("someone"));
fixture
.sefy()
.args(["show", "mail"])
.assert()
.success()
.stdout(contains("someone"))
.stdout(contains("https://example.invalid"))
.stdout(contains("hunter2").not());
}
#[test]
fn the_item_password_never_falls_back_to_the_master_password() {
let fixture = Fixture::with_vault();
fixture
.sefy()
.args(["add", "login", "mail", "--login", "someone"])
.write_stdin("")
.assert()
.failure()
.stderr(contains("not attached to a terminal"));
}
#[test]
fn a_file_comes_back_byte_for_byte() {
let fixture = Fixture::with_vault();
let source = fixture.directory().join("keyfile.bin");
let bytes: Vec<u8> = (0..=255u8).cycle().take(100_000).collect();
std::fs::write(&source, &bytes).unwrap();
fixture
.sefy()
.args(["add", "file"])
.arg(&source)
.assert()
.success();
let restored = fixture.directory().join("restored.bin");
fixture
.sefy()
.args(["extract", "keyfile"])
.arg("-o")
.arg(&restored)
.assert()
.success();
assert_eq!(std::fs::read(&restored).unwrap(), bytes);
fixture
.sefy()
.args(["extract", "keyfile"])
.arg("-o")
.arg(&restored)
.assert()
.failure()
.stderr(contains("--force"));
}
#[test]
fn getting_a_file_points_at_extract_instead() {
let fixture = Fixture::with_vault();
let source = fixture.directory().join("keyfile.bin");
std::fs::write(&source, b"bytes").unwrap();
fixture
.sefy()
.args(["add", "file"])
.arg(&source)
.assert()
.success();
fixture
.sefy()
.args(["get", "keyfile"])
.assert()
.failure()
.stderr(contains("sefy extract"));
}
#[test]
fn an_ambiguous_reference_lists_the_candidates() {
let fixture = Fixture::with_vault();
add_note(&fixture, "mail — personal", "a", &[]);
add_note(&fixture, "mail — work", "b", &[]);
fixture
.sefy()
.args(["get", "mail"])
.assert()
.failure()
.stderr(contains("2 items match"))
.stderr(contains("mail — personal"))
.stderr(contains("mail — work"))
.stderr(contains("use an id"));
}
#[test]
fn an_exact_title_beats_a_substring() {
let fixture = Fixture::with_vault();
add_note(&fixture, "mail", "exact one", &[]);
add_note(&fixture, "mailing list", "the other", &[]);
fixture
.sefy()
.args(["get", "mail", "--stdout"])
.assert()
.success()
.stdout(contains("exact one"));
}
#[test]
fn a_reference_matching_nothing_says_so() {
let fixture = Fixture::with_vault();
add_note(&fixture, "bank", "text", &[]);
fixture
.sefy()
.args(["get", "nowhere"])
.assert()
.failure()
.stderr(contains("nothing matches"));
}
#[test]
fn ls_and_find_narrow_by_kind_and_tag() {
let fixture = Fixture::with_vault();
add_note(&fixture, "bank pin", "4815", &["money"]);
add_note(&fixture, "grocery list", "milk", &["home"]);
fixture
.sefy()
.args(["ls"])
.assert()
.success()
.stdout(contains("bank pin"))
.stdout(contains("grocery list"));
fixture
.sefy()
.args(["ls", "--tag", "money"])
.assert()
.success()
.stdout(contains("bank pin"))
.stdout(contains("grocery list").not());
fixture
.sefy()
.args(["find", "milk"])
.assert()
.success()
.stdout(contains("grocery list"))
.stdout(contains("bank pin").not());
fixture
.sefy()
.args(["find", "bank", "--kind", "login"])
.assert()
.success()
.stdout(contains("no items"));
}
#[test]
fn edit_changes_what_it_is_asked_to_and_nothing_else() {
let fixture = Fixture::with_vault();
add_note(&fixture, "old title", "the text", &["stale"]);
fixture
.sefy()
.args([
"edit",
"old title",
"--title",
"new title",
"--tag",
"fresh",
])
.assert()
.success();
fixture
.sefy()
.args(["get", "new title", "--stdout"])
.assert()
.success()
.stdout(contains("the text"));
fixture
.sefy()
.args(["tags"])
.assert()
.success()
.stdout(contains("fresh"))
.stdout(contains("stale").not());
}
#[test]
fn edit_rejects_flags_meant_for_another_kind() {
let fixture = Fixture::with_vault();
add_note(&fixture, "a note", "text", &[]);
fixture
.sefy()
.args(["edit", "a note", "--set", "login=someone"])
.assert()
.failure()
.stderr(contains("note"));
fixture
.sefy()
.args(["edit", "a note"])
.assert()
.failure()
.stderr(contains("nothing to change"));
}
#[test]
fn rm_takes_the_item_out_of_the_file() {
let fixture = Fixture::with_vault();
add_note(&fixture, "temporary", "text", &[]);
fixture
.sefy()
.args(["rm", "temporary", "-y"])
.assert()
.success()
.stdout(contains("removed"));
fixture
.sefy()
.args(["ls"])
.assert()
.success()
.stdout(contains("no items"));
}
#[test]
fn rm_without_a_terminal_refuses_rather_than_assuming_yes() {
let fixture = Fixture::with_vault();
add_note(&fixture, "precious", "text", &[]);
fixture
.sefy()
.args(["rm", "precious"])
.write_stdin("")
.assert()
.failure()
.stderr(contains("--yes"));
fixture
.sefy()
.args(["get", "precious", "--stdout"])
.assert()
.success()
.stdout(contains("text"));
}
#[test]
fn wipe_without_a_terminal_refuses_even_given_the_name() {
let fixture = Fixture::with_vault();
add_note(&fixture, "precious", "text", &[]);
sefy_core::copies::keep(&fixture.path).unwrap();
let vault = std::fs::read(&fixture.path).unwrap();
let copy = sefy_core::copies::path(&fixture.path, 1);
fixture
.sefy()
.arg("wipe")
.write_stdin("notes.bak\n")
.assert()
.failure()
.stderr(contains("on a terminal, and input is not one"))
.stderr(contains("there is no --yes"));
assert_eq!(std::fs::read(&fixture.path).unwrap(), vault);
assert!(copy.exists());
}
#[test]
fn wipe_refuses_before_it_asks_for_the_password() {
let fixture = Fixture::with_vault();
fixture
.sefy()
.env("SEFY_TEST_PASSWORD", "not the password")
.arg("wipe")
.assert()
.failure()
.stderr(contains("there is no --yes"))
.stderr(contains("wrong password").not());
assert!(fixture.path.exists());
}
#[test]
fn wipe_has_no_way_to_say_yes_in_advance() {
let fixture = Fixture::with_vault();
for yes in ["--yes", "-y", "--force"] {
fixture
.sefy()
.args(["wipe", yes])
.assert()
.code(2)
.stderr(contains("unexpected argument"));
}
assert!(fixture.path.exists());
}
#[test]
fn change_password_takes_a_separate_variable_for_the_new_one() {
let fixture = Fixture::with_vault();
add_note(&fixture, "kept", "text", &[]);
fixture
.sefy()
.env("NEW_PASSWORD", "a different password")
.args(["change-password", "--new-password-env", "NEW_PASSWORD"])
.assert()
.success();
fixture
.sefy()
.arg("ls")
.assert()
.failure()
.stderr(contains("wrong password"));
Command::cargo_bin("sefy")
.unwrap()
.env("SEFY_VAULT", &fixture.path)
.env("NEW_PASSWORD", "a different password")
.args(["--password-env", "NEW_PASSWORD", "get", "kept", "--stdout"])
.assert()
.success()
.stdout(contains("text"));
}
#[test]
fn secrets_do_not_appear_in_the_file_on_disk() {
let fixture = Fixture::with_vault();
add_note(
&fixture,
"thing",
"xyzzy-plugh-secret",
&["a-distinctive-tag"],
);
let bytes = std::fs::read(&fixture.path).unwrap();
for needle in ["xyzzy-plugh-secret", "a-distinctive-tag", "SQLite format 3"] {
assert!(
!bytes
.windows(needle.len())
.any(|window| window == needle.as_bytes()),
"{needle:?} leaked into the vault file"
);
}
}
#[test]
fn working_with_a_vault_leaves_no_other_files_behind() {
let fixture = Fixture::with_vault();
add_note(&fixture, "one", "first", &[]);
add_note(&fixture, "two", "second", &[]);
fixture.sefy().args(["rm", "one", "-y"]).assert().success();
let entries: Vec<_> = std::fs::read_dir(fixture.directory())
.unwrap()
.map(|entry| entry.unwrap().file_name())
.collect();
assert_eq!(entries, vec![fixture.path.file_name().unwrap().to_owned()]);
}
#[test]
fn export_refuses_until_the_plaintext_warning_is_acknowledged() {
let fixture = Fixture::with_vault();
add_note(&fixture, "bank", "code 4815", &[]);
fixture
.sefy()
.arg("export")
.assert()
.failure()
.stderr(contains("in the clear"))
.stderr(contains("--i-know-this-writes-plaintext"));
fixture
.sefy()
.args(["export", "--i-know-this-writes-plaintext"])
.assert()
.success()
.stdout(contains("sefy_export"))
.stdout(contains("code 4815"));
}
#[test]
fn an_export_survives_a_round_trip_through_the_command_line() {
let source = Fixture::with_vault();
add_note(&source, "bank", "code 4815", &["money"]);
source
.sefy()
.env("ITEM_PASSWORD", "hunter2")
.args([
"add",
"login",
"mail",
"--login",
"someone",
"--item-password-env",
"ITEM_PASSWORD",
])
.assert()
.success();
let dump = source.directory().join("dump.json");
source
.sefy()
.args(["export", "--i-know-this-writes-plaintext", "-o"])
.arg(&dump)
.assert()
.success();
let destination = Fixture::with_vault();
destination
.sefy()
.arg("import")
.arg(&dump)
.assert()
.success()
.stdout(contains("imported 2 items"));
destination
.sefy()
.args(["get", "bank", "--stdout"])
.assert()
.success()
.stdout(contains("code 4815"));
destination
.sefy()
.args(["get", "mail", "--stdout"])
.assert()
.success()
.stdout(contains("hunter2"));
}
#[test]
fn export_does_not_overwrite_a_file_without_force() {
let fixture = Fixture::with_vault();
add_note(&fixture, "bank", "code", &[]);
let dump = fixture.directory().join("dump.json");
std::fs::write(&dump, b"existing").unwrap();
fixture
.sefy()
.args(["export", "--i-know-this-writes-plaintext", "-o"])
.arg(&dump)
.assert()
.failure()
.stderr(contains("--force"));
assert_eq!(std::fs::read(&dump).unwrap(), b"existing");
}
#[test]
fn import_reads_stdin_and_reports_malformed_input() {
let fixture = Fixture::with_vault();
fixture
.sefy()
.arg("import")
.write_stdin(r#"{"sefy_export":1,"items":[{"title":"x","kind":"note","text":"y"}]}"#)
.assert()
.success()
.stdout(contains("imported 1 item"));
fixture
.sefy()
.arg("import")
.write_stdin(r#"{"sefy_export":1,"items":[{"title":"x","kind":"note"}]}"#)
.assert()
.failure()
.stderr(contains("malformed"));
fixture
.sefy()
.arg("import")
.write_stdin("not json")
.assert()
.failure()
.stderr(contains("not a file sefy can import"));
}
#[test]
fn a_keepass_export_imports_back_with_its_format_named() {
let source = Fixture::with_vault();
add_note(&source, "bank", "code 4815", &["money"]);
add_full_login(&source, "mail");
let file = source.directory().join("export.xml");
source
.sefy()
.args([
"export",
"--format",
"keepass",
"--i-know-this-writes-plaintext",
"-o",
])
.arg(&file)
.assert()
.success()
.stderr(contains("in the clear: 2 items"));
assert!(
std::fs::read_to_string(&file)
.unwrap()
.contains("<KeePassFile>")
);
let target = Fixture::with_vault();
target
.sefy()
.arg("import")
.arg(&file)
.assert()
.success()
.stdout(contains(
"imported 2 items from a KeePass XML export: 1 note, 1 login",
))
.stdout(contains("still holds all of it in the clear"));
target
.sefy()
.args(["get", "bank", "--stdout"])
.assert()
.success()
.stdout(contains("code 4815"));
}
#[test]
fn a_csv_export_says_what_it_left_out_and_refuses_history() {
let fixture = Fixture::with_vault();
add_note(&fixture, "bank", "code 4815", &[]);
add_full_login(&fixture, "mail");
fixture
.sefy()
.args(["export", "--format", "csv", "--with-history"])
.arg("--i-know-this-writes-plaintext")
.assert()
.failure()
.stderr(contains("a CSV has no place for earlier versions"));
fixture
.sefy()
.args([
"export",
"--format",
"csv",
"--i-know-this-writes-plaintext",
])
.assert()
.success()
.stdout(contains("name,url,username,password,note,totp"))
.stdout(contains("code 4815").not())
.stderr(contains("left out: 1 note"));
}
#[test]
fn an_import_names_every_entry_it_could_not_bring_as_it_was() {
let fixture = Fixture::with_vault();
let csv = "name,url,username,password,httpRealm
mail,https://mail.example.invalid,someone,pw,
only a name,,,,
";
fixture
.sefy()
.arg("import")
.write_stdin(csv)
.assert()
.success()
.stdout(contains("imported 1 item from a CSV of passwords: 1 login"))
.stdout(contains(
"not imported:
\"only a name\" - row 3: nothing in it but a name",
))
.stdout(contains(
"columns left out as the exporting program's own bookkeeping: httpRealm",
))
.stdout(contains("still holds").not());
fixture
.sefy()
.arg("import")
.write_stdin(vec![0xff, 0xfe, b'n', 0])
.assert()
.failure()
.stderr(contains("not UTF-8"));
}
#[test]
fn get_clears_the_clipboard_after_the_timeout() {
let fixture = Fixture::with_vault();
add_note(&fixture, "bank", "code 4815", &[]);
let assertion = fixture
.sefy()
.args(["get", "bank", "--clear-after", "1"])
.assert();
let output = assertion.get_output();
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stdout.contains("clearing in 1s") || stderr.contains("cannot reach the clipboard"),
"unexpected output:\nstdout: {stdout}\nstderr: {stderr}"
);
}
#[test]
fn the_editor_is_refused_when_there_is_nowhere_to_open_one() {
let fixture = Fixture::with_vault();
fixture
.sefy()
.env("EDITOR", "vi")
.args(["add", "note", "x", "--editor"])
.write_stdin("")
.assert()
.failure()
.stderr(contains("not a terminal"));
fixture
.sefy()
.env_remove("EDITOR")
.env_remove("VISUAL")
.args(["add", "note", "x", "--editor"])
.write_stdin("")
.assert()
.failure()
.stderr(contains("no editor configured").or(contains("not a terminal")));
}
#[test]
fn the_editor_flag_is_refused_on_items_that_are_not_notes() {
let fixture = Fixture::with_vault();
fixture
.sefy()
.env("ITEM_PASSWORD", "hunter2")
.args([
"add",
"login",
"mail",
"--login",
"someone",
"--item-password-env",
"ITEM_PASSWORD",
])
.assert()
.success();
fixture
.sefy()
.env("EDITOR", "vi")
.args(["edit", "mail", "--editor"])
.assert()
.failure()
.stderr(contains("login"));
}
#[test]
fn completions_are_generated_for_every_supported_shell() {
for shell in ["bash", "zsh", "fish", "powershell", "elvish"] {
Command::cargo_bin("sefy")
.unwrap()
.args(["completions", shell])
.assert()
.success()
.stdout(contains("sefy"));
}
}
#[test]
fn completions_and_help_need_no_vault_and_no_password() {
for arguments in [
vec!["--help"],
vec!["--version"],
vec!["completions", "bash"],
vec!["plugin", "list"],
] {
Command::cargo_bin("sefy")
.unwrap()
.env_remove("SEFY_VAULT")
.args(&arguments)
.assert()
.success();
}
}
fn plugin_directory(manifest: &str) -> tempfile::TempDir {
let directory = tempfile::tempdir().unwrap();
#[cfg(windows)]
{
let manifest_path = directory.path().join("manifest.json");
std::fs::write(&manifest_path, manifest).unwrap();
std::fs::write(
directory.path().join("sefy-plugin-demo.cmd"),
format!(
"@echo off\r\nif \"%1\"==\"--manifest\" (type \"{}\") else (echo {{}})\r\n",
manifest_path.display()
),
)
.unwrap();
}
#[cfg(not(windows))]
{
use std::os::unix::fs::PermissionsExt;
let path = directory.path().join("sefy-plugin-demo");
std::fs::write(
&path,
format!(
"#!/bin/sh\nif [ \"$1\" = \"--manifest\" ]; then\n printf '%s' '{manifest}'\nelse\n printf '%s' '{{}}'\nfi\n"
),
)
.unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
}
directory
}
fn sefy_seeing_only(directory: &Path) -> Command {
let mut command = Command::cargo_bin("sefy").unwrap();
command
.env_remove("SEFY_VAULT")
.env("PATH", directory)
.env("APPDATA", directory)
.env("XDG_DATA_HOME", directory)
.env("HOME", directory);
command
}
#[test]
fn an_installed_plugin_is_listed_with_what_it_can_do() {
let directory = plugin_directory(
r#"{"protocol_version":1,"name":"demo","version":"1.2.3","operations":["push","pull"]}"#,
);
sefy_seeing_only(directory.path())
.args(["plugin", "list"])
.assert()
.success()
.stdout(
contains("demo")
.and(contains("1.2.3"))
.and(contains("push")),
);
}
#[test]
fn a_plugin_speaking_another_protocol_is_listed_with_the_reason() {
let directory = plugin_directory(
r#"{"protocol_version":99,"name":"demo","version":"1.2.3","operations":["push"]}"#,
);
sefy_seeing_only(directory.path())
.args(["plugin", "list"])
.assert()
.success()
.stdout(
contains("demo")
.and(contains("unusable"))
.and(contains("99")),
);
}
#[test]
fn nothing_installed_says_how_a_plugin_is_installed() {
let directory = tempfile::tempdir().unwrap();
sefy_seeing_only(directory.path())
.args(["plugin", "list"])
.assert()
.success()
.stdout(contains("no plugins installed").and(contains("sefy-plugin-")));
}
fn plugins_inside(data: &Path) -> PathBuf {
#[cfg(target_os = "macos")]
let base = data.join("Library").join("Application Support");
#[cfg(not(target_os = "macos"))]
let base = data.to_path_buf();
let directory = base.join("sefy").join("plugins");
std::fs::create_dir_all(&directory).unwrap();
directory
}
fn transport_directory(remote: &Path) -> tempfile::TempDir {
let data = tempfile::tempdir().unwrap();
let directory = plugins_inside(data.path());
let manifest =
r#"{"protocol_version":1,"name":"file","version":"0.1.0","operations":["push","pull"]}"#;
let remote = remote.display().to_string();
let seen = directory.join("seen-name.txt").display().to_string();
#[cfg(windows)]
{
let manifest_path = directory.join("manifest.json");
std::fs::write(&manifest_path, manifest).unwrap();
let script = directory.join("transport.ps1");
std::fs::write(
&script,
format!(
"$request = [Console]::In.ReadToEnd() | ConvertFrom-Json\r\n\
Set-Content -LiteralPath '{seen}' -Value $request.name -NoNewline\r\n\
if ($request.operation -eq 'push') {{ Copy-Item -LiteralPath $request.file -Destination '{remote}' -Force }}\r\n\
else {{ Copy-Item -LiteralPath '{remote}' -Destination $request.file -Force }}\r\n"
),
)
.unwrap();
let shell = PathBuf::from(std::env::var_os("SystemRoot").unwrap())
.join("System32")
.join("WindowsPowerShell")
.join("v1.0")
.join("powershell.exe");
std::fs::write(
directory.join("sefy-plugin-file.cmd"),
format!(
"@echo off\r\n\
if \"%1\"==\"--manifest\" (type \"{manifest}\" & exit /b 0)\r\n\
\"{shell}\" -NoProfile -ExecutionPolicy Bypass -File \"{script}\"\r\n",
manifest = manifest_path.display(),
script = script.display(),
shell = shell.display(),
),
)
.unwrap();
}
#[cfg(not(windows))]
{
use std::os::unix::fs::PermissionsExt;
let path = directory.join("sefy-plugin-file");
std::fs::write(
&path,
format!(
r#"#!/bin/sh
if [ "$1" = "--manifest" ]; then
printf '%s' '{manifest}'
exit 0
fi
REQUEST=$(cat)
FILE=$(printf '%s' "$REQUEST" | sed 's/.*"file":"//; s/".*//')
printf '%s' "$REQUEST" | sed 's/.*"name":"//; s/".*//' > '{seen}'
case "$REQUEST" in
*'"operation":"push"'*) cp "$FILE" '{remote}' ;;
*) cp '{remote}' "$FILE" ;;
esac
"#
),
)
.unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
}
data
}
fn name_the_transport_saw(data: &Path) -> String {
std::fs::read_to_string(plugins_inside(data).join("seen-name.txt"))
.expect("the transport must have been called")
.trim()
.to_owned()
}
fn second_transport(data: &Path) {
let directory = plugins_inside(data);
let manifest =
r#"{"protocol_version":1,"name":"other","version":"0.1.0","operations":["push","pull"]}"#;
#[cfg(windows)]
{
let manifest_path = directory.join("other.json");
std::fs::write(&manifest_path, manifest).unwrap();
std::fs::write(
directory.join("sefy-plugin-other.cmd"),
format!(
"@echo off\r\nif \"%1\"==\"--manifest\" (type \"{}\")\r\n",
manifest_path.display()
),
)
.unwrap();
}
#[cfg(not(windows))]
{
use std::os::unix::fs::PermissionsExt;
let path = directory.join("sefy-plugin-other");
std::fs::write(
&path,
format!(
"#!/bin/sh\nif [ \"$1\" = \"--manifest\" ]; then\n printf '%s' '{manifest}'\nfi\n"
),
)
.unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
}
}
fn sefy_with_transport(fixture: &Fixture, transports: &Path) -> Command {
let mut command = fixture.sefy();
command
.env("PATH", system_path())
.env("APPDATA", transports)
.env("XDG_DATA_HOME", transports)
.env("HOME", transports)
.env_remove("SEFY_TRANSPORT")
.env_remove("SEFY_REMOTE_NAME");
command
}
fn system_path() -> std::ffi::OsString {
#[cfg(windows)]
{
let root = std::env::var("SystemRoot").unwrap_or_else(|_| r"C:\Windows".into());
std::ffi::OsString::from(format!(
r"{root}\System32;{root};{root}\System32\WindowsPowerShell\v1.0"
))
}
#[cfg(not(windows))]
{
std::ffi::OsString::from("/usr/bin:/bin:/usr/sbin:/sbin")
}
}
#[test]
fn a_push_sends_the_file_and_a_pull_brings_the_other_side_back() {
let here = Fixture::with_vault();
add_note(&here, "my note", "from here", &[]);
let there = Fixture::empty();
std::fs::copy(&here.path, &there.path).unwrap();
add_note(&there, "their note", "from over there", &[]);
let remote = here.directory().join("remote.bin");
let transports = transport_directory(&remote);
sefy_with_transport(&there, transports.path())
.arg("push")
.assert()
.success()
.stdout(contains("pushed"));
sefy_with_transport(&here, transports.path())
.arg("pull")
.assert()
.success()
.stdout(contains("1 added"));
sefy_with_transport(&here, transports.path())
.arg("ls")
.assert()
.success()
.stdout(contains("my note").and(contains("their note")));
}
#[test]
fn a_sync_leaves_both_sides_holding_everything() {
let here = Fixture::with_vault();
add_note(&here, "my note", "from here", &[]);
let there = Fixture::empty();
std::fs::copy(&here.path, &there.path).unwrap();
add_note(&there, "their note", "from over there", &[]);
let remote = here.directory().join("remote.bin");
let transports = transport_directory(&remote);
sefy_with_transport(&there, transports.path())
.arg("push")
.assert()
.success();
sefy_with_transport(&here, transports.path())
.arg("sync")
.assert()
.success()
.stdout(contains("1 added"));
sefy_with_transport(&there, transports.path())
.arg("pull")
.assert()
.success();
sefy_with_transport(&there, transports.path())
.arg("ls")
.assert()
.success()
.stdout(contains("my note").and(contains("their note")));
}
#[test]
fn what_reaches_the_remote_never_contains_a_secret_in_the_clear() {
let fixture = Fixture::with_vault();
add_note(&fixture, "bank", "SECRETVALUE", &[]);
let remote = fixture.directory().join("remote.bin");
let transports = transport_directory(&remote);
sefy_with_transport(&fixture, transports.path())
.arg("push")
.assert()
.success();
let carried = std::fs::read(&remote).unwrap();
assert!(
!carried
.windows(b"SECRETVALUE".len())
.any(|window| window == b"SECRETVALUE"),
"the remote copy holds ciphertext"
);
}
#[test]
fn with_no_transport_installed_the_error_says_how_to_install_one() {
let fixture = Fixture::with_vault();
let empty = tempfile::tempdir().unwrap();
sefy_with_transport(&fixture, empty.path())
.arg("push")
.assert()
.failure()
.stderr(contains("no usable transport").and(contains("sefy-plugin-")));
}
#[test]
fn with_several_installed_sefy_asks_which_rather_than_guessing() {
let fixture = Fixture::with_vault();
let remote = fixture.directory().join("remote.bin");
let transports = transport_directory(&remote);
second_transport(transports.path());
sefy_with_transport(&fixture, transports.path())
.arg("push")
.assert()
.failure()
.stderr(
contains("--transport")
.and(contains("file"))
.and(contains("other")),
);
sefy_with_transport(&fixture, transports.path())
.args(["push", "--transport", "file"])
.assert()
.success();
}
#[test]
fn a_transport_that_is_not_installed_is_named_in_the_error() {
let fixture = Fixture::with_vault();
let remote = fixture.directory().join("remote.bin");
let transports = transport_directory(&remote);
sefy_with_transport(&fixture, transports.path())
.args(["push", "--transport", "nowhere"])
.assert()
.failure()
.stderr(contains("nowhere").and(contains("plugin list")));
}
#[test]
fn the_remote_name_is_what_the_transport_is_told_to_call_it() {
let fixture = Fixture::with_vault();
let remote = fixture.directory().join("remote.bin");
let transports = transport_directory(&remote);
sefy_with_transport(&fixture, transports.path())
.args(["push", "--name", "work-laptop"])
.assert()
.success()
.stdout(contains("work-laptop"));
assert_eq!(name_the_transport_saw(transports.path()), "work-laptop");
sefy_with_transport(&fixture, transports.path())
.arg("push")
.assert()
.success();
assert_eq!(name_the_transport_saw(transports.path()), "vault");
}
#[test]
fn a_pull_under_a_different_remote_password_is_asked_for_separately() {
let here = Fixture::with_vault();
let there = Fixture::empty();
there
.sefy()
.env("SEFY_TEST_PASSWORD", "another password")
.arg("init")
.assert()
.success();
let mut add = there.sefy();
add.env("SEFY_TEST_PASSWORD", "another password")
.args(["add", "note", "their note", "--text", "from over there"])
.assert()
.success();
let remote = here.directory().join("remote.bin");
std::fs::copy(&there.path, &remote).unwrap();
let transports = transport_directory(&remote);
sefy_with_transport(&here, transports.path())
.arg("pull")
.assert()
.failure()
.stderr(contains("wrong password"));
sefy_with_transport(&here, transports.path())
.env("SEFY_REMOTE_PASSWORD", "another password")
.args(["pull", "--remote-password-env", "SEFY_REMOTE_PASSWORD"])
.assert()
.success()
.stdout(contains("1 added"));
}
fn drifted_with_remote() -> (Fixture, Fixture, PathBuf, tempfile::TempDir) {
let here = Fixture::with_vault();
add_note(&here, "shared", "both have it", &[]);
let there = Fixture::empty();
std::fs::copy(&here.path, &there.path).unwrap();
add_note(&here, "my note", "from here", &[]);
add_note(&there, "their note", "from over there", &[]);
let remote = here.directory().join("remote.bin");
let transports = transport_directory(&remote);
sefy_with_transport(&there, transports.path())
.arg("push")
.assert()
.success();
(here, there, remote, transports)
}
fn titles_in(path: &Path, password: &str) -> Vec<String> {
let vault = sefy_core::Vault::open(path, password.as_bytes()).unwrap();
let mut titles: Vec<String> = vault
.list()
.unwrap()
.into_iter()
.map(|item| item.title)
.collect();
titles.sort();
titles
}
#[test]
fn a_dry_run_sync_names_what_each_side_would_gain_and_changes_nothing() {
let (here, _there, remote, transports) = drifted_with_remote();
let before = std::fs::read(&here.path).unwrap();
let remote_before = std::fs::read(&remote).unwrap();
let output = sefy_with_transport(&here, transports.path())
.args(["sync", "--dry-run"])
.assert()
.success()
.get_output()
.stdout
.clone();
let output = String::from_utf8(output).unwrap();
let here_at = output.find("here, from").expect(&output);
let there_at = output.find("there, once").expect(&output);
let theirs_at = output.find("add \"their note\"").expect(&output);
let mine_at = output.find("add \"my note\"").expect(&output);
assert!(
here_at < theirs_at && theirs_at < there_at && there_at < mine_at,
"{output}"
);
assert!(output.contains("dry run"), "{output}");
assert_eq!(
std::fs::read(&here.path).unwrap(),
before,
"nothing written here"
);
assert_eq!(
std::fs::read(&remote).unwrap(),
remote_before,
"nothing sent"
);
assert!(!sefy_core::copies::path(&here.path, 1).exists());
sefy_with_transport(&here, transports.path())
.arg("status")
.assert()
.success()
.stdout(contains("synced never"));
}
#[test]
fn a_dry_run_pull_and_merge_change_nothing_either() {
let (here, there, _remote, transports) = drifted_with_remote();
let before = std::fs::read(&here.path).unwrap();
sefy_with_transport(&here, transports.path())
.args(["pull", "--dry-run"])
.assert()
.success()
.stdout(contains("add \"their note\"").and(contains("dry run")));
here.sefy()
.env("OTHER_PASSWORD", MASTER)
.args([
"merge",
"--dry-run",
"--other-password-env",
"OTHER_PASSWORD",
])
.arg(&there.path)
.assert()
.success()
.stdout(contains("add \"their note\"").and(contains("dry run")));
assert_eq!(std::fs::read(&here.path).unwrap(), before);
}
#[test]
fn a_pull_that_brings_something_keeps_the_vault_as_it_was_beside_it() {
let (here, _there, _remote, transports) = drifted_with_remote();
sefy_with_transport(&here, transports.path())
.arg("pull")
.assert()
.success()
.stdout(contains("kept as").and(contains("notes.bak.1")));
let kept = sefy_core::copies::path(&here.path, 1);
assert_eq!(titles_in(&kept, MASTER), vec!["my note", "shared"]);
here.sefy()
.arg("status")
.assert()
.success()
.stdout(contains("copies 1 copy beside the vault"));
sefy_with_transport(&here, transports.path())
.arg("pull")
.assert()
.success()
.stdout(contains("kept as").not());
assert!(!sefy_core::copies::path(&here.path, 2).exists());
}
#[test]
fn a_change_reaches_the_remote_by_itself_when_auto_sync_is_on() {
let (here, _there, remote, transports) = drifted_with_remote();
sefy_with_transport(&here, transports.path())
.env("SEFY_AUTO_SYNC", "on")
.args(["add", "note", "fresh", "--text", "added here"])
.assert()
.success()
.stderr(contains("synced \"vault\" through file"));
assert_eq!(
titles_in(&remote, MASTER),
vec!["fresh", "my note", "shared", "their note"]
);
sefy_with_transport(&here, transports.path())
.env("SEFY_AUTO_SYNC", "on")
.args([
"--auto-sync",
"off",
"add",
"note",
"local",
"--text",
"stays here",
])
.assert()
.success()
.stderr(contains("synced").not());
assert!(!titles_in(&remote, MASTER).contains(&"local".to_owned()));
}
#[test]
fn syncing_after_a_write_keeps_stdout_for_the_secret() {
let (here, _there, remote, transports) = drifted_with_remote();
let output = sefy_with_transport(&here, transports.path())
.env("SEFY_AUTO_SYNC", "on")
.args(["gen", "--save", "fresh login", "--stdout", "-n", "24"])
.assert()
.success()
.stderr(contains("synced"))
.get_output()
.stdout
.clone();
let output = String::from_utf8(output).unwrap();
assert_eq!(output.lines().count(), 1, "{output:?}");
assert_eq!(output.trim().chars().count(), 24);
assert!(titles_in(&remote, MASTER).contains(&"fresh login".to_owned()));
}
#[test]
fn a_sync_after_a_write_that_fails_warns_and_keeps_the_change() {
let fixture = Fixture::with_vault();
let nothing_installed = tempfile::tempdir().unwrap();
sefy_with_transport(&fixture, nothing_installed.path())
.env("SEFY_AUTO_SYNC", "on")
.args(["add", "note", "kept", "--text", "still here"])
.assert()
.success()
.stderr(contains("warning").and(contains("did not reach the remote")));
assert_eq!(titles_in(&fixture.path, MASTER), vec!["kept"]);
}
#[test]
fn a_transfer_is_not_followed_by_a_second_one() {
let (here, _there, remote, transports) = drifted_with_remote();
let remote_before = std::fs::read(&remote).unwrap();
sefy_with_transport(&here, transports.path())
.env("SEFY_AUTO_SYNC", "on")
.arg("pull")
.assert()
.success()
.stderr(contains("synced").not());
assert_eq!(std::fs::read(&remote).unwrap(), remote_before);
}
#[test]
fn a_command_that_writes_nothing_syncs_nothing() {
let fixture = Fixture::with_vault();
add_note(&fixture, "one", "first", &[]);
let nothing_installed = tempfile::tempdir().unwrap();
sefy_with_transport(&fixture, nothing_installed.path())
.env("SEFY_AUTO_SYNC", "on")
.arg("ls")
.assert()
.success()
.stderr(contains("warning").not());
}
#[test]
fn a_password_change_reaches_the_remote_under_the_new_password() {
let (here, _there, remote, transports) = drifted_with_remote();
sefy_with_transport(&here, transports.path())
.arg("pull")
.assert()
.success();
sefy_with_transport(&here, transports.path())
.env("SEFY_AUTO_SYNC", "on")
.env("NEW_PASSWORD", "a different password")
.args(["change-password", "--new-password-env", "NEW_PASSWORD"])
.assert()
.success()
.stdout(contains("1 copy beside the vault sealed under it too"))
.stderr(contains("synced"));
assert_eq!(
titles_in(&remote, "a different password"),
vec!["my note", "shared", "their note"]
);
let kept = sefy_core::copies::path(&here.path, 1);
assert_eq!(
titles_in(&kept, "a different password"),
vec!["my note", "shared"]
);
}
#[test]
fn the_doctor_reports_every_check_and_finds_the_remote_in_step_after_a_sync() {
let (here, _there, _remote, transports) = drifted_with_remote();
sefy_with_transport(&here, transports.path())
.arg("doctor")
.assert()
.stdout(
contains("ok vault")
.and(contains("2 items"))
.and(contains("plugins file 0.1.0"))
.and(contains("a sync would change 1 item here and 1 item there"))
.and(contains("skip auto-sync"))
.and(contains("clipboard")),
);
sefy_with_transport(&here, transports.path())
.arg("sync")
.assert()
.success();
sefy_with_transport(&here, transports.path())
.env("SEFY_AUTO_SYNC", "on")
.arg("doctor")
.assert()
.stdout(
contains("is in step with this vault")
.and(contains("ok copies 1 copy"))
.and(contains("ok auto-sync on")),
);
add_note(&here, "newer here", "not sent yet", &[]);
sefy_with_transport(&here, transports.path())
.arg("doctor")
.assert()
.stdout(
contains("a sync would change 0 items here and 1 item there")
.and(contains("in step").not()),
);
}
#[test]
fn the_doctor_fails_on_a_wrong_password_and_still_checks_the_rest() {
let fixture = Fixture::with_vault();
let transports = transport_directory(&fixture.directory().join("remote.bin"));
sefy_with_transport(&fixture, transports.path())
.env("SEFY_TEST_PASSWORD", "not the password")
.arg("doctor")
.assert()
.failure()
.stdout(
contains("fail vault")
.and(contains("wrong password"))
.and(contains("plugins file")),
)
.stderr(contains("failed"));
}
#[test]
fn the_doctor_needs_no_vault_to_check_the_machine() {
let transports = transport_directory(Path::new("nowhere.bin"));
let mut command = Command::cargo_bin("sefy").unwrap();
command
.env_remove("SEFY_VAULT")
.env_remove("SEFY_AUTO_SYNC")
.env_remove("SEFY_TRANSPORT")
.env("PATH", system_path())
.env("APPDATA", transports.path())
.env("XDG_DATA_HOME", transports.path())
.env("HOME", transports.path())
.arg("doctor")
.assert()
.failure()
.stdout(
contains("skip vault")
.and(contains("SEFY_VAULT"))
.and(contains("fail transport")),
);
}
#[test]
fn the_doctor_warns_when_the_remote_copy_is_under_another_password() {
let here = Fixture::with_vault();
let there = Fixture::empty();
there
.sefy()
.env("SEFY_TEST_PASSWORD", "another password")
.arg("init")
.assert()
.success();
let remote = here.directory().join("remote.bin");
std::fs::copy(&there.path, &remote).unwrap();
let transports = transport_directory(&remote);
sefy_with_transport(&here, transports.path())
.arg("doctor")
.assert()
.stdout(
contains("warn transport")
.and(contains("does not open under this vault's password"))
.and(contains("--ask-remote-password")),
);
}
#[test]
fn the_fixture_transport_is_where_sefy_looks_for_one() {
let fixture = Fixture::with_vault();
let transports = transport_directory(&fixture.directory().join("remote.bin"));
sefy_with_transport(&fixture, transports.path())
.args(["plugin", "list"])
.assert()
.success()
.stdout(contains("file").and(contains("pull")).and(contains("push")));
}
fn add_item_of_a_future_kind(fixture: &Fixture, title: &str) {
let file = std::fs::read(&fixture.path).unwrap();
let database = sefy_core::format::decode(MASTER.as_bytes(), &file).unwrap();
let connection = sefy_core::db::load(&database).unwrap();
connection
.execute(
"INSERT INTO items (uuid, title, kind, created_at, updated_at)
VALUES ('11111111-2222-4333-8444-555555555555', ?1, 'passport', 1000, 1000)",
[title],
)
.unwrap();
let id = connection.last_insert_rowid();
connection
.execute_batch("CREATE TABLE passports (item_id INTEGER NOT NULL, number TEXT NOT NULL)")
.unwrap();
connection
.execute(
"INSERT INTO passports (item_id, number) VALUES (?1, '4111')",
[id],
)
.unwrap();
let dumped = sefy_core::db::dump(&connection).unwrap();
std::fs::write(
&fixture.path,
sefy_core::format::encode(MASTER.as_bytes(), &dumped).unwrap(),
)
.unwrap();
}
#[test]
fn an_item_from_a_newer_sefy_does_not_break_the_listing() {
let fixture = Fixture::with_vault();
add_note(&fixture, "shed", "combination 4815", &[]);
add_item_of_a_future_kind(&fixture, "my passport");
fixture
.sefy()
.arg("ls")
.assert()
.success()
.stdout(contains("shed").and(contains("my passport")))
.stdout(contains("needs a newer sefy"));
}
#[test]
fn reading_an_item_from_a_newer_sefy_explains_rather_than_denies_it() {
let fixture = Fixture::with_vault();
add_item_of_a_future_kind(&fixture, "my passport");
fixture
.sefy()
.args(["show", "my passport"])
.assert()
.success()
.stdout(contains("passport").and(contains("does not know")));
fixture
.sefy()
.args(["get", "my passport", "--stdout"])
.assert()
.failure()
.stderr(contains("upgrade to read it"))
.stderr(contains("does not know\nit was written"));
}
#[test]
fn an_item_from_a_newer_sefy_can_still_be_retitled() {
let fixture = Fixture::with_vault();
add_item_of_a_future_kind(&fixture, "my passport");
fixture
.sefy()
.args(["edit", "my passport", "--title", "travel passport"])
.assert()
.success();
fixture
.sefy()
.arg("ls")
.assert()
.success()
.stdout(contains("travel passport"));
fixture
.sefy()
.args(["edit", "travel passport", "--text", "nope"])
.assert()
.failure()
.stderr(contains("only --title and tags"));
}
fn add_full_login(fixture: &Fixture, title: &str) {
fixture
.sefy()
.env("ITEM_PASSWORD", "hunter2")
.args([
"add",
"login",
title,
"--login",
"someone",
"--url",
"https://example.invalid",
"--totp",
"jbsw y3dp ehpk 3pxp",
"--notes",
"backup codes in the drawer",
"--item-password-env",
"ITEM_PASSWORD",
])
.assert()
.success();
}
#[test]
fn a_login_round_trips_every_field_it_was_given() {
let fixture = Fixture::with_vault();
add_full_login(&fixture, "mail");
fixture
.sefy()
.args(["get", "mail", "--stdout"])
.assert()
.success()
.stdout(contains("hunter2"));
fixture
.sefy()
.args(["get", "mail", "--field", "login", "--stdout"])
.assert()
.success()
.stdout(contains("someone"));
fixture
.sefy()
.args(["get", "mail", "--field", "totp", "--stdout"])
.assert()
.success()
.stdout("JBSWY3DPEHPK3PXP\n");
}
#[test]
fn a_login_option_left_out_produces_no_field_at_all() {
let fixture = Fixture::with_vault();
fixture
.sefy()
.env("ITEM_PASSWORD", "hunter2")
.args([
"add",
"login",
"bare",
"--login",
"someone",
"--item-password-env",
"ITEM_PASSWORD",
])
.assert()
.success();
fixture
.sefy()
.args(["show", "bare"])
.assert()
.success()
.stdout(contains("login:"))
.stdout(contains("password:"))
.stdout(contains("url:").not())
.stdout(contains("totp:").not())
.stdout(contains("notes:").not());
}
#[test]
fn a_card_round_trips_and_get_with_no_field_gives_the_number() {
let fixture = Fixture::with_vault();
fixture
.sefy()
.env("CARD_NUMBER", "4111111111111111")
.env("CARD_CVV", "123")
.env("CARD_PIN", "4815")
.args([
"add",
"card",
"wallet",
"--holder",
"A Cardholder",
"--expires",
"12/30",
"--number-env",
"CARD_NUMBER",
"--cvv-env",
"CARD_CVV",
"--pin-env",
"CARD_PIN",
])
.assert()
.success();
fixture
.sefy()
.args(["get", "wallet", "--stdout"])
.assert()
.success()
.stdout(contains("4111111111111111"));
fixture
.sefy()
.args(["get", "wallet", "--field", "cvv", "--stdout"])
.assert()
.success()
.stdout(contains("123"));
fixture
.sefy()
.args(["get", "wallet", "--field", "pin", "--stdout"])
.assert()
.success()
.stdout(contains("4815"));
fixture
.sefy()
.args(["get", "wallet", "--field", "expires", "--stdout"])
.assert()
.success()
.stdout("2030-12\n");
fixture
.sefy()
.args(["show", "wallet"])
.assert()
.success()
.stdout(contains("expires: 2030-12 (in "));
}
#[test]
fn no_cvv_and_no_pin_leave_those_fields_out_of_the_card() {
let fixture = Fixture::with_vault();
fixture
.sefy()
.env("CARD_NUMBER", "4111111111111111")
.args([
"add",
"card",
"bare card",
"--number-env",
"CARD_NUMBER",
"--no-cvv",
"--no-pin",
])
.assert()
.success();
fixture
.sefy()
.args(["show", "bare card"])
.assert()
.success()
.stdout(contains("number:"))
.stdout(contains("cvv:").not())
.stdout(contains("pin:").not());
}
#[test]
fn an_ssh_key_stores_a_trimmed_public_key_and_the_private_key_comes_back_exact() {
let fixture = Fixture::with_vault();
let private_path = fixture.directory().join("id_ed25519");
let public_path = fixture.directory().join("id_ed25519.pub");
std::fs::write(
&private_path,
"-----BEGIN PRIVATE KEY-----\nabc\n-----END-----\n",
)
.unwrap();
std::fs::write(&public_path, "ssh-ed25519 AAAAC3 comment\n").unwrap();
fixture
.sefy()
.args(["add", "ssh-key", "prod server", "--private-key"])
.arg(&private_path)
.args(["--host", "prod.example.invalid", "--no-passphrase"])
.assert()
.success();
fixture
.sefy()
.args(["show", "prod server"])
.assert()
.success()
.stdout(contains("ssh-ed25519 AAAAC3 comment"));
fixture
.sefy()
.args(["get", "prod server", "--field", "private-key", "--stdout"])
.assert()
.success()
.stdout(contains(
"-----BEGIN PRIVATE KEY-----\nabc\n-----END-----\n",
));
}
#[test]
fn an_ssh_key_with_no_pub_file_beside_it_still_succeeds_and_has_no_public_key_field() {
let fixture = Fixture::with_vault();
let private_path = fixture.directory().join("lonely_key");
std::fs::write(&private_path, "the private half only").unwrap();
fixture
.sefy()
.args(["add", "ssh-key", "lonely", "--private-key"])
.arg(&private_path)
.arg("--no-passphrase")
.assert()
.success();
fixture
.sefy()
.args(["show", "lonely"])
.assert()
.success()
.stdout(contains("private-key:"))
.stdout(contains("public-key:").not());
}
#[test]
fn get_field_on_a_name_the_record_lacks_lists_what_it_does_have() {
let fixture = Fixture::with_vault();
add_full_login(&fixture, "mail");
fixture
.sefy()
.args(["get", "mail", "--field", "nonexistent"])
.assert()
.failure()
.stderr(contains("nonexistent"))
.stderr(contains("login"))
.stderr(contains("password"))
.stderr(contains("url"))
.stderr(contains("totp"))
.stderr(contains("notes"));
}
#[test]
fn show_hides_every_secret_field_and_names_it_in_the_hint() {
let fixture = Fixture::with_vault();
add_full_login(&fixture, "mail");
fixture
.sefy()
.args(["show", "mail"])
.assert()
.success()
.stdout(contains("hunter2").not())
.stdout(contains("JBSWY3DP").not())
.stdout(contains("<hidden — use sefy get --field password>"))
.stdout(contains("<hidden — sefy otp gives the code>"));
}
#[test]
fn edit_set_changes_an_existing_fields_value() {
let fixture = Fixture::with_vault();
add_full_login(&fixture, "mail");
fixture
.sefy()
.args(["edit", "mail", "--set", "login=someone-else"])
.assert()
.success();
fixture
.sefy()
.args(["get", "mail", "--field", "login", "--stdout"])
.assert()
.success()
.stdout(contains("someone-else"));
}
#[test]
fn edit_set_adds_a_field_the_record_did_not_have_and_it_is_public() {
let fixture = Fixture::with_vault();
let private_path = fixture.directory().join("id_ed25519");
std::fs::write(&private_path, "private half").unwrap();
fixture
.sefy()
.args(["add", "ssh-key", "box", "--private-key"])
.arg(&private_path)
.arg("--no-passphrase")
.assert()
.success();
fixture
.sefy()
.args(["edit", "box", "--set", "fingerprint=abc"])
.assert()
.success();
fixture
.sefy()
.args(["show", "box"])
.assert()
.success()
.stdout(contains("fingerprint: abc"));
}
#[test]
fn edit_set_on_an_existing_secret_field_keeps_it_secret() {
let fixture = Fixture::with_vault();
add_full_login(&fixture, "mail");
fixture
.sefy()
.args(["edit", "mail", "--set", "password=newpassword"])
.assert()
.success();
fixture
.sefy()
.args(["show", "mail"])
.assert()
.success()
.stdout(contains("newpassword").not())
.stdout(contains("<hidden — use sefy get --field password>"));
fixture
.sefy()
.args(["get", "mail", "--stdout"])
.assert()
.success()
.stdout(contains("newpassword"));
}
#[test]
fn edit_unset_removes_a_field_and_refuses_a_name_that_is_not_there() {
let fixture = Fixture::with_vault();
add_full_login(&fixture, "mail");
fixture
.sefy()
.args(["edit", "mail", "--unset", "notes"])
.assert()
.success();
fixture
.sefy()
.args(["show", "mail"])
.assert()
.success()
.stdout(contains("notes:").not());
fixture
.sefy()
.args(["edit", "mail", "--unset", "notes"])
.assert()
.failure()
.stderr(contains("no field named"))
.stderr(contains("login"));
}
#[test]
fn edit_unset_of_every_field_is_refused() {
let fixture = Fixture::with_vault();
fixture
.sefy()
.env("ITEM_PASSWORD", "hunter2")
.args([
"add",
"login",
"bare",
"--login",
"someone",
"--item-password-env",
"ITEM_PASSWORD",
])
.assert()
.success();
fixture
.sefy()
.args(["edit", "bare", "--unset", "login", "--unset", "password"])
.assert()
.failure()
.stderr(contains("cannot be left without any field"));
fixture
.sefy()
.args(["show", "bare"])
.assert()
.success()
.stdout(contains("login:"));
}
#[test]
fn a_malformed_set_without_an_equals_sign_is_an_error() {
let fixture = Fixture::with_vault();
add_full_login(&fixture, "mail");
fixture
.sefy()
.args(["edit", "mail", "--set", "loginsomeone"])
.assert()
.failure()
.stderr(contains("NAME=VALUE"));
}
#[test]
fn set_on_a_note_is_refused_as_a_records_only_option() {
let fixture = Fixture::with_vault();
add_note(&fixture, "a note", "text", &[]);
fixture
.sefy()
.args(["edit", "a note", "--set", "anything=value"])
.assert()
.failure()
.stderr(contains("--set, --set-secret and --unset apply to records"));
}
#[test]
fn ls_kind_shows_only_that_kind_and_credential_is_an_alias_for_login() {
let fixture = Fixture::with_vault();
add_full_login(&fixture, "mail");
fixture
.sefy()
.env("CARD_NUMBER", "4111111111111111")
.args([
"add",
"card",
"wallet",
"--number-env",
"CARD_NUMBER",
"--no-cvv",
"--no-pin",
])
.assert()
.success();
fixture
.sefy()
.args(["ls", "--kind", "card"])
.assert()
.success()
.stdout(contains("wallet"))
.stdout(contains("mail").not());
fixture
.sefy()
.args(["ls", "--kind", "credential"])
.assert()
.success()
.stdout(contains("mail"))
.stdout(contains("wallet").not());
}
#[test]
fn add_credential_is_still_accepted_and_produces_a_login() {
let fixture = Fixture::with_vault();
fixture
.sefy()
.env("ITEM_PASSWORD", "hunter2")
.args([
"add",
"credential",
"legacy",
"--login",
"someone",
"--item-password-env",
"ITEM_PASSWORD",
])
.assert()
.success();
fixture
.sefy()
.args(["show", "legacy"])
.assert()
.success()
.stdout(contains("kind: login"));
}
#[test]
fn set_secret_needs_a_terminal_it_does_not_have_in_a_script() {
let fixture = Fixture::with_vault();
add_full_login(&fixture, "mail");
fixture
.sefy()
.args(["edit", "mail", "--set-secret", "password"])
.write_stdin("newpassword\n")
.assert()
.failure()
.stderr(contains("not attached to a terminal"));
}
#[test]
fn an_export_still_runs_with_an_item_from_a_newer_sefy_in_the_vault() {
let fixture = Fixture::with_vault();
add_note(&fixture, "shed", "combination 4815", &[]);
add_item_of_a_future_kind(&fixture, "my passport");
let output = fixture.directory().join("export.json");
fixture
.sefy()
.args(["export", "--output"])
.arg(&output)
.arg("--i-know-this-writes-plaintext")
.assert()
.success();
let written = std::fs::read_to_string(&output).unwrap();
assert!(written.contains("my passport"), "the item is in the export");
assert!(
written.contains("contents_not_exported"),
"and the export says its contents could not be read"
);
}
#[test]
fn status_reports_shape_and_never_contents() {
let fixture = Fixture::with_vault();
add_note(&fixture, "bank code", "vault code 1234", &["money"]);
add_note(&fixture, "wifi", "hunter2", &["home", "money"]);
let output = fixture.sefy().arg("status").assert().success();
let printed = String::from_utf8(output.get_output().stdout.clone()).unwrap();
assert!(printed.contains("2 items"), "item count: {printed}");
assert!(printed.contains("2 note"), "kind breakdown: {printed}");
assert!(printed.contains("2 tags"), "tag count: {printed}");
assert!(printed.contains("schema"), "schema version: {printed}");
assert!(printed.contains("never"), "a fresh vault has not synced");
let sealing = sefy_core::SEALING;
assert!(
printed.contains(&format!(
"sealed {} ({} MiB, {} passes, {} lanes), {}, format v{}",
sealing.kdf,
sealing.memory_kib / 1024,
sealing.passes,
sealing.lanes,
sealing.cipher,
sealing.format
)),
"sealing: {printed}"
);
for secret in ["vault code 1234", "hunter2", "bank code", "wifi"] {
assert!(
!printed.contains(secret),
"status printed {secret:?}, which is contents:\n{printed}"
);
}
}
#[test]
fn status_counts_a_kind_this_build_does_not_know() {
let fixture = Fixture::with_vault();
add_note(&fixture, "ordinary", "text", &[]);
add_item_of_a_future_kind(&fixture, "my passport");
let output = fixture.sefy().arg("status").assert().success();
let printed = String::from_utf8(output.get_output().stdout.clone()).unwrap();
assert!(printed.contains("2 items"), "both are counted: {printed}");
assert!(
printed.contains("passport"),
"the unknown kind is named as stored: {printed}"
);
}
#[test]
fn open_refuses_anything_that_is_not_a_web_address() {
let fixture = Fixture::with_vault();
for (title, url) in [
("local", "file:///C:/Windows/System32/calc.exe"),
("script", "javascript:alert(1)"),
("settings", "ms-settings:privacy"),
("bare", "example.com"),
] {
fixture
.sefy()
.args(["add", "login", title, "--login", "someone"])
.args(["--item-password-env", "SEFY_TEST_PASSWORD"])
.args(["--url", url])
.assert()
.success();
fixture
.sefy()
.args(["open", title])
.assert()
.failure()
.stderr(contains("web address"));
}
}
#[test]
fn open_says_what_is_missing_rather_than_opening_nothing() {
let fixture = Fixture::with_vault();
add_note(&fixture, "a note", "text", &[]);
fixture
.sefy()
.args(["add", "login", "no-site", "--login", "someone"])
.args(["--item-password-env", "SEFY_TEST_PASSWORD"])
.assert()
.success();
fixture
.sefy()
.args(["open", "a note"])
.assert()
.failure()
.stderr(contains("no site to open"));
fixture
.sefy()
.args(["open", "no-site"])
.assert()
.failure()
.stderr(contains("has no \"url\" field"))
.stderr(contains("--set url="));
}
#[test]
fn the_picker_is_refused_off_a_terminal_rather_than_hanging() {
let fixture = Fixture::with_vault();
add_note(&fixture, "something", "text", &[]);
fixture
.sefy()
.assert()
.failure()
.stderr(contains("not a terminal"))
.stderr(contains("sefy ls"));
}
#[test]
fn find_lists_rather_than_picking_wherever_it_runs() {
let fixture = Fixture::with_vault();
add_note(&fixture, "bank code", "text", &["money"]);
add_note(&fixture, "wifi", "text", &["home"]);
fixture
.sefy()
.args(["find", "--tag", "money"])
.assert()
.success()
.stdout(contains("bank code"))
.stdout(contains("wifi").not());
}
fn sefy_without_a_vault() -> Command {
let mut command = Command::cargo_bin("sefy").unwrap();
command.env_remove("SEFY_VAULT");
command
}
fn generated(command: &mut Command) -> String {
let output = command
.arg("--stdout")
.assert()
.success()
.get_output()
.clone();
let stdout = String::from_utf8(output.stdout).unwrap();
let lines: Vec<&str> = stdout.lines().collect();
assert_eq!(
lines.len(),
1,
"stdout carries more than the secret: {stdout:?}"
);
lines[0].to_owned()
}
#[test]
fn gen_needs_no_vault_and_prints_the_secret_alone() {
let password = generated(sefy_without_a_vault().arg("gen"));
assert_eq!(password.chars().count(), 20);
sefy_without_a_vault()
.args(["gen", "--stdout"])
.assert()
.success()
.stderr(contains("bits of entropy").and(contains("strength")));
}
#[test]
fn gen_does_not_touch_a_vault_it_was_not_asked_to_save_into() {
let fixture = Fixture::empty();
generated(fixture.sefy().arg("gen"));
assert!(!fixture.path.exists());
}
#[test]
fn gen_follows_the_policy_it_was_given() {
let password = generated(sefy_without_a_vault().args([
"gen",
"--length",
"64",
"--no-symbols",
"--no-uppercase",
]));
assert_eq!(password.len(), 64);
assert!(
password
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
);
}
#[test]
fn gen_makes_a_passphrase_from_the_list_asked_for() {
let english = generated(sefy_without_a_vault().args(["gen", "--words", "5"]));
assert_eq!(english.split('-').count(), 5);
let russian = generated(sefy_without_a_vault().args([
"gen",
"--words",
"4",
"--lang",
"ru",
"--separator",
" ",
]));
let words: Vec<&str> = russian.split(' ').collect();
assert_eq!(words.len(), 4);
assert!(
words
.iter()
.all(|word| word.chars().all(|c| ('а'..='я').contains(&c)))
);
}
#[test]
fn gen_refuses_options_that_belong_to_another_recipe() {
for arguments in [
&["gen", "--words", "4", "--no-symbols"][..],
&["gen", "--words", "4", "--length", "30"],
&["gen", "--pronounceable", "--no-digits"],
&["gen", "--lang", "ru"],
&["gen", "--login", "someone"],
] {
sefy_without_a_vault().args(arguments).assert().failure();
}
}
#[test]
fn gen_save_stores_the_very_password_it_hands_over() {
let fixture = Fixture::with_vault();
let password = generated(fixture.sefy().args([
"gen",
"--save",
"forum",
"--login",
"someone@example.com",
"--url",
"https://forum.example.com",
"--tag",
"web",
]));
fixture
.sefy()
.args(["get", "forum", "--stdout"])
.assert()
.success()
.stdout(format!("{password}\n"));
fixture
.sefy()
.args(["show", "forum"])
.assert()
.success()
.stdout(
contains("kind:")
.and(contains("login"))
.and(contains("someone@example.com"))
.and(contains("https://forum.example.com"))
.and(contains("web"))
.and(contains(password.as_str()).not()),
);
}
#[test]
fn gen_save_with_the_wrong_password_hands_over_nothing() {
let fixture = Fixture::with_vault();
fixture
.sefy()
.env("SEFY_TEST_PASSWORD", "not the master password")
.args(["gen", "--save", "forum", "--stdout"])
.assert()
.failure()
.stdout("");
}
const OTP_KEY: &str = "JBSWY3DPEHPK3PXP";
fn add_plain_login(fixture: &Fixture, title: &str) {
fixture
.sefy()
.env("ITEM_PASSWORD", "hunter2")
.args(["add", "login", title, "--login", "someone"])
.args(["--item-password-env", "ITEM_PASSWORD"])
.assert()
.success();
}
fn codes_around_now(key: &str, run: impl FnOnce() -> String) -> (String, [String; 2]) {
let totp = sefy_core::Totp::parse(key).unwrap();
let now = || {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
};
let before = totp.code_at(now());
let output = run();
let after = totp.code_at(now());
(output, [before, after])
}
#[test]
fn otp_set_stores_the_key_and_answers_with_the_first_code() {
let fixture = Fixture::with_vault();
add_plain_login(&fixture, "forge");
let (stdout, expected) = codes_around_now(OTP_KEY, || {
let output = fixture
.sefy()
.env("OTP_KEY", "jbsw-y3dp ehpk-3pxp")
.args(["otp", "forge", "--key-env", "OTP_KEY", "--stdout"])
.assert()
.success()
.stderr(contains("stored the one-time password key of \"forge\""))
.get_output()
.stdout
.clone();
String::from_utf8(output).unwrap()
});
let code = stdout.trim_end();
assert_eq!(stdout, format!("{code}\n"), "stdout carries the code alone");
assert!(
expected.contains(&code.to_owned()),
"{code} not in {expected:?}"
);
fixture
.sefy()
.args(["get", "forge", "--field", "totp", "--stdout"])
.assert()
.success()
.stdout(format!("{OTP_KEY}\n"));
let (stdout, expected) = codes_around_now(OTP_KEY, || {
let output = fixture
.sefy()
.args(["otp", "forge", "--stdout"])
.assert()
.success()
.get_output()
.stdout
.clone();
String::from_utf8(output).unwrap()
});
assert!(expected.contains(&stdout.trim_end().to_owned()));
}
#[test]
fn otp_follows_the_parameters_of_a_link() {
let fixture = Fixture::with_vault();
add_plain_login(&fixture, "bank portal");
let link = format!(
"otpauth://totp/Example%20Bank:someone?secret={OTP_KEY}&issuer=Example%20Bank&digits=8&algorithm=SHA256"
);
let (stdout, expected) = codes_around_now(&link, || {
let output = fixture
.sefy()
.env("OTP_KEY", &link)
.args(["otp", "bank portal", "--key-env", "OTP_KEY", "--stdout"])
.assert()
.success()
.get_output()
.stdout
.clone();
String::from_utf8(output).unwrap()
});
let code = stdout.trim_end();
assert_eq!(code.len(), 8, "{code}");
assert!(expected.contains(&code.to_owned()));
fixture
.sefy()
.args(["get", "bank portal", "--field", "totp", "--stdout"])
.assert()
.success()
.stdout(format!("{link}\n"));
}
#[test]
fn otp_refuses_what_is_not_a_key_and_leaves_the_record_alone() {
let fixture = Fixture::with_vault();
add_plain_login(&fixture, "forge");
fixture
.sefy()
.env("OTP_KEY", "hunter2-is-not-base32!")
.args(["otp", "forge", "--key-env", "OTP_KEY", "--stdout"])
.assert()
.failure()
.stderr(contains("not a one-time password key"))
.stderr(contains("hunter2").not())
.stdout("");
fixture
.sefy()
.args(["get", "forge", "--field", "totp", "--stdout"])
.assert()
.failure();
}
#[test]
fn otp_refuses_a_counter_based_key_by_name() {
let fixture = Fixture::with_vault();
add_plain_login(&fixture, "forge");
fixture
.sefy()
.env(
"OTP_KEY",
format!("otpauth://hotp/x?secret={OTP_KEY}&counter=3"),
)
.args(["otp", "forge", "--key-env", "OTP_KEY"])
.assert()
.failure()
.stderr(contains("HOTP"));
}
#[test]
fn otp_on_a_login_without_a_key_says_how_to_store_one() {
let fixture = Fixture::with_vault();
add_plain_login(&fixture, "forge");
fixture
.sefy()
.args(["otp", "forge", "--stdout"])
.assert()
.failure()
.stderr(contains("has no one-time password key"))
.stderr(contains("sefy otp 1 --set"));
}
#[test]
fn otp_on_a_note_is_refused() {
let fixture = Fixture::with_vault();
add_note(&fixture, "diary", "dear diary", &[]);
fixture
.sefy()
.args(["otp", "diary", "--stdout"])
.assert()
.failure()
.stderr(contains("is a note"));
}
#[test]
fn otp_qr_draws_only_on_a_terminal() {
let fixture = Fixture::with_vault();
add_full_login(&fixture, "mail");
fixture
.sefy()
.args(["otp", "mail", "--qr"])
.assert()
.failure()
.stderr(contains("only on a terminal"))
.stdout(contains(OTP_KEY).not())
.stdout(contains("\u{2580}").not());
}
#[test]
fn a_key_that_cannot_make_a_code_is_refused_on_the_way_in() {
let fixture = Fixture::with_vault();
fixture
.sefy()
.env("ITEM_PASSWORD", "hunter2")
.args(["add", "login", "mail", "--login", "someone"])
.args(["--totp", "hunter1!", "--item-password-env", "ITEM_PASSWORD"])
.assert()
.failure()
.stderr(contains("not a one-time password key"));
add_plain_login(&fixture, "forge");
fixture
.sefy()
.args(["edit", "forge", "--set", "totp=1234"])
.assert()
.failure()
.stderr(contains("not a one-time password key"));
}
#[test]
fn fill_needs_a_terminal_to_wait_on_and_says_what_to_do_instead() {
let fixture = Fixture::with_vault();
add_full_login(&fixture, "mail");
fixture
.sefy()
.args(["fill", "mail"])
.assert()
.failure()
.stderr(contains("waits for Enter"))
.stderr(contains("not a terminal\ntake one field at a time"))
.stderr(contains("sefy get 1 --field NAME --stdout"))
.stdout("");
}
#[test]
fn fill_refuses_a_record_with_nothing_to_fill() {
let fixture = Fixture::with_vault();
let key = fixture.directory().join("id_test");
std::fs::write(&key, "-----BEGIN TEST KEY-----\n").unwrap();
fixture
.sefy()
.args(["add", "ssh-key", "box", "--no-passphrase", "--private-key"])
.arg(&key)
.assert()
.success();
fixture
.sefy()
.args(["fill", "box"])
.assert()
.failure()
.stderr(contains("holds nothing a ssh-key fills in"))
.stderr(contains("private-key"));
}
#[test]
fn fill_on_a_note_is_refused() {
let fixture = Fixture::with_vault();
add_note(&fixture, "diary", "dear diary", &[]);
fixture
.sefy()
.args(["fill", "diary"])
.assert()
.failure()
.stderr(contains("is a note"));
}
#[test]
fn a_wifi_network_takes_its_public_fields_on_the_line_and_its_key_from_aside() {
let fixture = Fixture::with_vault();
fixture
.sefy()
.env("WIFI_KEY", "correct horse")
.args([
"add",
"wifi",
"home",
"--set",
"security=WPA3",
"--set",
"ssid=HomeNet",
])
.args(["--secret-env", "password=WIFI_KEY", "--tag", "house"])
.assert()
.success()
.stdout(contains("added \"home\""));
let shown = fixture
.sefy()
.args(["show", "home"])
.assert()
.success()
.stdout(contains("correct horse").not())
.get_output()
.stdout
.clone();
let shown = String::from_utf8(shown).unwrap();
let ssid = shown.find("ssid:").unwrap();
let password = shown.find("password:").unwrap();
let security = shown.find("security:").unwrap();
assert!(ssid < password && password < security, "{shown}");
assert!(shown.contains("kind: wifi"), "{shown}");
fixture
.sefy()
.args(["get", "home", "--stdout"])
.assert()
.success()
.stdout("correct horse\n");
fixture
.sefy()
.args(["ls", "--kind", "wifi"])
.assert()
.success()
.stdout(contains("home"));
}
#[test]
fn a_secret_field_is_never_taken_on_the_command_line() {
let fixture = Fixture::with_vault();
fixture
.sefy()
.args([
"add",
"wifi",
"home",
"--set",
"password=in the history now",
])
.assert()
.failure()
.stderr(contains("password is secret"))
.stderr(contains("--secret-env password=VAR"));
}
#[test]
fn a_secret_field_left_to_the_prompt_needs_a_terminal() {
let fixture = Fixture::with_vault();
fixture
.sefy()
.args([
"add",
"api-token",
"ci",
"--set",
"url=https://example.invalid",
])
.assert()
.failure()
.stderr(contains("not attached to a terminal"));
}
#[test]
fn skip_leaves_a_secret_out_and_names_only_secret_fields() {
let fixture = Fixture::with_vault();
fixture
.sefy()
.args(["add", "api-token", "ci", "--skip", "token"])
.args([
"--set",
"url=https://example.invalid",
"--set",
"scopes=read",
])
.assert()
.success();
fixture
.sefy()
.args(["show", "ci"])
.assert()
.success()
.stdout(contains("token:").not())
.stdout(contains("scopes:"));
fixture
.sefy()
.args(["add", "api-token", "other", "--skip", "url"])
.assert()
.failure()
.stderr(contains("those are: token"));
}
#[test]
fn a_bank_account_keeps_extra_fields_and_leaves_an_empty_secret_out() {
let fixture = Fixture::with_vault();
fixture
.sefy()
.env("ACCOUNT", "DE00 1234 5678")
.env("EMPTY", "")
.args(["add", "bank", "savings", "--secret-env", "account=ACCOUNT"])
.args(["--set", "holder=Someone", "--set", "branch=Main street"])
.args(["--secret-env", "online-pin=EMPTY"])
.assert()
.success();
fixture
.sefy()
.args(["show", "savings"])
.assert()
.success()
.stdout(contains("branch:"))
.stdout(contains("Main street"))
.stdout(contains("online-pin").not())
.stdout(contains("1234").not());
fixture
.sefy()
.args(["get", "savings", "--stdout"])
.assert()
.success()
.stdout("DE00 1234 5678\n");
}
#[test]
fn a_name_given_twice_is_refused_rather_than_guessed() {
let fixture = Fixture::with_vault();
fixture
.sefy()
.args(["add", "wifi", "home", "--skip", "password"])
.args(["--set", "ssid=one", "--set", "ssid=two"])
.assert()
.failure()
.stderr(contains("names \"ssid\" twice"));
}
#[test]
fn the_new_kinds_survive_an_export_and_an_import() {
let source = Fixture::with_vault();
source
.sefy()
.env("WIFI_KEY", "correct horse")
.args(["add", "wifi", "home", "--set", "ssid=HomeNet"])
.args(["--secret-env", "password=WIFI_KEY"])
.assert()
.success();
let export = source.directory().join("export.json");
source
.sefy()
.args(["export", "--i-know-this-writes-plaintext", "--output"])
.arg(&export)
.assert()
.success();
let target = Fixture::with_vault();
target
.sefy()
.arg("import")
.arg(&export)
.assert()
.success()
.stdout(contains("imported 1 item"));
target
.sefy()
.args(["get", "home", "--stdout"])
.assert()
.success()
.stdout("correct horse\n");
}
fn fixture_for_run() -> Fixture {
let fixture = Fixture::with_vault();
fixture
.sefy()
.env("API_TOKEN", "tok-4815")
.args(["add", "api-token", "ci"])
.args(["--set", "url=https://api.example.invalid"])
.args(["--secret-env", "token=API_TOKEN"])
.assert()
.success();
add_full_login(&fixture, "db");
fixture
}
fn print_variables(names: &[&str]) -> Vec<String> {
#[cfg(windows)]
{
let list = names
.iter()
.map(|name| format!("'{name}'"))
.collect::<Vec<_>>()
.join(",");
vec![
powershell(),
"-NoProfile".into(),
"-Command".into(),
format!(
"foreach ($n in {list}) {{ $v = [Environment]::GetEnvironmentVariable($n); \
if ($null -eq $v) {{ $n + ' unset' }} else {{ $n + '=' + $v }} }}"
),
]
}
#[cfg(not(windows))]
{
let script = names
.iter()
.map(|name| {
format!(
"if [ -n \"${{{name}+x}}\" ]; then printf '%s\\n' \"{name}=${name}\"; \
else printf '%s\\n' '{name} unset'; fi"
)
})
.collect::<Vec<_>>()
.join("; ");
vec!["sh".into(), "-c".into(), script]
}
}
fn exit_with(status: i32) -> Vec<String> {
#[cfg(windows)]
{
vec![
powershell(),
"-NoProfile".into(),
"-Command".into(),
format!("exit {status}"),
]
}
#[cfg(not(windows))]
{
vec!["sh".into(), "-c".into(), format!("exit {status}")]
}
}
#[cfg(windows)]
fn powershell() -> String {
PathBuf::from(std::env::var_os("SystemRoot").unwrap())
.join("System32")
.join("WindowsPowerShell")
.join("v1.0")
.join("powershell.exe")
.display()
.to_string()
}
#[test]
fn run_hands_the_command_what_was_mapped_and_not_the_master_password() {
let fixture = fixture_for_run();
let output = fixture
.sefy()
.args(["run", "-e", "TOKEN=ci", "-e", "DB_USER=db#login"])
.args(["--env", "DB_PASSWORD=db", "--"])
.args(print_variables(&[
"TOKEN",
"DB_USER",
"DB_PASSWORD",
"SEFY_TEST_PASSWORD",
]))
.assert()
.success()
.get_output()
.stdout
.clone();
let seen = String::from_utf8(output).unwrap().replace("\r\n", "\n");
assert!(seen.contains("TOKEN=tok-4815\n"), "{seen}");
assert!(seen.contains("DB_USER=someone\n"), "{seen}");
assert!(seen.contains("DB_PASSWORD=hunter2\n"), "{seen}");
assert!(seen.contains("SEFY_TEST_PASSWORD unset"), "{seen}");
}
#[test]
fn run_replaces_a_variable_the_environment_already_has() {
let fixture = fixture_for_run();
fixture
.sefy()
.env("TOKEN", "stale")
.args(["run", "-e", "TOKEN=ci", "--"])
.args(print_variables(&["TOKEN"]))
.assert()
.success()
.stdout(contains("TOKEN=tok-4815"));
}
#[test]
fn run_ends_with_the_status_the_command_ended_with() {
let fixture = fixture_for_run();
fixture
.sefy()
.args(["run", "-e", "TOKEN=ci", "--"])
.args(exit_with(7))
.assert()
.code(7);
fixture
.sefy()
.args(["run", "-e", "TOKEN=ci", "--"])
.args(exit_with(0))
.assert()
.success();
}
#[test]
fn run_starts_nothing_when_one_variable_cannot_be_set() {
let fixture = fixture_for_run();
fixture
.sefy()
.args(["run", "-e", "TOKEN=ci", "-e", "OTHER=nothing-like-it", "--"])
.args(print_variables(&["TOKEN"]))
.assert()
.code(125)
.stdout("")
.stderr(contains("cannot set OTHER"))
.stderr(contains("nothing-like-it"));
}
#[test]
fn run_tells_a_missing_program_from_one_that_failed() {
let fixture = fixture_for_run();
fixture
.sefy()
.args(["run", "-e", "TOKEN=ci", "--", "sefy-test-no-such-program"])
.assert()
.code(127)
.stderr(contains("no such program"));
}
#[test]
fn run_refuses_a_mapping_it_would_have_to_guess_about() {
let fixture = fixture_for_run();
add_note(&fixture, "diary", "dear diary", &[]);
for (mappings, expected) in [
(vec!["TOKEN"], "VAR=REFERENCE[#FIELD]"),
(vec!["=ci"], "VAR=REFERENCE[#FIELD]"),
(vec!["TOKEN="], "VAR=REFERENCE[#FIELD]"),
(vec!["TOKEN=ci#"], "taken by its id"),
(vec!["TOKEN=ci", "TOKEN=db"], "sets TOKEN twice"),
(vec!["TOKEN=ci", "token=db"], "one variable"),
(vec!["TOKEN=ci#scope"], "it holds: token, url"),
(vec!["TOKEN=diary#text"], "is a note"),
] {
let mut command = fixture.sefy();
command.arg("run");
for mapping in &mappings {
command.args(["-e", mapping]);
}
command
.arg("--")
.args(exit_with(0))
.assert()
.code(125)
.stderr(contains(expected));
}
}
#[test]
fn run_finds_a_script_the_way_a_shell_does() {
let fixture = fixture_for_run();
let tools = tempfile::tempdir().unwrap();
#[cfg(windows)]
std::fs::write(
tools.path().join("sefy-test-tool.cmd"),
"@echo off\r\necho tool sees %TOKEN%\r\n",
)
.unwrap();
#[cfg(not(windows))]
{
use std::os::unix::fs::PermissionsExt;
let path = tools.path().join("sefy-test-tool");
std::fs::write(&path, "#!/bin/sh\nprintf 'tool sees %s\\n' \"$TOKEN\"\n").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let mut path = tools.path().as_os_str().to_owned();
path.push(if cfg!(windows) { ";" } else { ":" });
path.push(system_path());
unless_the_script_is_busy(|| {
let mut command = fixture.sefy();
command
.env("PATH", &path)
.args(["run", "-e", "TOKEN=ci", "--", "sefy-test-tool"]);
command
})
.success()
.stdout(contains("tool sees tok-4815"));
}
fn unless_the_script_is_busy(make: impl Fn() -> Command) -> assert_cmd::assert::Assert {
for _ in 0..50 {
let assert = make().assert();
if !String::from_utf8_lossy(&assert.get_output().stderr).contains("Text file busy") {
return assert;
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
make().assert()
}
#[test]
fn get_refuses_a_field_named_on_a_note() {
let fixture = Fixture::with_vault();
add_note(&fixture, "diary", "dear diary", &[]);
fixture
.sefy()
.args(["get", "diary", "--field", "password", "--stdout"])
.assert()
.failure()
.stdout("")
.stderr(contains("is a note"));
}
mod history {
use super::{Fixture, MASTER, add_note};
use predicates::prelude::PredicateBooleanExt;
use predicates::str::contains;
fn login_changed_once() -> Fixture {
let fixture = Fixture::with_vault();
fixture
.sefy()
.env("ITEM_PASSWORD", "first-secret")
.args([
"add",
"login",
"mail",
"--login",
"ada",
"--item-password-env",
"ITEM_PASSWORD",
])
.assert()
.success();
fixture
.sefy()
.args(["edit", "mail", "--set", "password=second-secret"])
.assert()
.success();
fixture
}
#[test]
fn an_edit_keeps_the_text_it_replaced_and_history_lists_both() {
let fixture = Fixture::with_vault();
add_note(&fixture, "shed", "combination 4815", &[]);
fixture
.sefy()
.args(["edit", "shed", "--text", "combination 1623"])
.assert()
.success();
fixture
.sefy()
.args(["history", "shed"])
.assert()
.success()
.stdout(
contains("2 versions")
.and(contains("created"))
.and(contains("text (current)"))
.and(contains("sefy restore")),
);
fixture
.sefy()
.args(["history", "shed", "1"])
.assert()
.success()
.stdout(
contains("- combination 4815")
.and(contains("+ combination 1623"))
.and(contains("sefy restore")),
);
}
#[test]
fn neither_the_listing_nor_a_comparison_ever_prints_a_secret() {
let fixture = login_changed_once();
for arguments in [["history", "mail", ""], ["history", "mail", "1"]] {
let arguments: Vec<&str> = arguments.into_iter().filter(|a| !a.is_empty()).collect();
fixture.sefy().args(&arguments).assert().success().stdout(
contains("first-secret")
.not()
.and(contains("second-secret").not()),
);
}
fixture
.sefy()
.args(["history", "mail", "1"])
.assert()
.success()
.stdout(contains("password").and(contains("changed (secret, not shown)")));
}
#[test]
fn a_restored_password_is_the_one_get_hands_over() {
let fixture = login_changed_once();
fixture
.sefy()
.args(["restore", "mail", "1", "--field", "password"])
.assert()
.success()
.stdout(contains("restored the password of \"mail\" from version 1"));
fixture
.sefy()
.args(["get", "mail", "--stdout"])
.assert()
.success()
.stdout(contains("first-secret"));
fixture
.sefy()
.args(["history", "mail"])
.assert()
.success()
.stdout(contains("3 versions"));
}
#[test]
fn a_note_comes_back_whole() {
let fixture = Fixture::with_vault();
add_note(&fixture, "shed", "one", &[]);
fixture
.sefy()
.args(["edit", "shed", "--text", "two"])
.assert()
.success();
fixture
.sefy()
.args(["restore", "shed", "1", "--field", "text"])
.assert()
.failure()
.stderr(contains("restored whole"));
fixture
.sefy()
.args(["restore", "shed", "1"])
.assert()
.success();
fixture
.sefy()
.args(["get", "shed", "--stdout"])
.assert()
.success()
.stdout(contains("one"));
}
#[test]
fn a_version_or_a_field_that_is_not_there_is_refused_with_what_is() {
let fixture = login_changed_once();
fixture
.sefy()
.args(["history", "mail", "9"])
.assert()
.failure()
.stderr(contains("has 2 versions").and(contains("no version 9")));
fixture
.sefy()
.args(["restore", "mail", "0"])
.assert()
.failure()
.stderr(contains("numbered from 1"));
fixture
.sefy()
.args(["restore", "mail", "1", "--field", "pin"])
.assert()
.failure()
.stderr(contains("has no \"pin\"").and(contains("it held: login, password")));
}
#[test]
fn restoring_the_current_version_changes_nothing() {
let fixture = login_changed_once();
fixture
.sefy()
.args(["restore", "mail", "2"])
.assert()
.success()
.stdout(contains("nothing to restore"));
fixture
.sefy()
.args(["history", "mail"])
.assert()
.success()
.stdout(contains("2 versions"));
}
#[test]
fn show_says_there_is_a_history_to_look_at() {
let fixture = Fixture::with_vault();
add_note(&fixture, "shed", "one", &[]);
fixture
.sefy()
.args(["show", "shed"])
.assert()
.success()
.stdout(contains("history:").not());
fixture
.sefy()
.args(["edit", "shed", "--text", "two"])
.assert()
.success();
fixture
.sefy()
.args(["show", "shed"])
.assert()
.success()
.stdout(contains("1 earlier version (sefy history"));
}
#[test]
fn a_merge_conflict_ends_in_the_history_not_in_a_second_item() {
let here = Fixture::with_vault();
add_note(&here, "bank", "original", &[]);
let there = Fixture::empty();
std::fs::copy(&here.path, &there.path).unwrap();
here.sefy()
.args(["edit", "bank", "--text", "changed here"])
.assert()
.success();
there
.sefy()
.args(["edit", "bank", "--text", "changed there"])
.assert()
.success();
here.sefy()
.env("OTHER", MASTER)
.arg("merge")
.arg(&there.path)
.args(["--other-password-env", "OTHER"])
.assert()
.success()
.stdout(
contains("1 item changed on both sides")
.and(contains("sefy history"))
.and(contains("conflicted copy").not()),
);
here.sefy()
.arg("ls")
.assert()
.success()
.stdout(contains("conflicted copy").not());
here.sefy()
.args(["history", "bank"])
.assert()
.success()
.stdout(contains("3 versions").and(contains("lost a merge conflict")));
}
}
fn days_from_today(days: i64) -> String {
let today = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64
/ 86_400;
let (year, month, day) = sefy_core::time::civil_from_days(today + days);
format!("{year:04}-{month:02}-{day:02}")
}
fn add_token(fixture: &Fixture, title: &str, token: &str, expires: &str) {
fixture
.sefy()
.env("TOKEN", token)
.args(["add", "api-token", title, "--secret-env", "token=TOKEN"])
.arg("--set")
.arg(format!("expires={expires}"))
.assert()
.success();
}
fn add_login(fixture: &Fixture, title: &str, password: &str) {
fixture
.sefy()
.env("ITEM_PASSWORD", password)
.args([
"add",
"login",
title,
"--login",
"ada",
"--item-password-env",
"ITEM_PASSWORD",
])
.assert()
.success();
}
mod audit {
use super::*;
#[test]
fn a_vault_with_nothing_to_look_at_says_so_and_succeeds() {
let fixture = Fixture::with_vault();
add_login(&fixture, "mail", "vT4#qLw9!zR2@xN7$kP5");
add_token(&fixture, "ci", "ghp_synthetic", &days_from_today(400));
add_note(&fixture, "diary", "password1", &[]);
fixture.sefy().arg("audit").assert().success().stdout(
contains("looked at 1 password and 1 date in 3 items")
.and(contains("nothing to look at")),
);
}
#[test]
fn what_it_finds_is_named_by_item_and_field_and_never_shown() {
let fixture = Fixture::with_vault();
add_login(&fixture, "forum", "password1");
add_login(&fixture, "shop", "password1");
add_token(&fixture, "gone", "ghp_gone_synthetic", &days_from_today(-3));
add_token(&fixture, "soon", "ghp_soon_synthetic", &days_from_today(5));
add_token(&fixture, "fine", "ghp_fine_synthetic", &days_from_today(90));
let output = fixture.sefy().arg("audit").output().unwrap();
let stdout = String::from_utf8(output.stdout).unwrap();
let stderr = String::from_utf8(output.stderr).unwrap();
assert_eq!(output.status.code(), Some(1), "{stdout}{stderr}");
assert!(stderr.contains("error: 5 findings"), "{stderr}");
assert!(stdout.contains("2 passwords are easy to guess"), "{stdout}");
assert!(
stdout.contains("1 password is shared by more than one item"),
"{stdout}"
);
assert!(stdout.contains("3 days ago"), "{stdout}");
assert!(stdout.contains("in 5 days"), "{stdout}");
assert!(!stdout.contains("fine"), "{stdout}");
for secret in ["password1", "ghp_"] {
assert!(!stdout.contains(secret), "{secret} leaked: {stdout}");
assert!(!stderr.contains(secret), "{secret} leaked: {stderr}");
}
}
#[test]
fn it_changes_nothing() {
let fixture = Fixture::with_vault();
add_login(&fixture, "forum", "password1");
let before = std::fs::read(&fixture.path).unwrap();
fixture.sefy().arg("audit").assert().failure();
assert_eq!(std::fs::read(&fixture.path).unwrap(), before);
}
}
mod expires {
use super::*;
#[test]
fn a_date_is_checked_on_the_way_in_and_kept_in_one_shape() {
let fixture = Fixture::with_vault();
fixture
.sefy()
.env("TOKEN", "t")
.args([
"add",
"api-token",
"odd",
"--secret-env",
"token=TOKEN",
"--set",
"expires=next spring",
])
.assert()
.failure()
.stderr(contains("\"next spring\" is not a date sefy can read"));
fixture
.sefy()
.arg("ls")
.assert()
.success()
.stdout(contains("no items"));
add_token(&fixture, "ci", "t", "2027-01-31");
fixture
.sefy()
.args(["edit", "ci", "--set", "expires=03/29"])
.assert()
.success();
fixture
.sefy()
.args(["get", "ci", "--field", "expires", "--stdout"])
.assert()
.success()
.stdout("2029-03\n");
fixture
.sefy()
.args(["edit", "ci", "--set", "expires=yesterday"])
.assert()
.failure();
}
#[test]
fn taking_a_secret_near_its_date_warns_and_a_pipe_still_gets_the_secret_alone() {
let fixture = Fixture::with_vault();
add_token(&fixture, "gone", "ghp_synthetic", &days_from_today(-2));
fixture
.sefy()
.args(["get", "gone", "--stdout"])
.assert()
.success()
.stdout("ghp_synthetic\n")
.stderr(contains("warning: \"gone\" expired 2 days ago"));
add_token(&fixture, "today", "t", &days_from_today(0));
fixture
.sefy()
.args(["get", "today", "--stdout"])
.assert()
.success()
.stderr(contains("expires today"));
add_token(&fixture, "month", "t", &days_from_today(30));
fixture
.sefy()
.args(["get", "month", "--stdout"])
.assert()
.success()
.stderr(contains("expires in 30 days"));
add_token(&fixture, "far", "t", &days_from_today(31));
fixture
.sefy()
.args(["get", "far", "--stdout"])
.assert()
.success()
.stderr("");
}
#[test]
fn a_one_time_code_from_a_record_past_its_date_is_warned_about() {
let fixture = Fixture::with_vault();
add_plain_login(&fixture, "forge");
fixture
.sefy()
.arg("edit")
.arg("forge")
.arg("--set")
.arg(format!("expires={}", days_from_today(-1)))
.assert()
.success();
fixture
.sefy()
.env("OTP_KEY", "JBSWY3DPEHPK3PXP")
.args(["otp", "forge", "--key-env", "OTP_KEY", "--stdout"])
.assert()
.success()
.stderr(contains("warning: \"forge\" expired yesterday"));
}
#[test]
fn a_command_run_with_a_due_secret_is_warned_about_it() {
let fixture = Fixture::with_vault();
add_token(&fixture, "gone", "ghp_synthetic", &days_from_today(-1));
let mut command = fixture.sefy();
command.args(["run", "--env", "TOKEN=gone", "--"]);
if cfg!(windows) {
command.args(["cmd", "/C", "exit 0"]);
} else {
command.args(["sh", "-c", "exit 0"]);
}
command
.assert()
.success()
.stderr(contains("warning: \"gone\" expired yesterday"));
}
}