Features: - Introduce a Backend trait + System impl so flow/status/watch can be unit tested against a fake; add 11 connect-state-machine tests. - Captive-portal detection: status::connectivity returns Online/Portal/Offline; surfaced in status, JSON, connect notes, and a dedicated watch state. - `status --json` for bars/scripts; `profile add`/`profile remove`; detect now scores by number of in-range markers. Robustness: - Pin LC_ALL=C/LANG=C on child processes for locale-independent parsing. - Atomic config/state writes (temp + rename); 0600 config never world-readable. - Transient PSK file written to $XDG_RUNTIME_DIR when available. Fixes (from prior audit): - Feed Wi-Fi PSK to nmcli via stdin/passwd-file, never argv. - mask() no longer panics on multi-byte passwords. - Connectivity check requires HTTP 204 (no captive-portal false positives). - nmcli NAME,TYPE parsing handles escaped colons. - Strip CIDR suffix from displayed IP; PKGBUILD/Cargo version aligned (2.1.0).
491 lines
14 KiB
Rust
491 lines
14 KiB
Rust
//! End-to-end CLI tests. Each run is fully isolated: HOME / XDG dirs point at a
|
|
//! throwaway tempdir and PATH is emptied so no real `nmcli`/`tailscale`/`date`
|
|
//! is ever invoked and the host system is never touched.
|
|
|
|
use std::fs;
|
|
use std::path::PathBuf;
|
|
use std::process::Command;
|
|
use std::sync::atomic::{AtomicU32, Ordering};
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
const BIN: &str = env!("CARGO_BIN_EXE_breadcrumbs");
|
|
|
|
static COUNTER: AtomicU32 = AtomicU32::new(0);
|
|
|
|
struct Sandbox {
|
|
root: PathBuf,
|
|
}
|
|
|
|
impl Sandbox {
|
|
fn new() -> Sandbox {
|
|
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
|
|
let nanos = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_nanos();
|
|
let root = std::env::temp_dir().join(format!(
|
|
"breadcrumbs-it-{}-{}-{}",
|
|
std::process::id(),
|
|
n,
|
|
nanos
|
|
));
|
|
fs::create_dir_all(root.join("bin")).unwrap();
|
|
Sandbox { root }
|
|
}
|
|
|
|
/// Binary invocation with an isolated, side-effect-free environment.
|
|
fn cmd(&self, args: &[&str]) -> std::process::Output {
|
|
Command::new(BIN)
|
|
.args(args)
|
|
.env_clear()
|
|
.env("HOME", &self.root)
|
|
.env("XDG_CONFIG_HOME", self.root.join("config"))
|
|
.env("XDG_STATE_HOME", self.root.join("state"))
|
|
// Empty bin dir => no external commands resolve.
|
|
.env("PATH", self.root.join("bin"))
|
|
.output()
|
|
.expect("failed to spawn breadcrumbs")
|
|
}
|
|
|
|
fn config_file(&self) -> PathBuf {
|
|
self.root.join("config/breadcrumbs/breadcrumbs.toml")
|
|
}
|
|
}
|
|
|
|
impl Drop for Sandbox {
|
|
fn drop(&mut self) {
|
|
let _ = fs::remove_dir_all(&self.root);
|
|
}
|
|
}
|
|
|
|
fn stdout(o: &std::process::Output) -> String {
|
|
String::from_utf8_lossy(&o.stdout).to_string()
|
|
}
|
|
|
|
#[test]
|
|
fn help_lists_all_commands() {
|
|
let sb = Sandbox::new();
|
|
let o = sb.cmd(&["--help"]);
|
|
assert!(o.status.success());
|
|
let out = stdout(&o);
|
|
assert!(out.contains("Profile-aware Wi-Fi state machine"));
|
|
for c in [
|
|
"status",
|
|
"init",
|
|
"watch",
|
|
"profile",
|
|
"doctor",
|
|
"install-service",
|
|
] {
|
|
assert!(out.contains(c), "help missing `{c}`");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn version_prints_crate_version() {
|
|
let sb = Sandbox::new();
|
|
let o = sb.cmd(&["--version"]);
|
|
assert!(o.status.success());
|
|
assert!(stdout(&o).contains("breadcrumbs"));
|
|
}
|
|
|
|
#[test]
|
|
fn list_bootstraps_config_with_core_profiles() {
|
|
let sb = Sandbox::new();
|
|
let o = sb.cmd(&["list"]);
|
|
assert!(
|
|
o.status.success(),
|
|
"stderr: {}",
|
|
String::from_utf8_lossy(&o.stderr)
|
|
);
|
|
let out = stdout(&o);
|
|
|
|
// All three core profiles must be present on a fresh install.
|
|
assert!(out.contains("home") && out.contains("work") && out.contains("away"));
|
|
|
|
// Config was materialised on disk and is valid TOML.
|
|
let cfg = sb.config_file();
|
|
assert!(cfg.exists(), "config not created at {}", cfg.display());
|
|
let text = fs::read_to_string(&cfg).unwrap();
|
|
assert!(text.contains("[profiles.home]"));
|
|
assert!(text.contains("[profiles.work]"));
|
|
assert!(text.contains("[profiles.away]"));
|
|
}
|
|
|
|
#[test]
|
|
fn profile_defaults_to_away_then_persists_set() {
|
|
let sb = Sandbox::new();
|
|
|
|
let o = sb.cmd(&["profile", "get"]);
|
|
assert!(o.status.success());
|
|
assert_eq!(stdout(&o).trim(), "away");
|
|
|
|
// `set --no-apply` must not touch the network (no nmcli available anyway).
|
|
let o = sb.cmd(&["profile", "set", "home", "--no-apply"]);
|
|
assert!(
|
|
o.status.success(),
|
|
"stderr: {}",
|
|
String::from_utf8_lossy(&o.stderr)
|
|
);
|
|
|
|
let o = sb.cmd(&["profile", "get"]);
|
|
assert_eq!(stdout(&o).trim(), "home");
|
|
|
|
// Unknown profile is rejected.
|
|
let o = sb.cmd(&["profile", "set", "bogus"]);
|
|
assert!(!o.status.success());
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_profile_override_is_reported() {
|
|
let sb = Sandbox::new();
|
|
let o = sb.cmd(&["--profile", "nope", "init"]);
|
|
assert!(!o.status.success());
|
|
}
|
|
|
|
#[test]
|
|
fn profile_list_marks_active_profile() {
|
|
let sb = Sandbox::new();
|
|
|
|
// Default profile is "away".
|
|
let o = sb.cmd(&["profile", "list"]);
|
|
assert!(o.status.success());
|
|
let out = stdout(&o);
|
|
// The active profile line starts with "* ".
|
|
assert!(
|
|
out.lines().any(|l| l.starts_with("* away")),
|
|
"active profile marker missing: {out}"
|
|
);
|
|
// Inactive profiles start with " ".
|
|
assert!(
|
|
out.lines().any(|l| l.starts_with(" home")),
|
|
"inactive profile format wrong: {out}"
|
|
);
|
|
|
|
// After switching to "work" the marker should move.
|
|
sb.cmd(&["profile", "set", "work", "--no-apply"]);
|
|
let o = sb.cmd(&["profile", "list"]);
|
|
let out = stdout(&o);
|
|
assert!(out.lines().any(|l| l.starts_with("* work")));
|
|
assert!(out.lines().any(|l| l.starts_with(" away")));
|
|
}
|
|
|
|
#[test]
|
|
fn add_saves_network_to_config() {
|
|
let sb = Sandbox::new();
|
|
|
|
let o = sb.cmd(&["add", "CafeWifi", "mypassword"]);
|
|
assert!(
|
|
o.status.success(),
|
|
"stderr: {}",
|
|
String::from_utf8_lossy(&o.stderr)
|
|
);
|
|
assert!(stdout(&o).contains("saved"));
|
|
|
|
// The network should now appear in the config file.
|
|
let text = fs::read_to_string(sb.config_file()).unwrap();
|
|
assert!(text.contains("CafeWifi"), "SSID missing from config");
|
|
assert!(text.contains("mypassword"), "password missing from config");
|
|
}
|
|
|
|
#[test]
|
|
fn add_attaches_ssid_to_profile_at_position() {
|
|
let sb = Sandbox::new();
|
|
|
|
// First add without attaching, then add again with --to.
|
|
sb.cmd(&["add", "HomeWifi", "pw1"]);
|
|
let o = sb.cmd(&["add", "HomeWifi", "pw1", "--to", "home"]);
|
|
assert!(
|
|
o.status.success(),
|
|
"stderr: {}",
|
|
String::from_utf8_lossy(&o.stderr)
|
|
);
|
|
|
|
let text = fs::read_to_string(sb.config_file()).unwrap();
|
|
// After attaching, the home profile's networks list should contain HomeWifi.
|
|
assert!(
|
|
text.contains("HomeWifi"),
|
|
"SSID not found in config after --to: {text}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn add_to_unknown_profile_fails() {
|
|
let sb = Sandbox::new();
|
|
let o = sb.cmd(&["add", "SomeNet", "pw", "--to", "nonexistent"]);
|
|
assert!(!o.status.success());
|
|
}
|
|
|
|
#[test]
|
|
fn add_updates_existing_network_password() {
|
|
let sb = Sandbox::new();
|
|
|
|
sb.cmd(&["add", "MyNet", "oldpass"]);
|
|
let o = sb.cmd(&["add", "MyNet", "newpass"]);
|
|
assert!(o.status.success());
|
|
|
|
let text = fs::read_to_string(sb.config_file()).unwrap();
|
|
assert!(text.contains("newpass"), "updated password missing");
|
|
// Old password must be gone.
|
|
assert!(!text.contains("oldpass"), "old password still present");
|
|
// Only one entry for MyNet.
|
|
assert_eq!(
|
|
text.matches("MyNet").count(),
|
|
1,
|
|
"duplicate network entries"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn forget_removes_network_from_config() {
|
|
let sb = Sandbox::new();
|
|
|
|
sb.cmd(&["add", "ToDelete", "pw"]);
|
|
let text = fs::read_to_string(sb.config_file()).unwrap();
|
|
assert!(text.contains("ToDelete"));
|
|
|
|
let o = sb.cmd(&["forget", "ToDelete"]);
|
|
assert!(o.status.success());
|
|
assert!(stdout(&o).contains("forgot"));
|
|
|
|
let text = fs::read_to_string(sb.config_file()).unwrap();
|
|
assert!(
|
|
!text.contains("ToDelete"),
|
|
"network still in config after forget"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn forget_nonexistent_network_is_graceful() {
|
|
let sb = Sandbox::new();
|
|
// Should succeed (idempotent) even if the SSID was never saved.
|
|
let o = sb.cmd(&["forget", "NeverSaved"]);
|
|
assert!(o.status.success());
|
|
}
|
|
|
|
#[test]
|
|
fn forget_removes_ssid_from_profile_networks_list() {
|
|
let sb = Sandbox::new();
|
|
|
|
// Add "WorkNet" and attach it to the "work" profile.
|
|
sb.cmd(&["add", "WorkNet", "pw", "--to", "work"]);
|
|
let text = fs::read_to_string(sb.config_file()).unwrap();
|
|
assert!(text.contains("WorkNet"));
|
|
|
|
sb.cmd(&["forget", "WorkNet"]);
|
|
|
|
let text = fs::read_to_string(sb.config_file()).unwrap();
|
|
assert!(
|
|
!text.contains("WorkNet"),
|
|
"SSID still in profile after forget"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn list_masks_passwords_by_default() {
|
|
let sb = Sandbox::new();
|
|
sb.cmd(&["add", "SecretNet", "hunter2"]);
|
|
|
|
let o = sb.cmd(&["list"]);
|
|
assert!(o.status.success());
|
|
let out = stdout(&o);
|
|
assert!(
|
|
!out.contains("hunter2"),
|
|
"plain-text password exposed in list"
|
|
);
|
|
// A masking bullet should appear.
|
|
assert!(out.contains('•'), "no masking character in list output");
|
|
}
|
|
|
|
#[test]
|
|
fn list_masks_multibyte_password_without_panicking() {
|
|
let sb = Sandbox::new();
|
|
// A password whose first character is multi-byte UTF-8 used to panic the
|
|
// byte-slicing mask(); list must mask it cleanly instead.
|
|
sb.cmd(&["add", "UnicodeNet", "ñoño-café-🔐"]);
|
|
|
|
let o = sb.cmd(&["list"]);
|
|
assert!(
|
|
o.status.success(),
|
|
"list crashed on multibyte password: {}",
|
|
String::from_utf8_lossy(&o.stderr)
|
|
);
|
|
let out = stdout(&o);
|
|
assert!(
|
|
!out.contains("ñoño-café-🔐"),
|
|
"password leaked in masked list"
|
|
);
|
|
assert!(out.contains('•'), "no masking character in list output");
|
|
}
|
|
|
|
#[test]
|
|
fn list_show_passwords_reveals_password() {
|
|
let sb = Sandbox::new();
|
|
sb.cmd(&["add", "SecretNet", "hunter2"]);
|
|
|
|
let o = sb.cmd(&["list", "--show-passwords"]);
|
|
assert!(o.status.success());
|
|
assert!(
|
|
stdout(&o).contains("hunter2"),
|
|
"password not shown with --show-passwords"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn cd_prints_config_directory() {
|
|
let sb = Sandbox::new();
|
|
// Trigger config creation first.
|
|
sb.cmd(&["list"]);
|
|
|
|
let o = sb.cmd(&["cd"]);
|
|
assert!(o.status.success());
|
|
let out = stdout(&o).trim().to_string();
|
|
assert!(!out.is_empty(), "cd produced no output");
|
|
// The printed path must end with "breadcrumbs" (the config subdirectory).
|
|
assert!(out.ends_with("breadcrumbs"), "unexpected config dir: {out}");
|
|
}
|
|
|
|
#[test]
|
|
fn doctor_runs_without_crashing() {
|
|
let sb = Sandbox::new();
|
|
// With an empty PATH, nmcli/tailscale are absent. Doctor should report
|
|
// "MISSING"/"absent" but still exit successfully (it's a diag tool).
|
|
let o = sb.cmd(&["doctor"]);
|
|
assert!(
|
|
o.status.success(),
|
|
"stderr: {}",
|
|
String::from_utf8_lossy(&o.stderr)
|
|
);
|
|
let out = stdout(&o);
|
|
assert!(out.contains("nmcli"), "doctor missing nmcli line");
|
|
assert!(out.contains("tailscale"), "doctor missing tailscale line");
|
|
}
|
|
|
|
#[test]
|
|
fn status_exits_nonzero_when_unhealthy() {
|
|
let sb = Sandbox::new();
|
|
// No nmcli/tailscale → internet check fails → unhealthy → exit code 1.
|
|
let o = sb.cmd(&["status"]);
|
|
assert!(
|
|
!o.status.success(),
|
|
"expected non-zero exit for unhealthy status"
|
|
);
|
|
let out = stdout(&o);
|
|
assert!(
|
|
out.contains("breadcrumbs"),
|
|
"missing header in status output"
|
|
);
|
|
assert!(
|
|
out.contains("profile"),
|
|
"missing profile line in status output"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn profile_override_flag_does_not_persist() {
|
|
let sb = Sandbox::new();
|
|
|
|
// Set profile to "home" persistently.
|
|
sb.cmd(&["profile", "set", "home", "--no-apply"]);
|
|
|
|
// Use --profile flag to override for a single run (status).
|
|
let o = sb.cmd(&["--profile", "work", "status"]);
|
|
// Status exits non-zero (no network), but it should show the overridden profile.
|
|
let out = stdout(&o);
|
|
assert!(out.contains("work"), "override profile not shown in status");
|
|
|
|
// The persistent profile must still be "home".
|
|
let o = sb.cmd(&["profile", "get"]);
|
|
assert_eq!(stdout(&o).trim(), "home");
|
|
}
|
|
|
|
#[test]
|
|
fn add_hidden_flag_is_persisted() {
|
|
let sb = Sandbox::new();
|
|
let o = sb.cmd(&["add", "HiddenNet", "pw", "--hidden"]);
|
|
assert!(o.status.success());
|
|
|
|
let text = fs::read_to_string(sb.config_file()).unwrap();
|
|
assert!(text.contains("hidden = true"), "hidden flag not persisted");
|
|
}
|
|
|
|
#[test]
|
|
fn status_json_is_valid_and_machine_readable() {
|
|
let sb = Sandbox::new();
|
|
// No nmcli/tailscale → unhealthy, exit 1, but JSON must still be valid.
|
|
let o = sb.cmd(&["status", "--json"]);
|
|
assert!(
|
|
!o.status.success(),
|
|
"expected non-zero exit for unhealthy status"
|
|
);
|
|
let v: serde_json::Value =
|
|
serde_json::from_str(&stdout(&o)).expect("status --json did not emit valid JSON");
|
|
assert_eq!(v["profile"], "away");
|
|
assert_eq!(v["internet"], false);
|
|
assert_eq!(v["healthy"], false);
|
|
assert!(v["tailscale"].is_object());
|
|
}
|
|
|
|
#[test]
|
|
fn profile_add_persists_detect_ssids_and_remove_deletes() {
|
|
let sb = Sandbox::new();
|
|
|
|
let o = sb.cmd(&[
|
|
"profile", "add", "lab", "--detect", "LabWifi", "--detect", "LabGuest",
|
|
]);
|
|
assert!(
|
|
o.status.success(),
|
|
"stderr: {}",
|
|
String::from_utf8_lossy(&o.stderr)
|
|
);
|
|
|
|
let text = fs::read_to_string(sb.config_file()).unwrap();
|
|
assert!(
|
|
text.contains("[profiles.lab]"),
|
|
"profile not written: {text}"
|
|
);
|
|
assert!(
|
|
text.contains("LabWifi") && text.contains("LabGuest"),
|
|
"detect ssids missing"
|
|
);
|
|
|
|
// Adding the same profile again is rejected.
|
|
assert!(!sb.cmd(&["profile", "add", "lab"]).status.success());
|
|
|
|
// Remove it.
|
|
let o = sb.cmd(&["profile", "remove", "lab"]);
|
|
assert!(o.status.success());
|
|
let text = fs::read_to_string(sb.config_file()).unwrap();
|
|
assert!(
|
|
!text.contains("[profiles.lab]"),
|
|
"profile still present after remove"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn profile_remove_core_is_rejected() {
|
|
let sb = Sandbox::new();
|
|
let o = sb.cmd(&["profile", "remove", "home"]);
|
|
assert!(!o.status.success(), "removing a core profile should fail");
|
|
}
|
|
|
|
#[test]
|
|
fn install_service_writes_unit_file() {
|
|
let sb = Sandbox::new();
|
|
// Install but don't enable (systemctl is absent in the empty PATH, but
|
|
// writing the unit file should succeed regardless).
|
|
let o = sb.cmd(&["install-service", "--no-enable"]);
|
|
assert!(
|
|
o.status.success(),
|
|
"stderr: {}",
|
|
String::from_utf8_lossy(&o.stderr)
|
|
);
|
|
let unit = sb.root.join(".config/systemd/user/breadcrumbs.service");
|
|
assert!(unit.exists(), "unit file not written at {}", unit.display());
|
|
let content = fs::read_to_string(&unit).unwrap();
|
|
assert!(content.contains("ExecStart="), "unit missing ExecStart");
|
|
assert!(
|
|
content.contains("breadcrumbs watch"),
|
|
"unit missing watch subcommand"
|
|
);
|
|
}
|