Release v2.1.0: backend test seam, captive-portal detection, JSON status, robustness
All checks were successful
Mirror to GitHub / mirror (push) Successful in 2s
Build and publish package / package (push) Successful in 1m31s

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).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015iGKg2EEqRuw6HyWd4tnmL
This commit is contained in:
Breadway 2026-06-23 12:13:34 +08:00
parent 8aceab7857
commit 9b38504240
17 changed files with 1662 additions and 217 deletions

View file

@ -142,3 +142,350 @@ fn unknown_profile_override_is_reported() {
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"
);
}