Add feature batch: captive-portal detection, schedules, exit-node failover, 802.1x, per-network DNS
Implements the planned feature sweep: tri-state connectivity with portal
detection, time-based profile schedules, priority exit-node list with
failover, enterprise (802.1x) network support, per-network DNS, signal-
aware selection, auto-learn markers, suspend/resume recovery, prune
command, scored detection, and richer bread events (network.changed,
tailscale.changed). CLI gains --json output, init --wait, and add --dns/
--eap/--identity/--ca-cert. Adds regression coverage for each feature;
141 tests pass and clippy is clean with -D warnings.
Generated with Codebuff 🤖
Co-Authored-By: Codebuff <noreply@codebuff.com>
This commit is contained in:
parent
c02360a873
commit
13c7743d48
13 changed files with 2178 additions and 359 deletions
351
tests/cli.rs
351
tests/cli.rs
|
|
@ -426,6 +426,8 @@ case "$args" in
|
|||
"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")
|
||||
|
|
@ -566,6 +568,8 @@ case "$args" in
|
|||
"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
|
||||
|
|
@ -590,3 +594,350 @@ fn detect_picks_profile_whose_detect_ssids_are_visible() {
|
|||
assert!(o.status.success(), "stderr: {}", stderr(&o));
|
||||
assert_eq!(stdout(&o).trim(), "work");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Regression tests for the audit fixes (XDG paths, EDITOR args, config
|
||||
// merging/clamping, core-profile ownership, scan validation).
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn edit_splits_editor_arguments() {
|
||||
// EDITOR="code -w" style values must be split into program + args
|
||||
// instead of being treated as one (nonexistent) binary path.
|
||||
let sb = Sandbox::new();
|
||||
sb.write_fake_bin(
|
||||
"fake-editor",
|
||||
"#!/bin/sh\necho \"$@\" > \"$HOME/editor-args\"\nexit 0\n",
|
||||
);
|
||||
|
||||
let o = sb.cmd_env(&["edit"], &[("EDITOR", "fake-editor --wait")]);
|
||||
assert!(o.status.success(), "stderr: {}", stderr(&o));
|
||||
assert!(stdout(&o).contains("config OK"));
|
||||
let args = fs::read_to_string(sb.root.join("editor-args")).unwrap();
|
||||
assert!(args.contains("--wait"), "editor args must be split off: {args}");
|
||||
assert!(
|
||||
args.contains("breadcrumbs.toml"),
|
||||
"config path must be appended as its own argument: {args}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_to_unknown_profile_errors_like_add() {
|
||||
// `scan --to bogus` must fail up front, matching `add --to`, instead of
|
||||
// silently saving a network that never gets attached.
|
||||
let sb = Sandbox::new();
|
||||
sb.cmd(&["list"]); // bootstrap the config
|
||||
let o = sb.cmd(&["scan", "--to", "bogus"]);
|
||||
assert!(!o.status.success());
|
||||
assert!(
|
||||
stderr(&o).contains("unknown profile 'bogus'"),
|
||||
"stderr: {}",
|
||||
stderr(&o)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watch_interval_below_minimum_is_clamped_to_four() {
|
||||
// `list` and the watch loop must agree on the poll interval: a value
|
||||
// below the documented minimum of 4 is clamped at load, not just
|
||||
// silently clamped inside the watch loop.
|
||||
let sb = Sandbox::new();
|
||||
fs::create_dir_all(sb.root.join("config/breadcrumbs")).unwrap();
|
||||
fs::write(sb.config_file(), "[settings]\nwatch_interval = 1\n").unwrap();
|
||||
|
||||
let o = sb.cmd(&["list"]);
|
||||
assert!(o.status.success(), "stderr: {}", stderr(&o));
|
||||
assert!(
|
||||
stdout(&o).contains("watch every 4s"),
|
||||
"watch interval must clamp to the minimum: {}",
|
||||
stdout(&o)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_inline_networks_merge_with_networks_toml_instead_of_dropping() {
|
||||
// A breadcrumbs.toml still carrying a legacy inline `[[networks]]` block
|
||||
// must keep those networks even when networks.toml already exists — the
|
||||
// merge is completed (and the inline block dropped) on the next save.
|
||||
let sb = Sandbox::new();
|
||||
fs::create_dir_all(sb.root.join("config/breadcrumbs")).unwrap();
|
||||
fs::write(
|
||||
sb.config_file(),
|
||||
"[settings]\ndefault_profile = \"away\"\n\n[[networks]]\nssid = \"InlineNet\"\npassword = \"pw-inline\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
sb.networks_file(),
|
||||
"[[networks]]\nssid = \"FileNet\"\npassword = \"pw-file\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let o = sb.cmd(&["list"]);
|
||||
assert!(o.status.success(), "stderr: {}", stderr(&o));
|
||||
let out = stdout(&o);
|
||||
assert!(out.contains("InlineNet"), "inline network must survive the merge: {out}");
|
||||
assert!(out.contains("FileNet"), "networks.toml network must be present: {out}");
|
||||
|
||||
// A later save migrates the merged set into networks.toml and drops the
|
||||
// inline block from breadcrumbs.toml.
|
||||
let o2 = sb.cmd(&["add", "OtherNet", "pw3"]);
|
||||
assert!(o2.status.success(), "stderr: {}", stderr(&o2));
|
||||
let networks = fs::read_to_string(sb.networks_file()).unwrap();
|
||||
assert!(
|
||||
networks.contains("InlineNet")
|
||||
&& networks.contains("FileNet")
|
||||
&& networks.contains("OtherNet"),
|
||||
"save must persist the merged set: {networks}"
|
||||
);
|
||||
let config_text = fs::read_to_string(sb.config_file()).unwrap();
|
||||
assert!(
|
||||
!config_text.contains("[[networks]]"),
|
||||
"inline block should be gone after migration: {config_text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn core_profiles_are_not_resurrected_once_config_is_user_owned() {
|
||||
// After the first save the config is user-owned: a deliberately deleted
|
||||
// core profile must stay deleted.
|
||||
let sb = Sandbox::new();
|
||||
fs::create_dir_all(sb.root.join("config/breadcrumbs")).unwrap();
|
||||
fs::write(
|
||||
sb.config_file(),
|
||||
"[settings]\ncore_profiles_initialized = true\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let o = sb.cmd(&["profile", "list"]);
|
||||
assert!(o.status.success(), "stderr: {}", stderr(&o));
|
||||
let out = stdout(&o);
|
||||
assert!(
|
||||
!out.contains("home") && !out.contains("work") && !out.contains("away"),
|
||||
"deleted core profiles must stay deleted: {out}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_config_without_profiles_gets_core_profiles_backfilled() {
|
||||
// Pre-ownership configs (no flag yet) still get the core profiles
|
||||
// backfilled once — the self-heal that makes bare `[settings]` configs
|
||||
// usable.
|
||||
let sb = Sandbox::new();
|
||||
fs::create_dir_all(sb.root.join("config/breadcrumbs")).unwrap();
|
||||
fs::write(sb.config_file(), "[settings]\n").unwrap();
|
||||
|
||||
let o = sb.cmd(&["profile", "list"]);
|
||||
assert!(o.status.success(), "stderr: {}", stderr(&o));
|
||||
let out = stdout(&o);
|
||||
assert!(
|
||||
out.contains("home") && out.contains("work") && out.contains("away"),
|
||||
"legacy configs get the core profiles backfilled once: {out}"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// New features: per-network DNS, enterprise (802.1x) networks, --json
|
||||
// output, prune, scored detection, and init --wait retry.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn add_with_dns_persists_per_network_override() {
|
||||
let sb = Sandbox::new();
|
||||
let o = sb.cmd(&["add", "CafeWifi", "pw", "--dns", "9.9.9.9"]);
|
||||
assert!(o.status.success(), "stderr: {}", stderr(&o));
|
||||
let networks = fs::read_to_string(sb.networks_file()).unwrap();
|
||||
assert!(
|
||||
networks.contains("dns = \"9.9.9.9\""),
|
||||
"per-network DNS override must be persisted: {networks}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_enterprise_fields_persist() {
|
||||
let sb = Sandbox::new();
|
||||
let o = sb.cmd(&[
|
||||
"add",
|
||||
"CorpWifi",
|
||||
"pw",
|
||||
"--eap",
|
||||
"peap",
|
||||
"--identity",
|
||||
"user@corp",
|
||||
"--ca-cert",
|
||||
"/etc/ca.pem",
|
||||
]);
|
||||
assert!(o.status.success(), "stderr: {}", stderr(&o));
|
||||
let networks = fs::read_to_string(sb.networks_file()).unwrap();
|
||||
assert!(networks.contains("eap = \"peap\""), "networks: {networks}");
|
||||
assert!(networks.contains("identity = \"user@corp\""));
|
||||
assert!(networks.contains("ca_cert = \"/etc/ca.pem\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_json_emits_machine_readable_output() {
|
||||
let sb = Sandbox::new();
|
||||
let o = sb.cmd(&["status", "--json"]);
|
||||
// No adapter in the sandbox → unhealthy (exit 1), but still valid JSON.
|
||||
assert_eq!(o.status.code(), Some(1));
|
||||
let v: serde_json::Value =
|
||||
serde_json::from_str(&stdout(&o)).expect("status --json must emit valid JSON");
|
||||
assert_eq!(v["profile"].as_str(), Some("away"));
|
||||
assert_eq!(v["healthy"].as_bool(), Some(false));
|
||||
assert_eq!(v["internet"].as_bool(), Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_json_emits_machine_readable_output() {
|
||||
let sb = Sandbox::new();
|
||||
sb.write_fake_bin("nmcli", FAKE_NMCLI_DETECT);
|
||||
sb.cmd(&["list"]); // bootstrap the default config
|
||||
|
||||
let text = fs::read_to_string(sb.config_file()).unwrap();
|
||||
let patched = text.replace(
|
||||
"[profiles.work]",
|
||||
"[profiles.work]\ndetect_ssids = [\"CorpWifi\"]",
|
||||
);
|
||||
fs::write(sb.config_file(), patched).unwrap();
|
||||
|
||||
let o = sb.cmd(&["detect", "--json"]);
|
||||
assert!(o.status.success(), "stderr: {}", stderr(&o));
|
||||
let v: serde_json::Value =
|
||||
serde_json::from_str(&stdout(&o)).expect("detect --json must emit valid JSON");
|
||||
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);
|
||||
sb.cmd(&["list"]); // bootstrap
|
||||
|
||||
// home matches 1 marker (CorpWifi); work matches 2 (CorpWifi + CafeWifi).
|
||||
let text = fs::read_to_string(sb.config_file()).unwrap();
|
||||
let patched = text
|
||||
.replace(
|
||||
"[profiles.home]",
|
||||
"[profiles.home]\ndetect_ssids = [\"CorpWifi\"]",
|
||||
)
|
||||
.replace(
|
||||
"[profiles.work]",
|
||||
"[profiles.work]\ndetect_ssids = [\"CorpWifi\", \"CafeWifi\"]",
|
||||
);
|
||||
fs::write(sb.config_file(), patched).unwrap();
|
||||
|
||||
let o = sb.cmd(&["detect"]);
|
||||
assert!(o.status.success(), "stderr: {}", stderr(&o));
|
||||
assert_eq!(
|
||||
stdout(&o).trim(),
|
||||
"work",
|
||||
"the profile with more matching markers must win"
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
sb.cmd(&["list"]); // bootstrap (no saved networks → everything is stale)
|
||||
|
||||
let o = sb.cmd(&["prune", "--dry-run"]);
|
||||
assert!(o.status.success(), "stderr: {}", stderr(&o));
|
||||
let out = stdout(&o);
|
||||
assert!(
|
||||
out.contains("would remove") && out.contains("OldCafe"),
|
||||
"out: {out}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prune_removes_stale_nm_profiles() {
|
||||
let sb = Sandbox::new();
|
||||
sb.write_fake_bin("nmcli", FAKE_NMCLI_PRUNE);
|
||||
sb.cmd(&["list"]);
|
||||
|
||||
let o = sb.cmd(&["prune"]);
|
||||
assert!(o.status.success(), "stderr: {}", stderr(&o));
|
||||
let out = stdout(&o);
|
||||
assert!(
|
||||
out.contains("removed") && out.contains("OldCafe"),
|
||||
"out: {out}"
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
// "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.
|
||||
let o = sb.cmd(&["init", "--wait", "5"]);
|
||||
assert!(o.status.success(), "stderr: {}", stderr(&o));
|
||||
assert!(stdout(&o).contains("connected"), "out: {}", stdout(&o));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ use bread_utils::bread_client::BreadEvent;
|
|||
use breadcrumbs::bread_events;
|
||||
use breadcrumbs::config::{Config, NetworkDef, Profile, Settings};
|
||||
use breadcrumbs::flow;
|
||||
use breadcrumbs::nm;
|
||||
use breadcrumbs::state::{self, State};
|
||||
use breadcrumbs::util::with_runner;
|
||||
use breadcrumbs::watch::{classify, Health};
|
||||
|
|
@ -25,6 +24,10 @@ fn net(ssid: &str, password: Option<&str>) -> NetworkDef {
|
|||
NetworkDef {
|
||||
ssid: ssid.to_string(),
|
||||
password: password.map(str::to_string),
|
||||
dns: None,
|
||||
eap: None,
|
||||
identity: None,
|
||||
ca_cert: None,
|
||||
hidden: false,
|
||||
}
|
||||
}
|
||||
|
|
@ -33,6 +36,10 @@ fn hidden_net(ssid: &str, password: Option<&str>) -> NetworkDef {
|
|||
NetworkDef {
|
||||
ssid: ssid.to_string(),
|
||||
password: password.map(str::to_string),
|
||||
dns: None,
|
||||
eap: None,
|
||||
identity: None,
|
||||
ca_cert: None,
|
||||
hidden: true,
|
||||
}
|
||||
}
|
||||
|
|
@ -52,17 +59,28 @@ fn base_config() -> Config {
|
|||
/// 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 match: `-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.
|
||||
// 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(""))
|
||||
|
|
@ -121,7 +139,10 @@ fn flow_run_connects_to_first_visible_candidate_in_priority_order() {
|
|||
let _env = EnvSandbox::new();
|
||||
|
||||
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 {
|
||||
|
|
@ -147,11 +168,10 @@ fn flow_run_connects_to_first_visible_candidate_in_priority_order() {
|
|||
|
||||
// Priority order actually mattered: "Second" was never dialed 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")
|
||||
});
|
||||
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");
|
||||
|
||||
// The password used for the winning connect is now NM's problem, not
|
||||
|
|
@ -346,7 +366,9 @@ fn classify_reports_unknown_profile_without_touching_nm() {
|
|||
|
||||
let runner = FakeRunner::new();
|
||||
let calls = runner.calls_handle();
|
||||
let (health, ssid) = with_runner(runner, || classify(&cfg, "ghost"));
|
||||
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);
|
||||
|
|
@ -361,7 +383,8 @@ fn classify_reports_no_adapter_when_wifi_interface_absent() {
|
|||
|
||||
// `device status` succeeds but lists no wifi-type device.
|
||||
let runner = FakeRunner::new().on_contains("nmcli", "DEVICE,TYPE", ok("eth0:ethernet"));
|
||||
let (health, _) = with_runner(runner, || classify(&cfg, "away"));
|
||||
let class = with_runner(runner, || classify(&cfg, "away"));
|
||||
let health = class.health;
|
||||
|
||||
assert_eq!(health, Health::NoAdapter);
|
||||
}
|
||||
|
|
@ -377,7 +400,9 @@ fn classify_reports_down_no_net_when_internet_check_fails() {
|
|||
.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 (health, ssid) = with_runner(runner, || classify(&cfg, "away"));
|
||||
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()));
|
||||
|
|
@ -395,7 +420,9 @@ fn classify_reports_up_when_healthy_and_tailscale_not_required() {
|
|||
.on_contains("nmcli", "IP4.ADDRESS", ok("192.168.1.50/24"))
|
||||
.with_command("curl")
|
||||
.on(|prog, _| prog == "curl", ok("204"));
|
||||
let (health, ssid) = with_runner(runner, || classify(&cfg, "home"));
|
||||
let class = with_runner(runner, || classify(&cfg, "home"));
|
||||
let health = class.health;
|
||||
let ssid = class.ssid;
|
||||
|
||||
assert_eq!(health, Health::Up);
|
||||
assert_eq!(ssid, Some("HomeWifi".to_string()));
|
||||
|
|
@ -421,7 +448,8 @@ fn classify_reports_down_tailscale_manual_when_not_installed() {
|
|||
.on_contains("nmcli", "IP4.ADDRESS", ok("10.0.0.5/24"))
|
||||
.with_command("curl")
|
||||
.on(|prog, _| prog == "curl", ok("204"));
|
||||
let (health, _) = with_runner(runner, || classify(&cfg, "work"));
|
||||
let class = with_runner(runner, || classify(&cfg, "work"));
|
||||
let health = class.health;
|
||||
|
||||
assert_eq!(health, Health::DownTailscaleManual);
|
||||
}
|
||||
|
|
@ -449,7 +477,8 @@ fn classify_reports_down_tailscale_manual_when_needs_login() {
|
|||
|prog, args| prog == "tailscale" && args.contains(&"status"),
|
||||
ok(r#"{"BackendState":"NeedsLogin"}"#),
|
||||
);
|
||||
let (health, _) = with_runner(runner, || classify(&cfg, "work"));
|
||||
let class = with_runner(runner, || classify(&cfg, "work"));
|
||||
let health = class.health;
|
||||
|
||||
assert_eq!(health, Health::DownTailscaleManual);
|
||||
}
|
||||
|
|
@ -479,7 +508,8 @@ fn classify_reports_down_tailscale_other_when_exit_node_offline() {
|
|||
|prog, args| prog == "tailscale" && args.contains(&"status"),
|
||||
ok(json),
|
||||
);
|
||||
let (health, _) = with_runner(runner, || classify(&cfg, "work"));
|
||||
let class = with_runner(runner, || classify(&cfg, "work"));
|
||||
let health = class.health;
|
||||
|
||||
assert_eq!(health, Health::DownTailscaleOther);
|
||||
}
|
||||
|
|
@ -505,7 +535,8 @@ fn classify_reports_up_when_tailscale_healthy() {
|
|||
.with_command("tailscale")
|
||||
.on(|prog, _| prog == "curl", ok("204"))
|
||||
.on_contains("tailscale", "status", ok(&tailscale_json_ok("exitnode")));
|
||||
let (health, _) = with_runner(runner, || classify(&cfg, "work"));
|
||||
let class = with_runner(runner, || classify(&cfg, "work"));
|
||||
let health = class.health;
|
||||
|
||||
assert_eq!(health, Health::Up);
|
||||
}
|
||||
|
|
@ -622,82 +653,479 @@ fn handle_command_ignores_events_outside_its_own_command_namespace() {
|
|||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// PSK never on argv (first connect feeds nmcli --ask on stdin)
|
||||
// Regression tests for the audit fixes.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
fn assert_psk_not_on_argv(calls: &[common::RecordedCall], psk: &str) {
|
||||
for c in calls {
|
||||
if c.prog != "nmcli" {
|
||||
continue;
|
||||
#[test]
|
||||
fn flow_run_reports_no_exit_node_and_never_clears_selection() {
|
||||
// A tailscale profile with no exit node configured must report
|
||||
// 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 mut cfg = base_config();
|
||||
cfg.networks = vec![net("Corp", Some("corp-pw"))];
|
||||
cfg.profiles.insert(
|
||||
"work".into(),
|
||||
Profile {
|
||||
networks: vec!["Corp".into()],
|
||||
tailscale: true,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
let runner = base_nm(&["Corp"]).with_command("tailscale");
|
||||
let calls = runner.calls_handle();
|
||||
let outcome = with_runner(runner, || flow::run(&mut cfg, "work"));
|
||||
|
||||
match &outcome {
|
||||
flow::Outcome::TailscaleError { health, .. } => {
|
||||
assert_eq!(*health, breadcrumbs::tailscale::TsHealth::NoExitNode);
|
||||
}
|
||||
other => panic!("expected TailscaleError(NoExitNode), got {other:?}"),
|
||||
}
|
||||
assert!(
|
||||
!calls.borrow().iter().any(|c| c.prog == "tailscale"),
|
||||
"with no exit node configured, tailscale must not be touched: {:?}",
|
||||
calls.borrow()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_reports_down_tailscale_manual_when_no_exit_node_configured() {
|
||||
// An unset exit node needs human action (config edit), so it must
|
||||
// classify as DownTailscaleManual — not DownTailscaleOther, which would
|
||||
// make the watcher spin auto-recovery forever.
|
||||
let _env = EnvSandbox::new();
|
||||
let mut cfg = base_config();
|
||||
cfg.profiles.insert(
|
||||
"work".into(),
|
||||
Profile {
|
||||
tailscale: true,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
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 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()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_exit_node_attempts_to_start_unreachable_daemon() {
|
||||
// `tailscale status --json` with empty stdout is the "daemon not
|
||||
// running" signature (the error goes to stderr). ensure_exit_node must
|
||||
// try `tailscale up` and re-read instead of bailing out with an opaque
|
||||
// error — the old dead-code path that made the Stopped recovery
|
||||
// unreachable.
|
||||
let _env = EnvSandbox::new();
|
||||
let runner = FakeRunner::new()
|
||||
.with_command("tailscale")
|
||||
.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()])
|
||||
});
|
||||
assert!(
|
||||
matches!(health, breadcrumbs::tailscale::TsHealth::Error(_)),
|
||||
"daemon still unreachable after `up` → Error, got {health:?}"
|
||||
);
|
||||
let tailscale_calls: Vec<String> = calls
|
||||
.borrow()
|
||||
.iter()
|
||||
.filter(|c| c.prog == "tailscale")
|
||||
.map(|c| c.args.join(" "))
|
||||
.collect();
|
||||
assert!(
|
||||
tailscale_calls.iter().any(|c| c.starts_with("up")),
|
||||
"must attempt `tailscale up` when the daemon is unreachable: {tailscale_calls:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flow_run_fails_when_device_lands_on_wrong_ssid() {
|
||||
// NM autoconnect race: the connect succeeds but the device ends up on a
|
||||
// *different* network than requested. flow must not report Connected to
|
||||
// the requested SSID, and must not clear its password.
|
||||
let _env = EnvSandbox::new();
|
||||
let mut cfg = base_config();
|
||||
cfg.networks = vec![net("First", Some("pw1"))];
|
||||
cfg.profiles.insert(
|
||||
"home".into(),
|
||||
Profile {
|
||||
networks: vec!["First".into()],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
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"));
|
||||
|
||||
assert!(
|
||||
!matches!(outcome, flow::Outcome::Connected { .. }),
|
||||
"must not report Connected when the device is on a different SSID: {outcome:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
cfg.network("First").unwrap().password,
|
||||
Some("pw1".to_string()),
|
||||
"password must not be cleared for a network that was never joined"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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 mut cfg = base_config(); // no profiles → UnknownProfile path notifies
|
||||
|
||||
let runner = FakeRunner::new().with_command("notify-send");
|
||||
let calls = runner.calls_handle();
|
||||
with_runner(runner, || flow::run_quiet(&mut cfg, "ghost"));
|
||||
assert!(
|
||||
!calls.borrow().iter().any(|c| c.prog == "notify-send"),
|
||||
"run_quiet must not fire desktop notifications: {:?}",
|
||||
calls.borrow()
|
||||
);
|
||||
|
||||
let runner = FakeRunner::new().with_command("notify-send");
|
||||
let calls = runner.calls_handle();
|
||||
with_runner(runner, || flow::run(&mut cfg, "ghost"));
|
||||
assert!(
|
||||
calls.borrow().iter().any(|c| c.prog == "notify-send"),
|
||||
"run (CLI path) must still notify: {:?}",
|
||||
calls.borrow()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn internet_ok_requires_204_and_falls_back_to_ping() {
|
||||
// Only 204 counts as internet: captive/guest portals answer 200/301/302
|
||||
// with a login page or redirect, so those must not report healthy.
|
||||
let _env = EnvSandbox::new();
|
||||
let cfg = base_config();
|
||||
|
||||
let r = FakeRunner::new().with_command("curl").on(|p, _| p == "curl", ok("204"));
|
||||
assert!(with_runner(r, || breadcrumbs::status::internet_ok(&cfg)));
|
||||
|
||||
for code in ["200", "301", "302"] {
|
||||
let r = FakeRunner::new()
|
||||
.with_command("curl")
|
||||
.on(|p, _| p == "curl", ok(code))
|
||||
.on(|p, _| p == "ping", fail(""));
|
||||
assert!(
|
||||
!c.args.iter().any(|a| a == psk),
|
||||
"PSK leaked onto nmcli argv: {:?}",
|
||||
c.args
|
||||
!with_runner(r, || breadcrumbs::status::internet_ok(&cfg)),
|
||||
"{code} must not count as internet"
|
||||
);
|
||||
}
|
||||
|
||||
// curl absent → the ping fallback decides.
|
||||
let r = FakeRunner::new().with_command("ping").on(|p, _| p == "ping", ok(""));
|
||||
assert!(with_runner(r, || breadcrumbs::status::internet_ok(&cfg)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connect_verbose_create_feeds_psk_on_stdin_never_argv() {
|
||||
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).
|
||||
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"));
|
||||
|
||||
assert_eq!(list.len(), 2, "dedup by SSID, hidden (empty SSID) skipped: {list:?}");
|
||||
let cafe = list.iter().find(|e| e.ssid == "Cafe").unwrap();
|
||||
assert_eq!(cafe.signal, "90 %", "strongest signal wins");
|
||||
let office = list.iter().find(|e| e.ssid == "Office").unwrap();
|
||||
assert_eq!(office.signal, "60");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// New features: signal-aware selection, per-network DNS, learning,
|
||||
// captive portals, exit-node failover, preferred interface, enterprise
|
||||
// (802.1x) connect.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn flow_run_prefers_strongest_visible_signal_over_priority_order() {
|
||||
let _env = EnvSandbox::new();
|
||||
let mut cfg = base_config();
|
||||
cfg.networks = vec![net("Weak", Some("pw1")), net("Strong", Some("pw2"))];
|
||||
cfg.profiles.insert(
|
||||
"home".into(),
|
||||
Profile {
|
||||
networks: vec!["Weak".into(), "Strong".into()],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
// "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"));
|
||||
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"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flow_run_pins_per_network_dns_override() {
|
||||
let _env = EnvSandbox::new();
|
||||
let mut cfg = base_config();
|
||||
cfg.settings.dns = "1.1.1.1".into();
|
||||
let mut def = net("Home", Some("pw"));
|
||||
def.dns = Some("9.9.9.9".into());
|
||||
cfg.networks = vec![def];
|
||||
cfg.profiles.insert(
|
||||
"home".into(),
|
||||
Profile {
|
||||
networks: vec!["Home".into()],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
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"));
|
||||
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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flow_run_appends_learned_ssid_to_detect_ssids() {
|
||||
let _env = EnvSandbox::new();
|
||||
let mut cfg = base_config();
|
||||
cfg.networks = vec![net("Home", Some("pw"))];
|
||||
cfg.profiles.insert(
|
||||
"home".into(),
|
||||
Profile {
|
||||
networks: vec!["Home".into()],
|
||||
learn: true,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
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"));
|
||||
assert!(matches!(outcome, flow::Outcome::Connected { .. }));
|
||||
|
||||
assert_eq!(
|
||||
cfg.profile("home").unwrap().detect_ssids,
|
||||
vec!["Home".to_string()],
|
||||
"a successful connect on a learn=true profile must record the SSID"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_reports_captive_portal_when_connectivity_returns_200() {
|
||||
let _env = EnvSandbox::new();
|
||||
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 class = with_runner(runner, || classify(&cfg, "home"));
|
||||
|
||||
assert_eq!(class.health, Health::CaptivePortal);
|
||||
assert_eq!(class.ssid, Some("HomeWifi".to_string()));
|
||||
}
|
||||
|
||||
#[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 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")
|
||||
.on_contains("tailscale", "status", ok(json))
|
||||
.on(|p, args| p == "tailscale" && args.contains(&"set"), ok(""));
|
||||
let calls = runner.calls_handle();
|
||||
|
||||
let health = with_runner(runner, || {
|
||||
breadcrumbs::tailscale::ensure_exit_node(&["nodeA".into(), "nodeB".into()])
|
||||
});
|
||||
assert_eq!(health, breadcrumbs::tailscale::TsHealth::Ok);
|
||||
|
||||
let sets: Vec<String> = calls
|
||||
.borrow()
|
||||
.iter()
|
||||
.filter(|c| c.args.iter().any(|a| a == "set"))
|
||||
.map(|c| c.args.join(" "))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
sets,
|
||||
vec!["set --exit-node=nodeA".to_string(), "set --exit-node=nodeB".to_string()],
|
||||
"failover must try 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"));
|
||||
}
|
||||
|
||||
#[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"));
|
||||
}
|
||||
|
||||
#[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"));
|
||||
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 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_contains("nmcli", "connect", 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 net = net("Cafe", Some("super-secret-psk"));
|
||||
let result = with_runner(runner, || nm::connect_verbose("wlan0", &net, 8, "1.1.1.1"));
|
||||
assert!(result.is_ok(), "{result:?}");
|
||||
|
||||
let calls = calls.borrow();
|
||||
assert_psk_not_on_argv(&calls, "super-secret-psk");
|
||||
let connect = calls
|
||||
.iter()
|
||||
.find(|c| c.prog == "nmcli" && c.args.iter().any(|a| a == "connect"))
|
||||
.expect("expected device wifi connect");
|
||||
assert!(
|
||||
connect.args.iter().any(|a| a == "--ask"),
|
||||
"create path must use --ask: {:?}",
|
||||
connect.args
|
||||
);
|
||||
assert_eq!(connect.stdin.as_deref(), Some("super-secret-psk\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connect_verbose_reuse_feeds_psk_on_stdin_never_argv() {
|
||||
let runner = FakeRunner::new()
|
||||
.on_contains("nmcli", "NAME,TYPE", ok("Cafe:802-11-wireless"))
|
||||
.on_contains("nmcli", "connection modify", ok(""))
|
||||
.on_contains("nmcli", "connection 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 net = net("Cafe", Some("super-secret-psk"));
|
||||
let result = with_runner(runner, || nm::connect_verbose("wlan0", &net, 8, "1.1.1.1"));
|
||||
assert!(result.is_ok(), "{result:?}");
|
||||
|
||||
let calls = calls.borrow();
|
||||
assert_psk_not_on_argv(&calls, "super-secret-psk");
|
||||
let up = calls
|
||||
.iter()
|
||||
.find(|c| c.prog == "nmcli" && c.args.iter().any(|a| a == "up"))
|
||||
.expect("expected connection up");
|
||||
assert!(
|
||||
up.args.iter().any(|a| a == "--ask"),
|
||||
"reuse path must use --ask: {:?}",
|
||||
up.args
|
||||
);
|
||||
assert_eq!(up.stdin.as_deref(), Some("super-secret-psk\n"));
|
||||
// Clearing the stored PSK uses an empty argv value, never the secret.
|
||||
let cleared = calls.iter().any(|c| {
|
||||
c.prog == "nmcli"
|
||||
&& c.args.iter().any(|a| a == "802-11-wireless-security.psk")
|
||||
&& c.args.last().is_some_and(|a| a.is_empty())
|
||||
let res = with_runner(runner, || {
|
||||
breadcrumbs::nm::connect_verbose("wlan0", &def, 8, "1.1.1.1")
|
||||
});
|
||||
assert!(cleared, "reuse+password should reset stored PSK: {calls:?}");
|
||||
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"));
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue