Test the NM layer against an in-process D-Bus fake
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:
Breadway 2026-08-31 15:10:42 +08:00
parent b4c1d0b233
commit cc709a4af9
4 changed files with 1345 additions and 612 deletions

View file

@ -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")
);
}