Test the NM layer against an in-process D-Bus fake
All checks were successful
check / check (push) Successful in 1m39s
All checks were successful
check / check (push) Successful in 1m39s
`tests/common/fake_nm.rs` stands up a fake `org.freedesktop.NetworkManager` on a private bus so `cli.rs` and `flow_watch.rs` exercise the real `nm` code paths without a live NetworkManager and without shelling out. Replaces the previous command-capture scaffolding in those two files; scenario coverage (captive portal, exit-node failover, 802.1x, per-network DNS, schedule triggers, Tailscale recovery, SSID verification) is preserved — 139 tests.
This commit is contained in:
parent
b4c1d0b233
commit
cc709a4af9
4 changed files with 1345 additions and 612 deletions
307
tests/cli.rs
307
tests/cli.rs
|
|
@ -1,6 +1,10 @@
|
|||
//! 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.
|
||||
//! throwaway tempdir, PATH is emptied so no real `tailscale`/`curl`/`date` is
|
||||
//! ever invoked, and a private `dbus-daemon` (optionally hosting a fake
|
||||
//! NetworkManager service — see `tests/common::fake_nm`) stands in for the
|
||||
//! system bus so the binary's D-Bus calls never touch the host.
|
||||
|
||||
mod common;
|
||||
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
|
@ -8,12 +12,15 @@ use std::process::Command;
|
|||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use common::fake_nm::{self, Security};
|
||||
|
||||
const BIN: &str = env!("CARGO_BIN_EXE_breadcrumbs");
|
||||
|
||||
static COUNTER: AtomicU32 = AtomicU32::new(0);
|
||||
|
||||
struct Sandbox {
|
||||
root: PathBuf,
|
||||
_bus: fake_nm::Daemon,
|
||||
}
|
||||
|
||||
impl Sandbox {
|
||||
|
|
@ -30,7 +37,16 @@ impl Sandbox {
|
|||
nanos
|
||||
));
|
||||
fs::create_dir_all(root.join("bin")).unwrap();
|
||||
Sandbox { root }
|
||||
Sandbox {
|
||||
root,
|
||||
_bus: fake_nm::launch_daemon(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Attach the fake NetworkManager service to this sandbox's bus and
|
||||
/// return a handle for driving its state.
|
||||
fn nm(&self) -> fake_nm::FakeNmBus {
|
||||
fake_nm::serve_on(&self._bus.addr)
|
||||
}
|
||||
|
||||
/// Binary invocation with an isolated, side-effect-free environment.
|
||||
|
|
@ -48,7 +64,10 @@ impl Sandbox {
|
|||
.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"));
|
||||
.env("PATH", self.root.join("bin"))
|
||||
// Point the binary's `Connection::system()` at this test's
|
||||
// private bus so it never touches a real system bus.
|
||||
.env("DBUS_SYSTEM_BUS_ADDRESS", &self._bus.addr);
|
||||
for (k, v) in extra {
|
||||
c.env(k, v);
|
||||
}
|
||||
|
|
@ -66,7 +85,7 @@ impl Sandbox {
|
|||
}
|
||||
|
||||
/// Write an executable shell script into the sandbox's PATH dir so a
|
||||
/// test can stand in for an external command (e.g. `$EDITOR`).
|
||||
/// test can stand in for an external command (e.g. `$EDITOR`, `curl`).
|
||||
fn write_fake_bin(&self, name: &str, script: &str) -> PathBuf {
|
||||
let path = self.root.join("bin").join(name);
|
||||
fs::write(&path, script).unwrap();
|
||||
|
|
@ -151,7 +170,7 @@ fn profile_defaults_to_away_then_persists_set() {
|
|||
assert!(o.status.success());
|
||||
assert_eq!(stdout(&o).trim(), "away");
|
||||
|
||||
// `set --no-apply` must not touch the network (no nmcli available anyway).
|
||||
// `set --no-apply` must not touch the network (no NM on the bus anyway).
|
||||
let o = sb.cmd(&["profile", "set", "home", "--no-apply"]);
|
||||
assert!(
|
||||
o.status.success(),
|
||||
|
|
@ -194,9 +213,7 @@ fn profile_list_marks_exactly_the_current_profile() {
|
|||
let out = stdout(&o);
|
||||
assert!(out.contains("* home"), "out: {out}");
|
||||
assert_eq!(
|
||||
out.lines()
|
||||
.filter(|l| l.trim_start().starts_with('*'))
|
||||
.count(),
|
||||
out.lines().filter(|l| l.trim_start().starts_with('*')).count(),
|
||||
1,
|
||||
"expected exactly one marked profile, got: {out}"
|
||||
);
|
||||
|
|
@ -264,10 +281,7 @@ fn forget_removes_network_from_config() {
|
|||
);
|
||||
// ...and it should never have been in breadcrumbs.toml to begin with.
|
||||
let text = fs::read_to_string(sb.config_file()).unwrap();
|
||||
assert!(
|
||||
!text.contains("CafeWifi"),
|
||||
"network leaked into config: {text}"
|
||||
);
|
||||
assert!(!text.contains("CafeWifi"), "network leaked into config: {text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -275,15 +289,13 @@ fn detect_without_wifi_adapter_errors() {
|
|||
let sb = Sandbox::new();
|
||||
let o = sb.cmd(&["detect"]);
|
||||
assert!(!o.status.success());
|
||||
assert!(
|
||||
stderr(&o).contains("could not detect"),
|
||||
"stderr: {}",
|
||||
stderr(&o)
|
||||
);
|
||||
assert!(stderr(&o).contains("could not detect"), "stderr: {}", stderr(&o));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doctor_reports_missing_nmcli_in_sandbox() {
|
||||
fn doctor_reports_missing_network_manager_on_private_bus() {
|
||||
// The sandbox bus has no NetworkManager service on it, so doctor must
|
||||
// report it missing rather than assuming presence.
|
||||
let sb = Sandbox::new();
|
||||
let o = sb.cmd(&["doctor"]);
|
||||
assert!(o.status.success(), "stderr: {}", stderr(&o));
|
||||
|
|
@ -376,11 +388,7 @@ fn networks_are_stored_separately_from_settings_and_profiles() {
|
|||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mode = fs::metadata(sb.networks_file())
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode()
|
||||
& 0o777;
|
||||
let mode = fs::metadata(sb.networks_file()).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode, 0o600, "networks.toml should be owner-only");
|
||||
}
|
||||
}
|
||||
|
|
@ -390,8 +398,8 @@ fn add_with_empty_password_is_stored_as_no_password() {
|
|||
// An explicitly empty password (e.g. `add SSID ""`, or a blank response
|
||||
// at the interactive prompt) means "this is an open network" — it must
|
||||
// round-trip as an absent `password` key, the same as a cleared one,
|
||||
// not as `password = ""` (which `nm::connect_verbose` would treat as a
|
||||
// blank secret rather than an open network).
|
||||
// not as `password = ""` (which `nm::connect_verbose` would send as a
|
||||
// literal empty PSK and fail against a real open SSID).
|
||||
let sb = Sandbox::new();
|
||||
let o = sb.cmd(&["add", "OpenCafe", ""]);
|
||||
assert!(o.status.success(), "stderr: {}", stderr(&o));
|
||||
|
|
@ -408,77 +416,25 @@ fn add_with_empty_password_is_stored_as_no_password() {
|
|||
// NM-owned credentials (item 4): a password is only ever needed once.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// A fake `nmcli` that behaves statefully enough to exercise the "first
|
||||
/// connect creates a profile with a password, second connect reuses it
|
||||
/// without one" path: it records every invocation's argv (one per line) to
|
||||
/// `$HOME/.nmcli-calls`, and remembers — via a marker file, also under
|
||||
/// `$HOME` — that "device wifi connect TestNet" has already run, so a
|
||||
/// following `connection show` reports a saved profile exists.
|
||||
const FAKE_NMCLI_STATEFUL: &str = r#"#!/bin/sh
|
||||
record="$HOME/.nmcli-calls"
|
||||
marker="$HOME/.nmcli-profile-created"
|
||||
echo "$@" >> "$record"
|
||||
args="$*"
|
||||
case "$args" in
|
||||
"-t -f DEVICE,TYPE device status")
|
||||
echo "wlan0:wifi" ;;
|
||||
"radio wifi on") ;;
|
||||
"device wifi rescan"*) ;;
|
||||
"-t -f SSID device wifi list ifname wlan0")
|
||||
echo "TestNet" ;;
|
||||
"-t -f SSID,SIGNAL device wifi list ifname wlan0")
|
||||
echo "TestNet:80" ;;
|
||||
"-t -f ACTIVE,SSID device wifi list ifname wlan0")
|
||||
echo "yes:TestNet" ;;
|
||||
"-t -f NAME,TYPE connection show")
|
||||
if [ -f "$marker" ]; then
|
||||
echo "TestNet:802-11-wireless"
|
||||
fi
|
||||
;;
|
||||
*"device wifi connect TestNet"*)
|
||||
# `: > file` (truncate-or-create via a shell builtin + redirection) —
|
||||
# not `touch`, which is an external binary and the sandbox's PATH
|
||||
# deliberately contains nothing but this fake nmcli itself.
|
||||
: > "$marker" ;;
|
||||
*"connection up TestNet"*) ;;
|
||||
*"802-11-wireless-security.psk"*) ;;
|
||||
"-g GENERAL.CON-UUID device show wlan0")
|
||||
echo "uuid-1" ;;
|
||||
*"ipv4.ignore-auto-dns"*) ;;
|
||||
"device reapply wlan0") ;;
|
||||
"-t -f DEVICE,STATE device status")
|
||||
echo "wlan0:connected" ;;
|
||||
*) ;;
|
||||
esac
|
||||
exit 0
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn password_is_cleared_after_first_connect_and_never_sent_again() {
|
||||
// The first connect creates an NM profile carrying the PSK (over D-Bus,
|
||||
// never in argv); the second connect reuses that profile and must not
|
||||
// create a duplicate or resend the password.
|
||||
let sb = Sandbox::new();
|
||||
sb.write_fake_bin("nmcli", FAKE_NMCLI_STATEFUL);
|
||||
let nm = sb.nm();
|
||||
let dev = nm.add_wifi_device("wlan0", 100);
|
||||
nm.add_ap(&dev, "TestNet", 80, Security::Wpa2);
|
||||
|
||||
let add = sb.cmd(&["add", "TestNet", "hunter2"]);
|
||||
assert!(add.status.success(), "stderr: {}", stderr(&add));
|
||||
// "away" is the default profile and defaults to include_all_known, so
|
||||
// TestNet is already a connect candidate with no `--to` needed.
|
||||
|
||||
let record = sb.root.join(".nmcli-calls");
|
||||
|
||||
// First connect: no saved NM profile yet, so breadcrumbs creates one via
|
||||
// `nmcli --ask device wifi connect ...` with the PSK on stdin, not argv.
|
||||
// First connect: no saved NM profile yet, so breadcrumbs creates one
|
||||
// with the password.
|
||||
let first = sb.cmd(&["init"]);
|
||||
assert!(first.status.success(), "stderr: {}", stderr(&first));
|
||||
let first_calls = fs::read_to_string(&record).unwrap_or_default();
|
||||
assert!(
|
||||
first_calls.contains("device wifi connect TestNet") && first_calls.contains("--ask"),
|
||||
"first connect should create a new NM profile via --ask: {first_calls}"
|
||||
);
|
||||
assert!(
|
||||
!first_calls.contains("hunter2"),
|
||||
"PSK must not appear on nmcli argv: {first_calls}"
|
||||
);
|
||||
// stdin payload is asserted in-process via FakeRunner (see flow_watch).
|
||||
|
||||
// The local copy is gone from disk immediately after.
|
||||
let networks = fs::read_to_string(sb.networks_file()).unwrap();
|
||||
|
|
@ -486,54 +442,43 @@ fn password_is_cleared_after_first_connect_and_never_sent_again() {
|
|||
!networks.contains("hunter2"),
|
||||
"password should have been cleared from networks.toml: {networks}"
|
||||
);
|
||||
assert!(
|
||||
networks.contains("TestNet"),
|
||||
"network entry itself should remain"
|
||||
);
|
||||
assert!(networks.contains("TestNet"), "network entry itself should remain");
|
||||
|
||||
// Reset the recording so the second run's argv can be checked in isolation.
|
||||
fs::write(&record, "").unwrap();
|
||||
// The NM profile durably holds the secret.
|
||||
let psk = {
|
||||
let st = nm.state.lock().unwrap();
|
||||
st.connections
|
||||
.values()
|
||||
.filter_map(|s| {
|
||||
let sec = s.get("802-11-wireless-security")?;
|
||||
sec.get("psk").and_then(|v| v.downcast_ref::<String>().ok())
|
||||
})
|
||||
.next()
|
||||
};
|
||||
assert_eq!(psk.as_deref(), Some("hunter2"), "NM profile must hold the PSK");
|
||||
|
||||
// Second connect: a saved profile now exists (per the fake nmcli's own
|
||||
// bookkeeping) and breadcrumbs has no local password anymore, so it must
|
||||
// reuse the profile via `connection up` and never send a PSK argument.
|
||||
// Second connect: breadcrumbs has no local password anymore, so it must
|
||||
// reuse the existing profile without creating a duplicate.
|
||||
let second = sb.cmd(&["init"]);
|
||||
assert!(second.status.success(), "stderr: {}", stderr(&second));
|
||||
let second_calls = fs::read_to_string(&record).unwrap_or_default();
|
||||
assert!(
|
||||
second_calls.contains("connection up TestNet"),
|
||||
"second connect should reuse the existing NM profile: {second_calls}"
|
||||
);
|
||||
assert!(
|
||||
!second_calls.to_lowercase().contains("hunter2")
|
||||
&& !second_calls.contains("psk")
|
||||
&& !second_calls.contains("password"),
|
||||
"second connect must never send a password argument: {second_calls}"
|
||||
assert_eq!(
|
||||
nm.connection_count(),
|
||||
1,
|
||||
"reuse must not accumulate duplicate NM profiles"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// CLI-level coverage through fake nmcli/tailscale (item 3)
|
||||
// CLI-level coverage through fake NM + tailscale (item 3)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
const FAKE_NMCLI_HEALTHY: &str = r#"#!/bin/sh
|
||||
args="$*"
|
||||
case "$args" in
|
||||
"-t -f DEVICE,TYPE device status")
|
||||
echo "wlan0:wifi" ;;
|
||||
"-t -f ACTIVE,SSID device wifi list ifname wlan0")
|
||||
echo "yes:HomeWifi" ;;
|
||||
"-g IP4.ADDRESS device show wlan0")
|
||||
echo "192.168.1.50/24" ;;
|
||||
*) ;;
|
||||
esac
|
||||
exit 0
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn status_reports_healthy_through_fake_nmcli_and_curl() {
|
||||
fn status_reports_healthy_through_fake_nm_and_curl() {
|
||||
let sb = Sandbox::new();
|
||||
sb.write_fake_bin("nmcli", FAKE_NMCLI_HEALTHY);
|
||||
let nm = sb.nm();
|
||||
let dev = nm.add_wifi_device("wlan0", 100);
|
||||
let ap = nm.add_ap(&dev, "HomeWifi", 80, Security::Wpa2);
|
||||
nm.set_active_ap(&dev, &ap);
|
||||
sb.write_fake_bin("curl", "#!/bin/sh\necho -n 204\nexit 0\n");
|
||||
|
||||
let o = sb.cmd(&["status"]);
|
||||
|
|
@ -544,41 +489,27 @@ fn status_reports_healthy_through_fake_nmcli_and_curl() {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn doctor_reports_present_when_nmcli_and_tailscale_are_on_path() {
|
||||
fn doctor_reports_present_when_nm_and_tailscale_are_available() {
|
||||
let sb = Sandbox::new();
|
||||
sb.write_fake_bin("nmcli", FAKE_NMCLI_HEALTHY);
|
||||
let _nm = sb.nm(); // attach the fake NM; keep the handle alive for the run
|
||||
sb.write_fake_bin("tailscale", "#!/bin/sh\nexit 0\n");
|
||||
|
||||
let o = sb.cmd(&["doctor"]);
|
||||
assert!(o.status.success(), "stderr: {}", stderr(&o));
|
||||
let out = stdout(&o);
|
||||
assert!(
|
||||
out.contains("nmcli") && out.contains("present"),
|
||||
out.contains("network-manager") && out.contains("present"),
|
||||
"out: {out}"
|
||||
);
|
||||
assert!(!out.contains("MISSING"), "out: {out}");
|
||||
}
|
||||
|
||||
const FAKE_NMCLI_DETECT: &str = r#"#!/bin/sh
|
||||
args="$*"
|
||||
case "$args" in
|
||||
"-t -f DEVICE,TYPE device status")
|
||||
echo "wlan0:wifi" ;;
|
||||
"radio wifi on") ;;
|
||||
"device wifi rescan"*) ;;
|
||||
"-t -f SSID device wifi list ifname wlan0")
|
||||
echo "CorpWifi" ;;
|
||||
"-t -f SSID,SIGNAL device wifi list ifname wlan0")
|
||||
echo "CorpWifi:80" ;;
|
||||
*) ;;
|
||||
esac
|
||||
exit 0
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn detect_picks_profile_whose_detect_ssids_are_visible() {
|
||||
let sb = Sandbox::new();
|
||||
sb.write_fake_bin("nmcli", FAKE_NMCLI_DETECT);
|
||||
let nm = sb.nm();
|
||||
let dev = nm.add_wifi_device("wlan0", 100);
|
||||
nm.add_ap(&dev, "CorpWifi", 80, Security::Wpa2);
|
||||
sb.cmd(&["list"]); // bootstrap the default config (home/work/away)
|
||||
|
||||
// Attach a marker SSID to "work" so detection has something to match —
|
||||
|
|
@ -789,7 +720,9 @@ fn status_json_emits_machine_readable_output() {
|
|||
#[test]
|
||||
fn detect_json_emits_machine_readable_output() {
|
||||
let sb = Sandbox::new();
|
||||
sb.write_fake_bin("nmcli", FAKE_NMCLI_DETECT);
|
||||
let nm = sb.nm();
|
||||
let dev = nm.add_wifi_device("wlan0", 100);
|
||||
nm.add_ap(&dev, "CorpWifi", 80, Security::Wpa2);
|
||||
sb.cmd(&["list"]); // bootstrap the default config
|
||||
|
||||
let text = fs::read_to_string(sb.config_file()).unwrap();
|
||||
|
|
@ -806,25 +739,13 @@ fn detect_json_emits_machine_readable_output() {
|
|||
assert_eq!(v["profile"].as_str(), Some("work"));
|
||||
}
|
||||
|
||||
const FAKE_NMCLI_DETECT_TWO: &str = r#"#!/bin/sh
|
||||
args="$*"
|
||||
case "$args" in
|
||||
"-t -f DEVICE,TYPE device status")
|
||||
echo "wlan0:wifi" ;;
|
||||
"radio wifi on") ;;
|
||||
"device wifi rescan"*) ;;
|
||||
"-t -f SSID,SIGNAL device wifi list ifname wlan0")
|
||||
echo "CorpWifi:80"
|
||||
echo "CafeWifi:70" ;;
|
||||
*) ;;
|
||||
esac
|
||||
exit 0
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn detect_prefers_profile_with_more_matching_markers() {
|
||||
let sb = Sandbox::new();
|
||||
sb.write_fake_bin("nmcli", FAKE_NMCLI_DETECT_TWO);
|
||||
let nm = sb.nm();
|
||||
let dev = nm.add_wifi_device("wlan0", 100);
|
||||
nm.add_ap(&dev, "CorpWifi", 80, Security::Wpa2);
|
||||
nm.add_ap(&dev, "CafeWifi", 70, Security::Wpa2);
|
||||
sb.cmd(&["list"]); // bootstrap
|
||||
|
||||
// home matches 1 marker (CorpWifi); work matches 2 (CorpWifi + CafeWifi).
|
||||
|
|
@ -849,23 +770,11 @@ fn detect_prefers_profile_with_more_matching_markers() {
|
|||
);
|
||||
}
|
||||
|
||||
const FAKE_NMCLI_PRUNE: &str = r#"#!/bin/sh
|
||||
args="$*"
|
||||
case "$args" in
|
||||
"-t -f NAME,TYPE connection show")
|
||||
echo "OldCafe:802-11-wireless" ;;
|
||||
"-g 802-11-wireless.ssid connection show OldCafe")
|
||||
echo "OldCafe" ;;
|
||||
"connection delete id OldCafe") ;;
|
||||
*) ;;
|
||||
esac
|
||||
exit 0
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn prune_dry_run_lists_stale_nm_profiles() {
|
||||
let sb = Sandbox::new();
|
||||
sb.write_fake_bin("nmcli", FAKE_NMCLI_PRUNE);
|
||||
let nm = sb.nm();
|
||||
nm.save_connection("OldCafe", None);
|
||||
sb.cmd(&["list"]); // bootstrap (no saved networks → everything is stale)
|
||||
|
||||
let o = sb.cmd(&["prune", "--dry-run"]);
|
||||
|
|
@ -880,7 +789,8 @@ fn prune_dry_run_lists_stale_nm_profiles() {
|
|||
#[test]
|
||||
fn prune_removes_stale_nm_profiles() {
|
||||
let sb = Sandbox::new();
|
||||
sb.write_fake_bin("nmcli", FAKE_NMCLI_PRUNE);
|
||||
let nm = sb.nm();
|
||||
nm.save_connection("OldCafe", None);
|
||||
sb.cmd(&["list"]);
|
||||
|
||||
let o = sb.cmd(&["prune"]);
|
||||
|
|
@ -890,53 +800,26 @@ fn prune_removes_stale_nm_profiles() {
|
|||
out.contains("removed") && out.contains("OldCafe"),
|
||||
"out: {out}"
|
||||
);
|
||||
assert_eq!(
|
||||
nm.connection_count(),
|
||||
0,
|
||||
"prune must actually delete the stale profile"
|
||||
);
|
||||
}
|
||||
|
||||
const FAKE_NMCLI_RETRY: &str = r#"#!/bin/sh
|
||||
marker="$HOME/.nmcli-connect-ok"
|
||||
args="$*"
|
||||
case "$args" in
|
||||
"-t -f DEVICE,TYPE device status")
|
||||
echo "wlan0:wifi" ;;
|
||||
"radio wifi on") ;;
|
||||
"device wifi rescan"*) ;;
|
||||
"-t -f SSID device wifi list ifname wlan0")
|
||||
echo "HomeWifi" ;;
|
||||
"-t -f SSID,SIGNAL device wifi list ifname wlan0")
|
||||
echo "HomeWifi:80" ;;
|
||||
"-t -f ACTIVE,SSID device wifi list ifname wlan0")
|
||||
echo "yes:HomeWifi" ;;
|
||||
"-t -f NAME,TYPE connection show") ;;
|
||||
*"device wifi connect HomeWifi"*)
|
||||
if [ -f "$marker" ]; then
|
||||
exit 0
|
||||
else
|
||||
: > "$marker"
|
||||
exit 1
|
||||
fi ;;
|
||||
*"connection up HomeWifi"*)
|
||||
exit 0 ;;
|
||||
"-g GENERAL.CON-UUID device show wlan0")
|
||||
echo "uuid-1" ;;
|
||||
*"ipv4.ignore-auto-dns"*) ;;
|
||||
"device reapply wlan0") ;;
|
||||
"-t -f DEVICE,STATE device status")
|
||||
echo "wlan0:connected" ;;
|
||||
*) ;;
|
||||
esac
|
||||
exit 0
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn init_wait_retries_until_connect_succeeds() {
|
||||
let sb = Sandbox::new();
|
||||
sb.write_fake_bin("nmcli", FAKE_NMCLI_RETRY);
|
||||
let nm = sb.nm();
|
||||
let dev = nm.add_wifi_device("wlan0", 100);
|
||||
nm.add_ap(&dev, "HomeWifi", 80, Security::Wpa2);
|
||||
// "away" defaults to include_all_known, so HomeWifi is a candidate.
|
||||
let add = sb.cmd(&["add", "HomeWifi", "hunter2"]);
|
||||
assert!(add.status.success(), "stderr: {}", stderr(&add));
|
||||
|
||||
// The fake's first `device wifi connect` fails; the retry succeeds.
|
||||
// `--wait` must keep going past the first failure rather than bailing.
|
||||
// The fake's first activation fails; the retry succeeds. `--wait` must
|
||||
// keep going past the first failure rather than bailing.
|
||||
nm.fail_next_activations(1);
|
||||
let o = sb.cmd(&["init", "--wait", "5"]);
|
||||
assert!(o.status.success(), "stderr: {}", stderr(&o));
|
||||
assert!(stdout(&o).contains("connected"), "out: {}", stdout(&o));
|
||||
|
|
|
|||
1007
tests/common/fake_nm.rs
Normal file
1007
tests/common/fake_nm.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -9,7 +9,7 @@
|
|||
//! rules ("if the program+args match this predicate, return this canned
|
||||
//! `Output`"), which also records every invocation so a test can assert
|
||||
//! exactly what was — or, just as importantly, was *not* — passed (e.g.
|
||||
//! that a password argument never reaches a fake `nmcli`).
|
||||
//! that a password argument never reaches a fake subprocess).
|
||||
//! - [`EnvSandbox`]: real logic (`flow::run`, `watch::classify`) still does
|
||||
//! its own best-effort file logging via `notify::log`, which resolves a
|
||||
//! path from `$HOME`/`$XDG_STATE_HOME`. `EnvSandbox` points those at a
|
||||
|
|
@ -18,9 +18,15 @@
|
|||
//! inherently cross-test-within-this-binary racy, so it's guarded by a
|
||||
//! process-wide mutex — tests using it serialize against each other but
|
||||
//! not against unrelated tests (each `tests/*.rs` file is its own binary).
|
||||
//! - [`fake_nm`]: a real fake NetworkManager D-Bus service on a private
|
||||
//! `dbus-daemon`. The production `nm` module talks to it over real D-Bus
|
||||
//! marshalling (`Connection::system()` honors `DBUS_SYSTEM_BUS_ADDRESS`),
|
||||
//! replacing the old fake-`nmcli`-argv rules.
|
||||
|
||||
#![allow(dead_code)] // not every test file uses every helper here
|
||||
|
||||
pub mod fake_nm;
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
|
@ -94,7 +100,7 @@ impl FakeRunner {
|
|||
}
|
||||
|
||||
/// Shorthand for matching on `prog` plus a whitespace-joined view of
|
||||
/// `args` containing `substr` (handy for `nmcli`/`tailscale` calls, whose
|
||||
/// `args` containing `substr` (handy for `tailscale`/`curl` calls, whose
|
||||
/// interesting bit is usually a subcommand somewhere in the middle).
|
||||
pub fn on_contains(self, prog: &'static str, substr: &'static str, output: Output) -> Self {
|
||||
self.on(
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
//! In-process tests for the actual state machine (`flow::run`) and the watch
|
||||
//! loop's health classification (`watch::classify`), driven entirely through
|
||||
//! a faked `breadcrumbs::util::Runner` (see `tests/common`) — no subprocess
|
||||
//! is ever spawned. This complements `tests/cli.rs`'s black-box coverage
|
||||
//! (which spawns the real binary against fake-bin shell scripts) with fast,
|
||||
//! precise coverage of the logic itself: candidate priority order, the
|
||||
//! loop's health classification (`watch::classify`). NetworkManager is a real
|
||||
//! fake NM D-Bus service on a private bus (see `tests/common::fake_nm`), so
|
||||
//! every `nm` call is exercised over genuine D-Bus marshalling. Everything
|
||||
//! else (tailscale, curl/ping, notify) is faked through a
|
||||
//! `breadcrumbs::util::Runner` (see `tests/common`). This complements
|
||||
//! `tests/cli.rs`'s black-box coverage (which spawns the real binary) with
|
||||
//! fast, precise coverage of the logic itself: candidate priority order, the
|
||||
//! bootstrap+Tailscale gate, and every `watch::Health` transition.
|
||||
|
||||
mod common;
|
||||
|
|
@ -18,6 +20,7 @@ use breadcrumbs::state::{self, State};
|
|||
use breadcrumbs::util::with_runner;
|
||||
use breadcrumbs::watch::{classify, Health};
|
||||
|
||||
use common::fake_nm::{self, Security, SharedNm};
|
||||
use common::{fail, ok, EnvSandbox, FakeRunner};
|
||||
|
||||
fn net(ssid: &str, password: Option<&str>) -> NetworkDef {
|
||||
|
|
@ -52,82 +55,52 @@ fn base_config() -> Config {
|
|||
}
|
||||
}
|
||||
|
||||
/// Wires up the nmcli plumbing every `flow::run` call needs regardless of
|
||||
/// scenario: a Wi-Fi interface exists, radio/rescan calls are no-ops, no
|
||||
/// saved NM connection profiles exist yet (so every connect takes the
|
||||
/// "create via `device wifi connect`" path), DNS enforcement succeeds, and
|
||||
/// the device reports connected after any successful connect attempt.
|
||||
fn base_nm(visible_ssids: &[&str]) -> FakeRunner {
|
||||
let visible = visible_ssids.join("\n");
|
||||
// `-f SSID,SIGNAL` lines: all SSIDs at the same (strong) signal, so
|
||||
// priority order — not signal — decides between them.
|
||||
let with_signal = visible_ssids
|
||||
.iter()
|
||||
.map(|s| format!("{s}:80"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let runner = FakeRunner::new()
|
||||
.on_contains("nmcli", "DEVICE,TYPE", ok("wlan0:wifi"))
|
||||
.on_contains("nmcli", "radio wifi on", ok(""))
|
||||
.on_contains("nmcli", "wifi rescan", ok(""))
|
||||
// Exact matches: `-f ACTIVE,SSID` queries (which contain the
|
||||
// substring "SSID device wifi list") must NOT be answered with the
|
||||
// visible list — they go to the stateful rule below.
|
||||
.on(
|
||||
move |_prog, args| args.join(" ") == "-t -f SSID device wifi list ifname wlan0",
|
||||
ok(&visible),
|
||||
)
|
||||
.on(
|
||||
move |_prog, args| args.join(" ") == "-t -f SSID,SIGNAL device wifi list ifname wlan0",
|
||||
ok(&with_signal),
|
||||
)
|
||||
.on_contains("nmcli", "NAME,TYPE", ok("")) // no saved profiles
|
||||
.on_contains("nmcli", "GENERAL.CON-UUID", ok("uuid-1"))
|
||||
.on_contains("nmcli", "ipv4.ignore-auto-dns", ok(""))
|
||||
.on_contains("nmcli", "device reapply", ok(""))
|
||||
.on_contains("nmcli", "DEVICE,STATE", ok("wlan0:connected"));
|
||||
let calls = runner.calls_handle();
|
||||
runner.on_dynamic(
|
||||
move |_prog, args| args.join(" ") == "-t -f ACTIVE,SSID device wifi list ifname wlan0",
|
||||
move |_prog, _args| {
|
||||
// Stateful: answer with the SSID of the most recently dialed
|
||||
// connection, so connect_and_verify's post-connect SSID check
|
||||
// sees the network that was just activated (bootstrap first,
|
||||
// then the target).
|
||||
let rec = calls.borrow();
|
||||
let ssid = rec.iter().rev().find_map(|call| {
|
||||
let j = call.args.join(" ");
|
||||
if j.contains("connect") {
|
||||
call.args
|
||||
.iter()
|
||||
.position(|a| a == "connect")
|
||||
.map(|i| call.args[i + 1].clone())
|
||||
} else if j.contains("connection up") {
|
||||
call.args
|
||||
.iter()
|
||||
.position(|a| a == "up")
|
||||
.map(|i| call.args[i + 1].clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
match ssid {
|
||||
Some(s) => ok(&format!("yes:{s}")),
|
||||
None => ok(""),
|
||||
}
|
||||
},
|
||||
/// Reset the shared fake-NM bus and put a Wi-Fi device on it with one AP per
|
||||
/// SSID at the given signal strength. Returns the bus guard (held for the
|
||||
/// whole test so tests serialize) and the device path.
|
||||
fn setup_wifi(ssids: &[(&str, u8)]) -> (SharedNm, String) {
|
||||
let nm = fake_nm::shared();
|
||||
nm.reset();
|
||||
let dev = nm.add_wifi_device("wlan0", 100);
|
||||
for (ssid, strength) in ssids {
|
||||
nm.add_ap(&dev, ssid, *strength, Security::Wpa2);
|
||||
}
|
||||
(nm, dev)
|
||||
}
|
||||
|
||||
/// A runner that fakes the non-NM subprocesses a successful `flow::run`
|
||||
/// needs: curl (internet check) and nothing else.
|
||||
fn healthy_runner() -> FakeRunner {
|
||||
FakeRunner::new()
|
||||
.with_command("curl")
|
||||
.on(|prog, _| prog == "curl", ok("204"))
|
||||
}
|
||||
|
||||
/// The runner used by `classify` tests: internet check + optional tailscale.
|
||||
fn classify_runner(curl: &str, tailscale_status: Option<&str>) -> FakeRunner {
|
||||
let mut r = FakeRunner::new().with_command("curl").on(|p, _| p == "curl", ok(curl));
|
||||
if let Some(json) = tailscale_status {
|
||||
r = r
|
||||
.with_command("tailscale")
|
||||
.on(|p, args| p == "tailscale" && args.contains(&"status"), ok(json));
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// Make the device report being associated with `ssid` (for classify tests).
|
||||
fn associate(nm: &SharedNm, dev: &str, ssid: &str) {
|
||||
let ap = nm.add_ap(dev, ssid, 80, Security::Wpa2);
|
||||
nm.set_active_ap(dev, &ap);
|
||||
}
|
||||
|
||||
fn tailscale_json_ok(exit_node: &str) -> String {
|
||||
format!(
|
||||
r#"{{"BackendState":"Running","Peer":{{"k1":{{"HostName":"{exit_node}","DNSName":"{exit_node}.ts.net.","Online":true,"ExitNode":true,"ExitNodeOption":true}}}}}}"#
|
||||
)
|
||||
}
|
||||
|
||||
/// A successful `device wifi connect <ssid> ...` for every ssid in `ssids`.
|
||||
fn allow_connects(runner: FakeRunner, ssids: &[&'static str]) -> FakeRunner {
|
||||
ssids.iter().fold(runner, |r, ssid| {
|
||||
let ssid: &'static str = ssid;
|
||||
r.on(
|
||||
move |prog, args| prog == "nmcli" && args.contains(&"connect") && args.contains(&ssid),
|
||||
ok(""),
|
||||
)
|
||||
})
|
||||
fn tailscale_json_missing() -> &'static str {
|
||||
r#"{"BackendState":"Running","Peer":{}}"#
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
|
|
@ -137,12 +110,10 @@ fn allow_connects(runner: FakeRunner, ssids: &[&'static str]) -> FakeRunner {
|
|||
#[test]
|
||||
fn flow_run_connects_to_first_visible_candidate_in_priority_order() {
|
||||
let _env = EnvSandbox::new();
|
||||
let (nm, _dev) = setup_wifi(&[("First", 80), ("Second", 80)]);
|
||||
|
||||
let mut cfg = base_config();
|
||||
cfg.networks = vec![
|
||||
net("First", Some("pw1")),
|
||||
net("Second", Some("pw2")),
|
||||
];
|
||||
cfg.networks = vec![net("First", Some("pw1")), net("Second", Some("pw2"))];
|
||||
cfg.profiles.insert(
|
||||
"home".into(),
|
||||
Profile {
|
||||
|
|
@ -151,12 +122,7 @@ fn flow_run_connects_to_first_visible_candidate_in_priority_order() {
|
|||
},
|
||||
);
|
||||
|
||||
let runner = allow_connects(base_nm(&["First", "Second"]), &["First", "Second"])
|
||||
.on(|prog, _| prog == "curl", ok("204"))
|
||||
.with_command("curl");
|
||||
let calls = runner.calls_handle();
|
||||
|
||||
let outcome = with_runner(runner, || flow::run(&mut cfg, "home"));
|
||||
let outcome = with_runner(healthy_runner(), || flow::run(&mut cfg, "home"));
|
||||
|
||||
match outcome {
|
||||
flow::Outcome::Connected { ssid, note } => {
|
||||
|
|
@ -166,13 +132,13 @@ fn flow_run_connects_to_first_visible_candidate_in_priority_order() {
|
|||
other => panic!("expected Connected, got {other:?}"),
|
||||
}
|
||||
|
||||
// Priority order actually mattered: "Second" was never dialed even
|
||||
// Priority order actually mattered: "Second" was never activated even
|
||||
// though it was visible and would have succeeded too.
|
||||
let dialed_second = calls
|
||||
.borrow()
|
||||
.iter()
|
||||
.any(|c| c.prog == "nmcli" && c.args.contains(&"connect".to_string()) && c.args.iter().any(|a| a == "Second"));
|
||||
assert!(!dialed_second, "connected to Second when First should win");
|
||||
assert_eq!(
|
||||
nm.activated_ssids(),
|
||||
vec!["First".to_string()],
|
||||
"Second must not be dialed when First wins"
|
||||
);
|
||||
|
||||
// The password used for the winning connect is now NM's problem, not
|
||||
// breadcrumbs' — cleared and (via clear_password_if_used) persisted.
|
||||
|
|
@ -187,14 +153,15 @@ fn flow_run_connects_to_first_visible_candidate_in_priority_order() {
|
|||
#[test]
|
||||
fn flow_run_pass2_falls_back_to_hidden_candidate_not_in_scan() {
|
||||
let _env = EnvSandbox::new();
|
||||
// Nothing is visible; connecting creates the AP on the fly (hidden
|
||||
// networks appear only after association).
|
||||
let (nm, _dev) = setup_wifi(&[]);
|
||||
nm.set_connect_any(true);
|
||||
|
||||
let mut cfg = base_config();
|
||||
// "Ghost" is neither visible nor hidden, so pass 1 *and* pass 2 both
|
||||
// skip it outright — it should never be dialed.
|
||||
cfg.networks = vec![
|
||||
net("Ghost", Some("pw-ghost")),
|
||||
hidden_net("Shadow", Some("pw-shadow")),
|
||||
];
|
||||
cfg.networks = vec![net("Ghost", Some("pw-ghost")), hidden_net("Shadow", Some("pw-shadow"))];
|
||||
cfg.profiles.insert(
|
||||
"away".into(),
|
||||
Profile {
|
||||
|
|
@ -203,44 +170,37 @@ fn flow_run_pass2_falls_back_to_hidden_candidate_not_in_scan() {
|
|||
},
|
||||
);
|
||||
|
||||
// Neither SSID shows up in the scan — "Shadow" is only reachable via the
|
||||
// pass-2 "hidden and unseen" path.
|
||||
let runner = allow_connects(base_nm(&[]), &["Shadow"])
|
||||
.on(|prog, _| prog == "curl", ok("204"))
|
||||
.with_command("curl");
|
||||
let calls = runner.calls_handle();
|
||||
|
||||
let outcome = with_runner(runner, || flow::run(&mut cfg, "away"));
|
||||
let outcome = with_runner(healthy_runner(), || flow::run(&mut cfg, "away"));
|
||||
|
||||
match outcome {
|
||||
flow::Outcome::Connected { ssid, .. } => assert_eq!(ssid, "Shadow"),
|
||||
other => panic!("expected Connected to Shadow, got {other:?}"),
|
||||
}
|
||||
let dialed_ghost = calls
|
||||
.borrow()
|
||||
.iter()
|
||||
.any(|c| c.args.iter().any(|a| a == "Ghost") && c.args.contains(&"connect".to_string()));
|
||||
assert!(!dialed_ghost, "Ghost should never have been dialed");
|
||||
assert_eq!(
|
||||
nm.activated_ssids(),
|
||||
vec!["Shadow".to_string()],
|
||||
"Ghost must never have been dialed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flow_run_unknown_profile_short_circuits_before_touching_nm() {
|
||||
let _env = EnvSandbox::new();
|
||||
let nm = fake_nm::shared();
|
||||
nm.reset();
|
||||
let mut cfg = base_config();
|
||||
|
||||
let runner = FakeRunner::new(); // no rules at all
|
||||
let calls = runner.calls_handle();
|
||||
|
||||
let outcome = with_runner(runner, || flow::run(&mut cfg, "does-not-exist"));
|
||||
|
||||
assert!(matches!(outcome, flow::Outcome::UnknownProfile(p) if p == "does-not-exist"));
|
||||
// The only `Runner::run` call on this path is `notify`/`log`'s own
|
||||
// `date` timestamp lookup — nmcli (or anything network-related) is
|
||||
// never touched for a profile that doesn't exist.
|
||||
// The fake NetworkManager must never be touched for a profile that
|
||||
// doesn't exist (no devices, no scans, no activations).
|
||||
assert!(
|
||||
calls.borrow().iter().all(|c| c.prog != "nmcli"),
|
||||
"unknown-profile path should never shell out to nmcli: {:?}",
|
||||
calls.borrow()
|
||||
nm.calls().is_empty(),
|
||||
"unknown-profile path should never call NetworkManager: {:?}",
|
||||
nm.calls()
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -248,19 +208,10 @@ fn flow_run_unknown_profile_short_circuits_before_touching_nm() {
|
|||
// flow::run — bootstrap + Tailscale gating
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
fn tailscale_json_ok(exit_node: &str) -> String {
|
||||
format!(
|
||||
r#"{{"BackendState":"Running","Peer":{{"k1":{{"HostName":"{exit_node}","DNSName":"{exit_node}.ts.net.","Online":true,"ExitNode":true,"ExitNodeOption":true}}}}}}"#
|
||||
)
|
||||
}
|
||||
|
||||
fn tailscale_json_missing() -> &'static str {
|
||||
r#"{"BackendState":"Running","Peer":{}}"#
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flow_run_moves_past_bootstrap_once_tailscale_is_healthy() {
|
||||
let _env = EnvSandbox::new();
|
||||
let (nm, _dev) = setup_wifi(&[("Guest", 80), ("Corp", 80)]);
|
||||
|
||||
let mut cfg = base_config();
|
||||
cfg.settings.exit_node = "exitnode".into();
|
||||
|
|
@ -275,13 +226,10 @@ fn flow_run_moves_past_bootstrap_once_tailscale_is_healthy() {
|
|||
},
|
||||
);
|
||||
|
||||
let runner = allow_connects(base_nm(&["Guest", "Corp"]), &["Guest", "Corp"])
|
||||
.with_command("curl")
|
||||
let runner = healthy_runner()
|
||||
.with_command("tailscale")
|
||||
.on(|prog, _| prog == "curl", ok("204"))
|
||||
.on_contains("tailscale", "status", ok(&tailscale_json_ok("exitnode")))
|
||||
.on_contains("tailscale", "set", ok(""));
|
||||
let calls = runner.calls_handle();
|
||||
|
||||
let outcome = with_runner(runner, || flow::run(&mut cfg, "work"));
|
||||
|
||||
|
|
@ -294,16 +242,17 @@ fn flow_run_moves_past_bootstrap_once_tailscale_is_healthy() {
|
|||
assert_eq!(cfg.network("Guest").unwrap().password, None);
|
||||
assert_eq!(cfg.network("Corp").unwrap().password, None);
|
||||
|
||||
let dialed_guest = calls
|
||||
.borrow()
|
||||
.iter()
|
||||
.any(|c| c.args.iter().any(|a| a == "Guest") && c.args.contains(&"connect".to_string()));
|
||||
assert!(dialed_guest, "bootstrap should have been dialed first");
|
||||
assert_eq!(
|
||||
nm.activated_ssids(),
|
||||
vec!["Guest".to_string(), "Corp".to_string()],
|
||||
"bootstrap must be dialed before the target"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flow_run_stays_on_bootstrap_and_never_dials_target_when_tailscale_unhealthy() {
|
||||
let _env = EnvSandbox::new();
|
||||
let (nm, _dev) = setup_wifi(&[("Guest", 80), ("Corp", 80)]);
|
||||
|
||||
let mut cfg = base_config();
|
||||
cfg.settings.exit_node = "exitnode".into();
|
||||
|
|
@ -318,19 +267,10 @@ fn flow_run_stays_on_bootstrap_and_never_dials_target_when_tailscale_unhealthy()
|
|||
},
|
||||
);
|
||||
|
||||
let runner = allow_connects(base_nm(&["Guest", "Corp"]), &["Guest", "Corp"])
|
||||
.with_command("curl")
|
||||
let runner = healthy_runner()
|
||||
.with_command("tailscale")
|
||||
.on(|prog, _| prog == "curl", ok("204"))
|
||||
.on(
|
||||
|prog, args| prog == "tailscale" && args.contains(&"status"),
|
||||
ok(tailscale_json_missing()),
|
||||
)
|
||||
.on(
|
||||
|prog, args| prog == "tailscale" && args.contains(&"set"),
|
||||
ok(""),
|
||||
);
|
||||
let calls = runner.calls_handle();
|
||||
.on(|p, args| p == "tailscale" && args.contains(&"status"), ok(tailscale_json_missing()))
|
||||
.on(|p, args| p == "tailscale" && args.contains(&"set"), ok(""));
|
||||
|
||||
let outcome = with_runner(runner, || flow::run(&mut cfg, "work"));
|
||||
|
||||
|
|
@ -342,12 +282,9 @@ fn flow_run_stays_on_bootstrap_and_never_dials_target_when_tailscale_unhealthy()
|
|||
other => panic!("expected TailscaleError, got {other:?}"),
|
||||
}
|
||||
|
||||
let dialed_corp = calls
|
||||
.borrow()
|
||||
.iter()
|
||||
.any(|c| c.args.iter().any(|a| a == "Corp") && c.args.contains(&"connect".to_string()));
|
||||
assert!(
|
||||
!dialed_corp,
|
||||
assert_eq!(
|
||||
nm.activated_ssids(),
|
||||
vec!["Guest".to_string()],
|
||||
"target network must never be dialed while Tailscale is unhealthy"
|
||||
);
|
||||
// The bootstrap connect *did* use a password and succeeded, so it's
|
||||
|
|
@ -362,75 +299,69 @@ fn flow_run_stays_on_bootstrap_and_never_dials_target_when_tailscale_unhealthy()
|
|||
#[test]
|
||||
fn classify_reports_unknown_profile_without_touching_nm() {
|
||||
let _env = EnvSandbox::new();
|
||||
let nm = fake_nm::shared();
|
||||
nm.reset();
|
||||
let cfg = base_config(); // no profiles at all
|
||||
|
||||
let runner = FakeRunner::new();
|
||||
let calls = runner.calls_handle();
|
||||
let class = with_runner(runner, || classify(&cfg, "ghost"));
|
||||
let health = class.health;
|
||||
let ssid = class.ssid;
|
||||
|
||||
assert_eq!(health, Health::UnknownProfile);
|
||||
assert_eq!(ssid, None);
|
||||
assert_eq!(class.health, Health::UnknownProfile);
|
||||
assert_eq!(class.ssid, None);
|
||||
assert!(calls.borrow().is_empty());
|
||||
assert!(
|
||||
nm.calls().is_empty(),
|
||||
"unknown-profile classify must not touch NetworkManager"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_reports_no_adapter_when_wifi_interface_absent() {
|
||||
let _env = EnvSandbox::new();
|
||||
let nm = fake_nm::shared();
|
||||
nm.reset(); // no devices at all
|
||||
let mut cfg = base_config();
|
||||
cfg.profiles.insert("away".into(), Profile::default());
|
||||
|
||||
// `device status` succeeds but lists no wifi-type device.
|
||||
let runner = FakeRunner::new().on_contains("nmcli", "DEVICE,TYPE", ok("eth0:ethernet"));
|
||||
let class = with_runner(runner, || classify(&cfg, "away"));
|
||||
let health = class.health;
|
||||
|
||||
assert_eq!(health, Health::NoAdapter);
|
||||
let class = with_runner(FakeRunner::new(), || classify(&cfg, "away"));
|
||||
assert_eq!(class.health, Health::NoAdapter);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_reports_down_no_net_when_internet_check_fails() {
|
||||
let _env = EnvSandbox::new();
|
||||
let (nm, dev) = setup_wifi(&[]);
|
||||
associate(&nm, &dev, "HomeWifi");
|
||||
let mut cfg = base_config();
|
||||
cfg.profiles.insert("away".into(), Profile::default());
|
||||
|
||||
let runner = FakeRunner::new()
|
||||
.on_contains("nmcli", "DEVICE,TYPE", ok("wlan0:wifi"))
|
||||
.on_contains("nmcli", "ACTIVE,SSID", ok("yes:HomeWifi"))
|
||||
.on_contains("nmcli", "IP4.ADDRESS", ok("192.168.1.50/24"))
|
||||
.on(|prog, _| prog == "curl" || prog == "ping", fail(""));
|
||||
let runner = FakeRunner::new().on(|prog, _| prog == "curl" || prog == "ping", fail(""));
|
||||
let class = with_runner(runner, || classify(&cfg, "away"));
|
||||
let health = class.health;
|
||||
let ssid = class.ssid;
|
||||
|
||||
assert_eq!(health, Health::DownNoNet);
|
||||
assert_eq!(ssid, Some("HomeWifi".to_string()));
|
||||
assert_eq!(class.health, Health::DownNoNet);
|
||||
assert_eq!(class.ssid, Some("HomeWifi".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_reports_up_when_healthy_and_tailscale_not_required() {
|
||||
let _env = EnvSandbox::new();
|
||||
let (nm, dev) = setup_wifi(&[]);
|
||||
associate(&nm, &dev, "HomeWifi");
|
||||
let mut cfg = base_config();
|
||||
cfg.profiles.insert("home".into(), Profile::default()); // tailscale: false
|
||||
|
||||
let runner = FakeRunner::new()
|
||||
.on_contains("nmcli", "DEVICE,TYPE", ok("wlan0:wifi"))
|
||||
.on_contains("nmcli", "ACTIVE,SSID", ok("yes:HomeWifi"))
|
||||
.on_contains("nmcli", "IP4.ADDRESS", ok("192.168.1.50/24"))
|
||||
.with_command("curl")
|
||||
.on(|prog, _| prog == "curl", ok("204"));
|
||||
let class = with_runner(runner, || classify(&cfg, "home"));
|
||||
let health = class.health;
|
||||
let ssid = class.ssid;
|
||||
let class = with_runner(healthy_runner(), || classify(&cfg, "home"));
|
||||
|
||||
assert_eq!(health, Health::Up);
|
||||
assert_eq!(ssid, Some("HomeWifi".to_string()));
|
||||
assert_eq!(class.health, Health::Up);
|
||||
assert_eq!(class.ssid, Some("HomeWifi".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_reports_down_tailscale_manual_when_not_installed() {
|
||||
let _env = EnvSandbox::new();
|
||||
let (nm, dev) = setup_wifi(&[]);
|
||||
associate(&nm, &dev, "CorpWifi");
|
||||
let mut cfg = base_config();
|
||||
cfg.profiles.insert(
|
||||
"work".into(),
|
||||
|
|
@ -440,23 +371,17 @@ fn classify_reports_down_tailscale_manual_when_not_installed() {
|
|||
},
|
||||
);
|
||||
|
||||
// No `with_command("tailscale")`, so `tailscale::installed()` is false —
|
||||
// `status::gather` never even tries to run the `tailscale` binary.
|
||||
let runner = FakeRunner::new()
|
||||
.on_contains("nmcli", "DEVICE,TYPE", ok("wlan0:wifi"))
|
||||
.on_contains("nmcli", "ACTIVE,SSID", ok("yes:CorpWifi"))
|
||||
.on_contains("nmcli", "IP4.ADDRESS", ok("10.0.0.5/24"))
|
||||
.with_command("curl")
|
||||
.on(|prog, _| prog == "curl", ok("204"));
|
||||
let class = with_runner(runner, || classify(&cfg, "work"));
|
||||
let health = class.health;
|
||||
// No `with_command("tailscale")`, so `tailscale::installed()` is false.
|
||||
let class = with_runner(healthy_runner(), || classify(&cfg, "work"));
|
||||
|
||||
assert_eq!(health, Health::DownTailscaleManual);
|
||||
assert_eq!(class.health, Health::DownTailscaleManual);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_reports_down_tailscale_manual_when_needs_login() {
|
||||
let _env = EnvSandbox::new();
|
||||
let (nm, dev) = setup_wifi(&[]);
|
||||
associate(&nm, &dev, "CorpWifi");
|
||||
let mut cfg = base_config();
|
||||
cfg.profiles.insert(
|
||||
"work".into(),
|
||||
|
|
@ -466,26 +391,17 @@ fn classify_reports_down_tailscale_manual_when_needs_login() {
|
|||
},
|
||||
);
|
||||
|
||||
let runner = FakeRunner::new()
|
||||
.on_contains("nmcli", "DEVICE,TYPE", ok("wlan0:wifi"))
|
||||
.on_contains("nmcli", "ACTIVE,SSID", ok("yes:CorpWifi"))
|
||||
.on_contains("nmcli", "IP4.ADDRESS", ok("10.0.0.5/24"))
|
||||
.with_command("curl")
|
||||
.with_command("tailscale")
|
||||
.on(|prog, _| prog == "curl", ok("204"))
|
||||
.on(
|
||||
|prog, args| prog == "tailscale" && args.contains(&"status"),
|
||||
ok(r#"{"BackendState":"NeedsLogin"}"#),
|
||||
);
|
||||
let runner = classify_runner("204", Some(r#"{"BackendState":"NeedsLogin"}"#));
|
||||
let class = with_runner(runner, || classify(&cfg, "work"));
|
||||
let health = class.health;
|
||||
|
||||
assert_eq!(health, Health::DownTailscaleManual);
|
||||
assert_eq!(class.health, Health::DownTailscaleManual);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_reports_down_tailscale_other_when_exit_node_offline() {
|
||||
let _env = EnvSandbox::new();
|
||||
let (nm, dev) = setup_wifi(&[]);
|
||||
associate(&nm, &dev, "CorpWifi");
|
||||
let mut cfg = base_config();
|
||||
cfg.settings.exit_node = "exitnode".into();
|
||||
cfg.profiles.insert(
|
||||
|
|
@ -497,26 +413,17 @@ fn classify_reports_down_tailscale_other_when_exit_node_offline() {
|
|||
);
|
||||
|
||||
let json = r#"{"BackendState":"Running","Peer":{"k1":{"HostName":"exitnode","Online":false,"ExitNode":false,"ExitNodeOption":true}}}"#;
|
||||
let runner = FakeRunner::new()
|
||||
.on_contains("nmcli", "DEVICE,TYPE", ok("wlan0:wifi"))
|
||||
.on_contains("nmcli", "ACTIVE,SSID", ok("yes:CorpWifi"))
|
||||
.on_contains("nmcli", "IP4.ADDRESS", ok("10.0.0.5/24"))
|
||||
.with_command("curl")
|
||||
.with_command("tailscale")
|
||||
.on(|prog, _| prog == "curl", ok("204"))
|
||||
.on(
|
||||
|prog, args| prog == "tailscale" && args.contains(&"status"),
|
||||
ok(json),
|
||||
);
|
||||
let runner = classify_runner("204", Some(json));
|
||||
let class = with_runner(runner, || classify(&cfg, "work"));
|
||||
let health = class.health;
|
||||
|
||||
assert_eq!(health, Health::DownTailscaleOther);
|
||||
assert_eq!(class.health, Health::DownTailscaleOther);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_reports_up_when_tailscale_healthy() {
|
||||
let _env = EnvSandbox::new();
|
||||
let (nm, dev) = setup_wifi(&[]);
|
||||
associate(&nm, &dev, "CorpWifi");
|
||||
let mut cfg = base_config();
|
||||
cfg.settings.exit_node = "exitnode".into();
|
||||
cfg.profiles.insert(
|
||||
|
|
@ -527,18 +434,10 @@ fn classify_reports_up_when_tailscale_healthy() {
|
|||
},
|
||||
);
|
||||
|
||||
let runner = FakeRunner::new()
|
||||
.on_contains("nmcli", "DEVICE,TYPE", ok("wlan0:wifi"))
|
||||
.on_contains("nmcli", "ACTIVE,SSID", ok("yes:CorpWifi"))
|
||||
.on_contains("nmcli", "IP4.ADDRESS", ok("10.0.0.5/24"))
|
||||
.with_command("curl")
|
||||
.with_command("tailscale")
|
||||
.on(|prog, _| prog == "curl", ok("204"))
|
||||
.on_contains("tailscale", "status", ok(&tailscale_json_ok("exitnode")));
|
||||
let runner = classify_runner("204", Some(&tailscale_json_ok("exitnode")));
|
||||
let class = with_runner(runner, || classify(&cfg, "work"));
|
||||
let health = class.health;
|
||||
|
||||
assert_eq!(health, Health::Up);
|
||||
assert_eq!(class.health, Health::Up);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
|
|
@ -662,6 +561,8 @@ fn flow_run_reports_no_exit_node_and_never_clears_selection() {
|
|||
// TsHealth::NoExitNode — and must never run `tailscale set --exit-node=`
|
||||
// with an empty value, which would clear the user's current selection.
|
||||
let _env = EnvSandbox::new();
|
||||
let (nm, _dev) = setup_wifi(&[("Corp", 80)]);
|
||||
|
||||
let mut cfg = base_config();
|
||||
cfg.networks = vec![net("Corp", Some("corp-pw"))];
|
||||
cfg.profiles.insert(
|
||||
|
|
@ -673,7 +574,7 @@ fn flow_run_reports_no_exit_node_and_never_clears_selection() {
|
|||
},
|
||||
);
|
||||
|
||||
let runner = base_nm(&["Corp"]).with_command("tailscale");
|
||||
let runner = FakeRunner::new().with_command("tailscale");
|
||||
let calls = runner.calls_handle();
|
||||
let outcome = with_runner(runner, || flow::run(&mut cfg, "work"));
|
||||
|
||||
|
|
@ -688,6 +589,11 @@ fn flow_run_reports_no_exit_node_and_never_clears_selection() {
|
|||
"with no exit node configured, tailscale must not be touched: {:?}",
|
||||
calls.borrow()
|
||||
);
|
||||
assert!(
|
||||
nm.activated_ssids().is_empty(),
|
||||
"with no exit node configured, no network must be dialed: {:?}",
|
||||
nm.activated_ssids()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -696,6 +602,8 @@ fn classify_reports_down_tailscale_manual_when_no_exit_node_configured() {
|
|||
// classify as DownTailscaleManual — not DownTailscaleOther, which would
|
||||
// make the watcher spin auto-recovery forever.
|
||||
let _env = EnvSandbox::new();
|
||||
let (nm, dev) = setup_wifi(&[]);
|
||||
associate(&nm, &dev, "CorpWifi");
|
||||
let mut cfg = base_config();
|
||||
cfg.profiles.insert(
|
||||
"work".into(),
|
||||
|
|
@ -705,19 +613,11 @@ fn classify_reports_down_tailscale_manual_when_no_exit_node_configured() {
|
|||
},
|
||||
);
|
||||
|
||||
let runner = FakeRunner::new()
|
||||
.on_contains("nmcli", "DEVICE,TYPE", ok("wlan0:wifi"))
|
||||
.on_contains("nmcli", "ACTIVE,SSID", ok("yes:CorpWifi"))
|
||||
.on_contains("nmcli", "IP4.ADDRESS", ok("10.0.0.5/24"))
|
||||
.with_command("curl")
|
||||
.with_command("tailscale")
|
||||
.on(|prog, _| prog == "curl", ok("204"));
|
||||
let runner = classify_runner("204", None).with_command("tailscale");
|
||||
let class = with_runner(runner, || classify(&cfg, "work"));
|
||||
let health = class.health;
|
||||
let ssid = class.ssid;
|
||||
|
||||
assert_eq!(health, Health::DownTailscaleManual);
|
||||
assert_eq!(ssid, Some("CorpWifi".to_string()));
|
||||
assert_eq!(class.health, Health::DownTailscaleManual);
|
||||
assert_eq!(class.ssid, Some("CorpWifi".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -733,10 +633,9 @@ fn ensure_exit_node_attempts_to_start_unreachable_daemon() {
|
|||
.on(|p, args| p == "tailscale" && args.contains(&"status"), ok(""))
|
||||
.on(|p, args| p == "tailscale" && args.contains(&"up"), ok(""));
|
||||
let calls = runner.calls_handle();
|
||||
let health =
|
||||
with_runner(runner, || {
|
||||
breadcrumbs::tailscale::ensure_exit_node(&["exitnode".to_string()])
|
||||
});
|
||||
let health = with_runner(runner, || {
|
||||
breadcrumbs::tailscale::ensure_exit_node(&["exitnode".to_string()])
|
||||
});
|
||||
assert!(
|
||||
matches!(health, breadcrumbs::tailscale::TsHealth::Error(_)),
|
||||
"daemon still unreachable after `up` → Error, got {health:?}"
|
||||
|
|
@ -759,6 +658,9 @@ fn flow_run_fails_when_device_lands_on_wrong_ssid() {
|
|||
// *different* network than requested. flow must not report Connected to
|
||||
// the requested SSID, and must not clear its password.
|
||||
let _env = EnvSandbox::new();
|
||||
let (nm, _dev) = setup_wifi(&[("First", 80), ("OtherNet", 90)]);
|
||||
nm.set_land_on(Some("OtherNet"));
|
||||
|
||||
let mut cfg = base_config();
|
||||
cfg.networks = vec![net("First", Some("pw1"))];
|
||||
cfg.profiles.insert(
|
||||
|
|
@ -769,31 +671,7 @@ fn flow_run_fails_when_device_lands_on_wrong_ssid() {
|
|||
},
|
||||
);
|
||||
|
||||
let runner = FakeRunner::new()
|
||||
.on_contains("nmcli", "DEVICE,TYPE", ok("wlan0:wifi"))
|
||||
.on_contains("nmcli", "radio wifi on", ok(""))
|
||||
.on_contains("nmcli", "wifi rescan", ok(""))
|
||||
.on(
|
||||
|_p, args| args.join(" ") == "-t -f SSID device wifi list ifname wlan0",
|
||||
ok("First"),
|
||||
)
|
||||
.on(
|
||||
|_p, args| args.join(" ") == "-t -f SSID,SIGNAL device wifi list ifname wlan0",
|
||||
ok("First:80"),
|
||||
)
|
||||
.on_contains("nmcli", "NAME,TYPE", ok(""))
|
||||
.on_contains("nmcli", "GENERAL.CON-UUID", ok("uuid-1"))
|
||||
.on_contains("nmcli", "ipv4.ignore-auto-dns", ok(""))
|
||||
.on_contains("nmcli", "device reapply", ok(""))
|
||||
.on_contains("nmcli", "DEVICE,STATE", ok("wlan0:connected"))
|
||||
// The connect itself succeeds...
|
||||
.on_contains("nmcli", "device wifi connect First", ok(""))
|
||||
// ...but the device reports being on a different network.
|
||||
.on(
|
||||
|_p, args| args.join(" ") == "-t -f ACTIVE,SSID device wifi list ifname wlan0",
|
||||
ok("yes:OtherNet"),
|
||||
);
|
||||
let outcome = with_runner(runner, || flow::run(&mut cfg, "home"));
|
||||
let outcome = with_runner(healthy_runner(), || flow::run(&mut cfg, "home"));
|
||||
|
||||
assert!(
|
||||
!matches!(outcome, flow::Outcome::Connected { .. }),
|
||||
|
|
@ -811,6 +689,8 @@ fn run_quiet_suppresses_notifications_that_run_emits() {
|
|||
// The watch loop calls flow::run_quiet so a persistent failure doesn't
|
||||
// re-notify on every retry; the CLI keeps flow::run's notifications.
|
||||
let _env = EnvSandbox::new();
|
||||
let nm = fake_nm::shared();
|
||||
nm.reset();
|
||||
let mut cfg = base_config(); // no profiles → UnknownProfile path notifies
|
||||
|
||||
let runner = FakeRunner::new().with_command("notify-send");
|
||||
|
|
@ -860,21 +740,22 @@ fn internet_ok_requires_204_and_falls_back_to_ping() {
|
|||
|
||||
#[test]
|
||||
fn scan_list_dedups_by_ssid_keeping_strongest_signal() {
|
||||
// One line per BSSID: the same SSID broadcast by several APs must show
|
||||
// once, at its strongest signal (not the first, possibly weak, listing).
|
||||
// One entry per SSID, at its strongest signal (not the first, possibly
|
||||
// weak, listing). Hidden (empty-SSID) APs are skipped.
|
||||
let _env = EnvSandbox::new();
|
||||
let runner = FakeRunner::new().on_contains(
|
||||
"nmcli",
|
||||
"SSID,SIGNAL,SECURITY",
|
||||
ok("Cafe:40:WPA2\nCafe:80:WPA2\nOffice:60:WPA3\nCafe:90 %:WPA2\n:70:WPA2"),
|
||||
);
|
||||
let list = with_runner(runner, || breadcrumbs::nm::scan_list("wlan0"));
|
||||
let (nm, dev) = setup_wifi(&[]);
|
||||
nm.add_ap(&dev, "Cafe", 40, Security::Wpa2);
|
||||
nm.add_ap(&dev, "Cafe", 80, Security::Wpa2);
|
||||
nm.add_ap(&dev, "Office", 60, Security::Wpa3);
|
||||
nm.add_ap(&dev, "Cafe", 90, Security::Wpa2);
|
||||
|
||||
assert_eq!(list.len(), 2, "dedup by SSID, hidden (empty SSID) skipped: {list:?}");
|
||||
let list = breadcrumbs::nm::scan_list("wlan0");
|
||||
assert_eq!(list.len(), 2, "dedup by SSID: {list:?}");
|
||||
let cafe = list.iter().find(|e| e.ssid == "Cafe").unwrap();
|
||||
assert_eq!(cafe.signal, "90 %", "strongest signal wins");
|
||||
assert_eq!(cafe.signal, "90", "strongest signal wins");
|
||||
let office = list.iter().find(|e| e.ssid == "Office").unwrap();
|
||||
assert_eq!(office.signal, "60");
|
||||
assert_eq!(office.security, "WPA3");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
|
|
@ -886,6 +767,10 @@ fn scan_list_dedups_by_ssid_keeping_strongest_signal() {
|
|||
#[test]
|
||||
fn flow_run_prefers_strongest_visible_signal_over_priority_order() {
|
||||
let _env = EnvSandbox::new();
|
||||
// "Weak" is listed first (higher priority), but "Strong" has the better
|
||||
// signal — signal-aware selection must dial Strong first.
|
||||
let (nm, _dev) = setup_wifi(&[("Weak", 40), ("Strong", 90)]);
|
||||
|
||||
let mut cfg = base_config();
|
||||
cfg.networks = vec![net("Weak", Some("pw1")), net("Strong", Some("pw2"))];
|
||||
cfg.profiles.insert(
|
||||
|
|
@ -896,58 +781,24 @@ fn flow_run_prefers_strongest_visible_signal_over_priority_order() {
|
|||
},
|
||||
);
|
||||
|
||||
// "Weak" is listed first (higher priority), but "Strong" has the better
|
||||
// signal — signal-aware selection must dial Strong first.
|
||||
let runner = FakeRunner::new()
|
||||
.on_contains("nmcli", "DEVICE,TYPE", ok("wlan0:wifi"))
|
||||
.on_contains("nmcli", "radio wifi on", ok(""))
|
||||
.on_contains("nmcli", "wifi rescan", ok(""))
|
||||
.on(
|
||||
|_p, args| args.join(" ") == "-t -f SSID device wifi list ifname wlan0",
|
||||
ok("Weak\nStrong"),
|
||||
)
|
||||
.on(
|
||||
|_p, args| args.join(" ") == "-t -f SSID,SIGNAL device wifi list ifname wlan0",
|
||||
ok("Weak:40\nStrong:90"),
|
||||
)
|
||||
.on_contains("nmcli", "NAME,TYPE", ok(""))
|
||||
.on_contains("nmcli", "GENERAL.CON-UUID", ok("uuid-1"))
|
||||
.on_contains("nmcli", "ipv4.ignore-auto-dns", ok(""))
|
||||
.on_contains("nmcli", "device reapply", ok(""))
|
||||
.on_contains("nmcli", "DEVICE,STATE", ok("wlan0:connected"))
|
||||
.on(
|
||||
|_p, args| args.join(" ") == "-t -f ACTIVE,SSID device wifi list ifname wlan0",
|
||||
ok("yes:Strong"),
|
||||
)
|
||||
.on(
|
||||
|p, args| p == "nmcli" && args.contains(&"connect") && args.contains(&"Strong"),
|
||||
ok(""),
|
||||
)
|
||||
.on(|p, _| p == "curl", ok("204"))
|
||||
.with_command("curl");
|
||||
let calls = runner.calls_handle();
|
||||
|
||||
let outcome = with_runner(runner, || flow::run(&mut cfg, "home"));
|
||||
let outcome = with_runner(healthy_runner(), || flow::run(&mut cfg, "home"));
|
||||
match &outcome {
|
||||
flow::Outcome::Connected { ssid, .. } => assert_eq!(ssid, "Strong"),
|
||||
other => panic!("expected Connected to Strong, got {other:?}"),
|
||||
}
|
||||
|
||||
let dialed = |s: &str| {
|
||||
calls.borrow().iter().any(|c| {
|
||||
c.args.contains(&"connect".to_string()) && c.args.iter().any(|a| a == s)
|
||||
})
|
||||
};
|
||||
assert!(dialed("Strong"), "the stronger network must be dialed");
|
||||
assert!(
|
||||
!dialed("Weak"),
|
||||
"the weaker network must not be dialed despite higher priority"
|
||||
assert_eq!(
|
||||
nm.activated_ssids(),
|
||||
vec!["Strong".to_string()],
|
||||
"the stronger network must be dialed, and only it"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flow_run_pins_per_network_dns_override() {
|
||||
let _env = EnvSandbox::new();
|
||||
let (nm, _dev) = setup_wifi(&[("Home", 80)]);
|
||||
|
||||
let mut cfg = base_config();
|
||||
cfg.settings.dns = "1.1.1.1".into();
|
||||
let mut def = net("Home", Some("pw"));
|
||||
|
|
@ -961,24 +812,25 @@ fn flow_run_pins_per_network_dns_override() {
|
|||
},
|
||||
);
|
||||
|
||||
let runner = allow_connects(base_nm(&["Home"]), &["Home"])
|
||||
.on(|p, _| p == "curl", ok("204"))
|
||||
.with_command("curl");
|
||||
let calls = runner.calls_handle();
|
||||
let outcome = with_runner(runner, || flow::run(&mut cfg, "home"));
|
||||
let outcome = with_runner(healthy_runner(), || flow::run(&mut cfg, "home"));
|
||||
assert!(matches!(outcome, flow::Outcome::Connected { .. }));
|
||||
|
||||
// The DNS-pinning `connection modify` must carry the per-network override,
|
||||
// not the global 1.1.1.1.
|
||||
let dns_arg = calls.borrow().iter().any(|c| {
|
||||
c.args.join(" ").contains("ipv4.dns") && c.args.iter().any(|a| a == "9.9.9.9")
|
||||
});
|
||||
assert!(dns_arg, "per-network DNS override must reach nmcli");
|
||||
// The DNS-pinned profile must carry the per-network override, not the
|
||||
// global 1.1.1.1.
|
||||
let st = nm.state.lock().unwrap();
|
||||
let conn = st.connections.values().next().expect("a profile was saved");
|
||||
let dns = conn
|
||||
.get("ipv4")
|
||||
.and_then(|m| m.get("dns"))
|
||||
.and_then(fake_nm::value_str_list);
|
||||
assert_eq!(dns, Some(vec!["9.9.9.9".to_string()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flow_run_appends_learned_ssid_to_detect_ssids() {
|
||||
let _env = EnvSandbox::new();
|
||||
let (_nm, _dev) = setup_wifi(&[("Home", 80)]);
|
||||
|
||||
let mut cfg = base_config();
|
||||
cfg.networks = vec![net("Home", Some("pw"))];
|
||||
cfg.profiles.insert(
|
||||
|
|
@ -990,10 +842,7 @@ fn flow_run_appends_learned_ssid_to_detect_ssids() {
|
|||
},
|
||||
);
|
||||
|
||||
let runner = allow_connects(base_nm(&["Home"]), &["Home"])
|
||||
.on(|p, _| p == "curl", ok("204"))
|
||||
.with_command("curl");
|
||||
let outcome = with_runner(runner, || flow::run(&mut cfg, "home"));
|
||||
let outcome = with_runner(healthy_runner(), || flow::run(&mut cfg, "home"));
|
||||
assert!(matches!(outcome, flow::Outcome::Connected { .. }));
|
||||
|
||||
assert_eq!(
|
||||
|
|
@ -1006,15 +855,12 @@ fn flow_run_appends_learned_ssid_to_detect_ssids() {
|
|||
#[test]
|
||||
fn classify_reports_captive_portal_when_connectivity_returns_200() {
|
||||
let _env = EnvSandbox::new();
|
||||
let (nm, dev) = setup_wifi(&[]);
|
||||
associate(&nm, &dev, "HomeWifi");
|
||||
let mut cfg = base_config();
|
||||
cfg.profiles.insert("home".into(), Profile::default());
|
||||
|
||||
let runner = FakeRunner::new()
|
||||
.on_contains("nmcli", "DEVICE,TYPE", ok("wlan0:wifi"))
|
||||
.on_contains("nmcli", "ACTIVE,SSID", ok("yes:HomeWifi"))
|
||||
.on_contains("nmcli", "IP4.ADDRESS", ok("192.168.1.50/24"))
|
||||
.with_command("curl")
|
||||
.on(|p, _| p == "curl", ok("200"));
|
||||
let runner = classify_runner("200", None);
|
||||
let class = with_runner(runner, || classify(&cfg, "home"));
|
||||
|
||||
assert_eq!(class.health, Health::CaptivePortal);
|
||||
|
|
@ -1023,10 +869,10 @@ fn classify_reports_captive_portal_when_connectivity_returns_200() {
|
|||
|
||||
#[test]
|
||||
fn ensure_exit_node_failover_tries_nodes_in_priority_order() {
|
||||
let _env = EnvSandbox::new();
|
||||
// The status never shows nodeA; it always shows nodeB selected + online.
|
||||
// ensure_exit_node must therefore try nodeA (fail), then nodeB (succeed),
|
||||
// in that exact priority order.
|
||||
let _env = EnvSandbox::new();
|
||||
let json = r#"{"BackendState":"Running","Peer":{"k1":{"HostName":"nodeB","DNSName":"nodeB.ts.net.","Online":true,"ExitNode":true,"ExitNodeOption":true}}}"#;
|
||||
let runner = FakeRunner::new()
|
||||
.with_command("tailscale")
|
||||
|
|
@ -1054,78 +900,69 @@ fn ensure_exit_node_failover_tries_nodes_in_priority_order() {
|
|||
|
||||
#[test]
|
||||
fn wifi_interface_preferred_picks_named_device_over_first_wifi() {
|
||||
let runner = FakeRunner::new().on_contains(
|
||||
"nmcli",
|
||||
"DEVICE,TYPE",
|
||||
ok("wlan0:wifi\nwlan1:wifi"),
|
||||
);
|
||||
let iface = with_runner(runner, || {
|
||||
breadcrumbs::nm::wifi_interface_preferred(Some("wlan1"))
|
||||
});
|
||||
assert_eq!(iface.as_deref(), Some("wlan1"));
|
||||
let _env = EnvSandbox::new();
|
||||
let nm = fake_nm::shared();
|
||||
nm.reset();
|
||||
nm.add_wifi_device("wlan0", 100);
|
||||
nm.add_wifi_device("wlan1", 100);
|
||||
|
||||
assert_eq!(breadcrumbs::nm::wifi_interface_preferred(Some("wlan1")).as_deref(), Some("wlan1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wifi_interface_preferred_falls_back_to_first_wifi_when_pref_missing() {
|
||||
let runner = FakeRunner::new().on_contains(
|
||||
"nmcli",
|
||||
"DEVICE,TYPE",
|
||||
ok("wlan0:wifi\nwlan1:wifi"),
|
||||
);
|
||||
let iface = with_runner(runner, || {
|
||||
breadcrumbs::nm::wifi_interface_preferred(Some("wlan9"))
|
||||
});
|
||||
assert_eq!(iface.as_deref(), Some("wlan0"));
|
||||
let _env = EnvSandbox::new();
|
||||
let nm = fake_nm::shared();
|
||||
nm.reset();
|
||||
nm.add_wifi_device("wlan0", 100);
|
||||
nm.add_wifi_device("wlan1", 100);
|
||||
|
||||
assert_eq!(breadcrumbs::nm::wifi_interface_preferred(Some("wlan9")).as_deref(), Some("wlan0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visible_signals_dedups_by_strongest_signal() {
|
||||
let runner = FakeRunner::new().on_contains(
|
||||
"nmcli",
|
||||
"SSID,SIGNAL",
|
||||
ok("Cafe:40\nCafe:85\nOffice:60\n:90"),
|
||||
);
|
||||
let map = with_runner(runner, || breadcrumbs::nm::visible_signals("wlan0"));
|
||||
let _env = EnvSandbox::new();
|
||||
let (nm, dev) = setup_wifi(&[]);
|
||||
nm.add_ap(&dev, "Cafe", 40, Security::Wpa2);
|
||||
nm.add_ap(&dev, "Cafe", 85, Security::Wpa2);
|
||||
nm.add_ap(&dev, "Office", 60, Security::Wpa2);
|
||||
|
||||
let map = breadcrumbs::nm::visible_signals("wlan0");
|
||||
assert_eq!(map.get("Cafe"), Some(&85));
|
||||
assert_eq!(map.get("Office"), Some(&60));
|
||||
assert!(!map.contains_key(""), "hidden/empty SSID must be skipped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connect_verbose_enterprise_creates_8021x_profile() {
|
||||
let _env = EnvSandbox::new();
|
||||
let (nm, dev) = setup_wifi(&[]);
|
||||
nm.add_ap(&dev, "Corp", 80, Security::Enterprise);
|
||||
|
||||
let mut def = net("Corp", Some("pw"));
|
||||
def.eap = Some("peap".into());
|
||||
def.identity = Some("user@corp".into());
|
||||
def.ca_cert = Some("/etc/ca.pem".into());
|
||||
|
||||
// No saved profile (NAME,TYPE empty), so the enterprise create path runs.
|
||||
let runner = FakeRunner::new()
|
||||
.on_contains("nmcli", "NAME,TYPE", ok(""))
|
||||
.on(
|
||||
|p, args| p == "nmcli" && args.contains(&"add") && args.contains(&"connection"),
|
||||
ok(""),
|
||||
)
|
||||
.on(|p, args| p == "nmcli" && args.contains(&"up"), ok(""))
|
||||
.on_contains("nmcli", "GENERAL.CON-UUID", ok("uuid-1"))
|
||||
.on_contains("nmcli", "ipv4.ignore-auto-dns", ok(""))
|
||||
.on_contains("nmcli", "device reapply", ok(""));
|
||||
let calls = runner.calls_handle();
|
||||
|
||||
let res = with_runner(runner, || {
|
||||
breadcrumbs::nm::connect_verbose("wlan0", &def, 8, "1.1.1.1")
|
||||
});
|
||||
let res = breadcrumbs::nm::connect_verbose("wlan0", &def, 8, "1.1.1.1");
|
||||
assert!(res.is_ok(), "enterprise connect should succeed: {res:?}");
|
||||
|
||||
let calls_ref = calls.borrow();
|
||||
let add = calls_ref
|
||||
.iter()
|
||||
.find(|c| c.args.contains(&"add".to_string()) && c.args.contains(&"connection".to_string()))
|
||||
.expect("enterprise path must create a profile via `connection add`");
|
||||
let joined = add.args.join(" ");
|
||||
assert!(joined.contains("wpa-eap"));
|
||||
assert!(joined.contains("peap"));
|
||||
assert!(joined.contains("user@corp"));
|
||||
assert!(joined.contains("/etc/ca.pem"));
|
||||
assert!(joined.contains("802-1x.password"));
|
||||
let st = nm.state.lock().unwrap();
|
||||
let (_, settings) = st.connections.iter().next().expect("a profile was saved");
|
||||
let x1 = settings.get("802-1x").expect("802-1x section");
|
||||
assert_eq!(
|
||||
x1.get("identity").and_then(|v| v.downcast_ref::<String>().ok()).as_deref(),
|
||||
Some("user@corp")
|
||||
);
|
||||
let eap = x1.get("eap").and_then(fake_nm::value_str_list);
|
||||
assert_eq!(eap.as_deref(), Some(&["peap".to_string()][..]));
|
||||
// ca-cert is a GBytes (`ay`) holding the conventional `file://` URI for
|
||||
// a filesystem path — never a bare string.
|
||||
let ca = x1.get("ca-cert").and_then(fake_nm::value_bytes);
|
||||
assert_eq!(ca.as_deref(), Some(b"file:///etc/ca.pem".as_slice()));
|
||||
let sec = settings.get("802-11-wireless-security").expect("security section");
|
||||
assert_eq!(
|
||||
sec.get("key-mgmt").and_then(|v| v.downcast_ref::<String>().ok()).as_deref(),
|
||||
Some("wpa-eap")
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue