feat: Windows DNS configuration via netsh
numa install/uninstall now set/restore system DNS on Windows via netsh. Parses ipconfig /all per-interface (adapter name, DHCP status, DNS servers), saves backup to %APPDATA%\numa\original-dns.json, and restores on uninstall (DHCP or static with secondary servers). Handles localization (German adapter/DHCP/DNS labels), disconnected adapters, multiple interfaces, and missing admin privileges. Adds IP validation to discover_windows() for consistency. No Windows Service or CA trust yet — user runs numa in a terminal. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
132
scripts/test-ubuntu.sh
Normal file
132
scripts/test-ubuntu.sh
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Ubuntu integration test for Numa PR #27
|
||||||
|
# Usage: scp target/release/numa scripts/test-ubuntu.sh EC2:~ && ssh EC2 'sudo bash test-ubuntu.sh'
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BIN="./numa"
|
||||||
|
PASS=0
|
||||||
|
FAIL=0
|
||||||
|
|
||||||
|
check() {
|
||||||
|
local desc="$1"; shift
|
||||||
|
if "$@" > /dev/null 2>&1; then
|
||||||
|
echo " ✓ $desc"
|
||||||
|
PASS=$((PASS + 1))
|
||||||
|
else
|
||||||
|
echo " ✗ $desc"
|
||||||
|
FAIL=$((FAIL + 1))
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
$BIN uninstall 2>/dev/null || true
|
||||||
|
killall numa 2>/dev/null || true
|
||||||
|
sleep 1
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "=== Numa Ubuntu Integration Tests ==="
|
||||||
|
echo ""
|
||||||
|
chmod +x "$BIN"
|
||||||
|
|
||||||
|
# --- Test 1: Forward mode (default, no config) ---
|
||||||
|
echo "--- Test 1: Forward mode (default) ---"
|
||||||
|
cleanup
|
||||||
|
$BIN 2>&1 &
|
||||||
|
NUMA_PID=$!
|
||||||
|
sleep 3
|
||||||
|
|
||||||
|
check "API responds" curl -sf http://127.0.0.1:5380/health
|
||||||
|
check "mode is forward" bash -c 'curl -sf http://127.0.0.1:5380/stats | grep -q "\"mode\":\"forward\""'
|
||||||
|
check "DNS resolves" bash -c 'dig @127.0.0.1 example.com A +short +time=5 | grep -q "[0-9]"'
|
||||||
|
check "dashboard returns 200" bash -c 'curl -sf -o /dev/null -w "%{http_code}" http://127.0.0.1:5380/ | grep -q 200'
|
||||||
|
kill $NUMA_PID 2>/dev/null; sleep 1
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# --- Test 2: Recursive mode (explicit opt-in) ---
|
||||||
|
echo "--- Test 2: Recursive mode ---"
|
||||||
|
cleanup
|
||||||
|
mkdir -p /tmp/numa-test
|
||||||
|
cat > /tmp/numa-test/numa.toml << 'TOML'
|
||||||
|
[upstream]
|
||||||
|
mode = "recursive"
|
||||||
|
[dnssec]
|
||||||
|
enabled = true
|
||||||
|
TOML
|
||||||
|
$BIN /tmp/numa-test/numa.toml 2>&1 &
|
||||||
|
NUMA_PID=$!
|
||||||
|
sleep 5
|
||||||
|
|
||||||
|
check "API responds" curl -sf http://127.0.0.1:5380/health
|
||||||
|
check "mode is recursive" bash -c 'curl -sf http://127.0.0.1:5380/stats | grep -q "\"mode\":\"recursive\""'
|
||||||
|
check "dnssec enabled" bash -c 'curl -sf http://127.0.0.1:5380/stats | grep -q "\"dnssec\":true"'
|
||||||
|
check "DNS resolves recursively" bash -c 'dig @127.0.0.1 example.com A +short +time=10 | grep -q "[0-9]"'
|
||||||
|
check "AD flag set (DNSSEC)" bash -c 'dig @127.0.0.1 example.com A +dnssec +time=10 | grep "flags:" | grep -q "ad"'
|
||||||
|
kill $NUMA_PID 2>/dev/null; sleep 1
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# --- Test 3: Auto mode ---
|
||||||
|
echo "--- Test 3: Auto mode ---"
|
||||||
|
cleanup
|
||||||
|
cat > /tmp/numa-test/numa.toml << 'TOML'
|
||||||
|
[upstream]
|
||||||
|
mode = "auto"
|
||||||
|
TOML
|
||||||
|
$BIN /tmp/numa-test/numa.toml 2>&1 &
|
||||||
|
NUMA_PID=$!
|
||||||
|
sleep 10
|
||||||
|
|
||||||
|
check "API responds" curl -sf http://127.0.0.1:5380/health
|
||||||
|
MODE=$(curl -sf http://127.0.0.1:5380/stats | python3 -c "import sys,json; print(json.load(sys.stdin)['mode'])" 2>/dev/null || echo "unknown")
|
||||||
|
echo " → auto resolved to: $MODE"
|
||||||
|
check "mode is recursive or forward" bash -c "echo '$MODE' | grep -qE '^(recursive|forward)$'"
|
||||||
|
check "DNS resolves" bash -c 'dig @127.0.0.1 example.com A +short +time=10 | grep -q "[0-9]"'
|
||||||
|
kill $NUMA_PID 2>/dev/null; sleep 1
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# --- Test 4: Install / Uninstall ---
|
||||||
|
echo "--- Test 4: Install / Uninstall ---"
|
||||||
|
cleanup
|
||||||
|
cp "$BIN" /usr/local/bin/numa
|
||||||
|
|
||||||
|
echo " Installing..."
|
||||||
|
INSTALL_OUTPUT=$($BIN install 2>&1) || true
|
||||||
|
echo "$INSTALL_OUTPUT"
|
||||||
|
check "post-install mentions recursive" bash -c "echo '$INSTALL_OUTPUT' | grep -q 'recursive'"
|
||||||
|
sleep 3
|
||||||
|
|
||||||
|
check "service is running" systemctl is-active numa
|
||||||
|
check "API responds after install" curl -sf http://127.0.0.1:5380/health
|
||||||
|
check "DNS resolves after install" bash -c 'dig @127.0.0.1 example.com A +short +time=5 | grep -q "[0-9]"'
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo " Uninstalling..."
|
||||||
|
$BIN uninstall 2>&1 || true
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
check "service stopped" bash -c '! systemctl is-active numa'
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# --- Test 5: Port 53 conflict ---
|
||||||
|
echo "--- Test 5: Port 53 conflict ---"
|
||||||
|
cleanup
|
||||||
|
# Start a dummy listener on port 53
|
||||||
|
python3 -c "import socket; s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM); s.bind(('0.0.0.0',53)); input()" &
|
||||||
|
BLOCKER_PID=$!
|
||||||
|
sleep 1
|
||||||
|
|
||||||
|
$BIN 2>&1 &
|
||||||
|
NUMA_PID=$!
|
||||||
|
sleep 3
|
||||||
|
# numa should fail to bind
|
||||||
|
check "numa fails when port 53 taken" bash -c '! kill -0 $NUMA_PID 2>/dev/null'
|
||||||
|
kill $BLOCKER_PID 2>/dev/null
|
||||||
|
kill $NUMA_PID 2>/dev/null
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# --- Cleanup ---
|
||||||
|
cleanup
|
||||||
|
rm -rf /tmp/numa-test
|
||||||
|
|
||||||
|
echo "=== Results: $PASS passed, $FAIL failed ==="
|
||||||
|
[ $FAIL -eq 0 ] && echo "All tests passed!" || echo "Some tests failed."
|
||||||
|
exit $FAIL
|
||||||
@@ -334,7 +334,7 @@ fn discover_windows() -> SystemDnsInfo {
|
|||||||
if trimmed.contains("DNS Servers") || trimmed.contains("DNS-Server") {
|
if trimmed.contains("DNS Servers") || trimmed.contains("DNS-Server") {
|
||||||
if let Some(ip) = trimmed.split(':').next_back() {
|
if let Some(ip) = trimmed.split(':').next_back() {
|
||||||
let ip = ip.trim();
|
let ip = ip.trim();
|
||||||
if !is_loopback_or_stub(ip) {
|
if ip.parse::<std::net::IpAddr>().is_ok() && !is_loopback_or_stub(ip) {
|
||||||
upstream = Some(ip.to_string());
|
upstream = Some(ip.to_string());
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -358,6 +358,231 @@ fn discover_windows() -> SystemDnsInfo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(any(windows, test))]
|
||||||
|
#[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq)]
|
||||||
|
struct WindowsInterfaceDns {
|
||||||
|
dhcp: bool,
|
||||||
|
servers: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(any(windows, test))]
|
||||||
|
fn parse_ipconfig_interfaces(text: &str) -> std::collections::HashMap<String, WindowsInterfaceDns> {
|
||||||
|
let mut interfaces = std::collections::HashMap::new();
|
||||||
|
let mut current_adapter: Option<String> = None;
|
||||||
|
let mut current_dhcp = false;
|
||||||
|
let mut current_dns: Vec<String> = Vec::new();
|
||||||
|
let mut in_dns_block = false;
|
||||||
|
let mut disconnected = false;
|
||||||
|
|
||||||
|
for line in text.lines() {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
|
||||||
|
// Adapter section headers start at column 0
|
||||||
|
if !trimmed.is_empty() && !line.starts_with(' ') && !line.starts_with('\t') {
|
||||||
|
if let Some(name) = current_adapter.take() {
|
||||||
|
if !disconnected {
|
||||||
|
interfaces.insert(
|
||||||
|
name,
|
||||||
|
WindowsInterfaceDns {
|
||||||
|
dhcp: current_dhcp,
|
||||||
|
servers: std::mem::take(&mut current_dns),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
current_dns.clear();
|
||||||
|
}
|
||||||
|
in_dns_block = false;
|
||||||
|
current_dhcp = false;
|
||||||
|
disconnected = false;
|
||||||
|
|
||||||
|
// "XXX adapter YYY:" (English) / "XXX Adapter YYY:" (German)
|
||||||
|
let lower = trimmed.to_lowercase();
|
||||||
|
if let Some(pos) = lower.find(" adapter ") {
|
||||||
|
let after = &trimmed[pos + " adapter ".len()..];
|
||||||
|
let name = after.trim_end_matches(':').trim();
|
||||||
|
if !name.is_empty() {
|
||||||
|
current_adapter = Some(name.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if current_adapter.is_some() {
|
||||||
|
if trimmed.contains("Media disconnected") || trimmed.contains("Medienstatus") {
|
||||||
|
disconnected = true;
|
||||||
|
} else if trimmed.contains("DHCP") && trimmed.contains(". .") {
|
||||||
|
current_dhcp = trimmed
|
||||||
|
.split(':')
|
||||||
|
.next_back()
|
||||||
|
.map(|v| {
|
||||||
|
let v = v.trim().to_lowercase();
|
||||||
|
v == "yes" || v == "ja"
|
||||||
|
})
|
||||||
|
.unwrap_or(false);
|
||||||
|
in_dns_block = false;
|
||||||
|
} else if trimmed.contains("DNS Servers") || trimmed.contains("DNS-Server") {
|
||||||
|
in_dns_block = true;
|
||||||
|
if let Some(ip) = trimmed.split(':').next_back() {
|
||||||
|
let ip = ip.trim();
|
||||||
|
if ip.parse::<std::net::IpAddr>().is_ok() {
|
||||||
|
current_dns.push(ip.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if in_dns_block {
|
||||||
|
if trimmed.parse::<std::net::IpAddr>().is_ok() {
|
||||||
|
current_dns.push(trimmed.to_string());
|
||||||
|
} else {
|
||||||
|
in_dns_block = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(name) = current_adapter {
|
||||||
|
if !disconnected {
|
||||||
|
interfaces.insert(
|
||||||
|
name,
|
||||||
|
WindowsInterfaceDns {
|
||||||
|
dhcp: current_dhcp,
|
||||||
|
servers: current_dns,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interfaces
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn get_windows_interfaces() -> Result<std::collections::HashMap<String, WindowsInterfaceDns>, String>
|
||||||
|
{
|
||||||
|
let output = std::process::Command::new("ipconfig")
|
||||||
|
.arg("/all")
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("failed to run ipconfig /all: {}", e))?;
|
||||||
|
let text = String::from_utf8_lossy(&output.stdout);
|
||||||
|
Ok(parse_ipconfig_interfaces(&text))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn windows_backup_path() -> std::path::PathBuf {
|
||||||
|
crate::config_dir().join("original-dns.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn install_windows() -> Result<(), String> {
|
||||||
|
let interfaces = get_windows_interfaces()?;
|
||||||
|
if interfaces.is_empty() {
|
||||||
|
return Err("no active network interfaces found".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = windows_backup_path();
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)
|
||||||
|
.map_err(|e| format!("failed to create {}: {}", parent.display(), e))?;
|
||||||
|
}
|
||||||
|
let json = serde_json::to_string_pretty(&interfaces)
|
||||||
|
.map_err(|e| format!("failed to serialize backup: {}", e))?;
|
||||||
|
std::fs::write(&path, json).map_err(|e| format!("failed to write backup: {}", e))?;
|
||||||
|
|
||||||
|
for name in interfaces.keys() {
|
||||||
|
let status = std::process::Command::new("netsh")
|
||||||
|
.args([
|
||||||
|
"interface",
|
||||||
|
"ipv4",
|
||||||
|
"set",
|
||||||
|
"dnsservers",
|
||||||
|
name,
|
||||||
|
"static",
|
||||||
|
"127.0.0.1",
|
||||||
|
"primary",
|
||||||
|
])
|
||||||
|
.status()
|
||||||
|
.map_err(|e| format!("failed to set DNS for {}: {}", name, e))?;
|
||||||
|
|
||||||
|
if status.success() {
|
||||||
|
eprintln!(" set DNS for \"{}\" -> 127.0.0.1", name);
|
||||||
|
} else {
|
||||||
|
eprintln!(
|
||||||
|
" warning: failed to set DNS for \"{}\" (run as Administrator?)",
|
||||||
|
name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
eprintln!("\n Original DNS saved to {}", path.display());
|
||||||
|
eprintln!(" Run 'numa uninstall' to restore.");
|
||||||
|
eprintln!(" Note: run Numa manually with 'numa' in a terminal.\n");
|
||||||
|
eprintln!(" Want full DNS sovereignty? Add to numa.toml:");
|
||||||
|
eprintln!(" [upstream]");
|
||||||
|
eprintln!(" mode = \"recursive\"\n");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn uninstall_windows() -> Result<(), String> {
|
||||||
|
let path = windows_backup_path();
|
||||||
|
let json = std::fs::read_to_string(&path)
|
||||||
|
.map_err(|e| format!("no backup found at {}: {}", path.display(), e))?;
|
||||||
|
let original: std::collections::HashMap<String, WindowsInterfaceDns> =
|
||||||
|
serde_json::from_str(&json).map_err(|e| format!("invalid backup file: {}", e))?;
|
||||||
|
|
||||||
|
for (name, dns_info) in &original {
|
||||||
|
if dns_info.dhcp || dns_info.servers.is_empty() {
|
||||||
|
let status = std::process::Command::new("netsh")
|
||||||
|
.args(["interface", "ipv4", "set", "dnsservers", name, "dhcp"])
|
||||||
|
.status()
|
||||||
|
.map_err(|e| format!("failed to restore DNS for {}: {}", name, e))?;
|
||||||
|
|
||||||
|
if status.success() {
|
||||||
|
eprintln!(" restored DNS for \"{}\" -> DHCP", name);
|
||||||
|
} else {
|
||||||
|
eprintln!(" warning: failed to restore DNS for \"{}\"", name);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let status = std::process::Command::new("netsh")
|
||||||
|
.args([
|
||||||
|
"interface",
|
||||||
|
"ipv4",
|
||||||
|
"set",
|
||||||
|
"dnsservers",
|
||||||
|
name,
|
||||||
|
"static",
|
||||||
|
&dns_info.servers[0],
|
||||||
|
"primary",
|
||||||
|
])
|
||||||
|
.status()
|
||||||
|
.map_err(|e| format!("failed to restore DNS for {}: {}", name, e))?;
|
||||||
|
|
||||||
|
if !status.success() {
|
||||||
|
eprintln!(" warning: failed to restore primary DNS for \"{}\"", name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (i, server) in dns_info.servers.iter().skip(1).enumerate() {
|
||||||
|
let _ = std::process::Command::new("netsh")
|
||||||
|
.args([
|
||||||
|
"interface",
|
||||||
|
"ipv4",
|
||||||
|
"add",
|
||||||
|
"dnsservers",
|
||||||
|
name,
|
||||||
|
server,
|
||||||
|
&format!("index={}", i + 2),
|
||||||
|
])
|
||||||
|
.status();
|
||||||
|
}
|
||||||
|
|
||||||
|
eprintln!(
|
||||||
|
" restored DNS for \"{}\" -> {}",
|
||||||
|
name,
|
||||||
|
dns_info.servers.join(", ")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::fs::remove_file(&path).ok();
|
||||||
|
eprintln!("\n System DNS restored. Backup removed.\n");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Find the upstream for a domain by checking forwarding rules.
|
/// Find the upstream for a domain by checking forwarding rules.
|
||||||
/// Returns None if no rule matches (use default upstream).
|
/// Returns None if no rule matches (use default upstream).
|
||||||
/// Zero-allocation on the hot path — dot_suffix is pre-computed.
|
/// Zero-allocation on the hot path — dot_suffix is pre-computed.
|
||||||
@@ -522,7 +747,9 @@ pub fn install_service() -> Result<(), String> {
|
|||||||
let result = install_service_macos();
|
let result = install_service_macos();
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
let result = install_service_linux();
|
let result = install_service_linux();
|
||||||
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
#[cfg(windows)]
|
||||||
|
let result = install_windows();
|
||||||
|
#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))]
|
||||||
let result = Err::<(), String>("service installation not supported on this OS".to_string());
|
let result = Err::<(), String>("service installation not supported on this OS".to_string());
|
||||||
|
|
||||||
if result.is_ok() {
|
if result.is_ok() {
|
||||||
@@ -546,7 +773,11 @@ pub fn uninstall_service() -> Result<(), String> {
|
|||||||
{
|
{
|
||||||
uninstall_service_linux()
|
uninstall_service_linux()
|
||||||
}
|
}
|
||||||
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
uninstall_windows()
|
||||||
|
}
|
||||||
|
#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))]
|
||||||
{
|
{
|
||||||
Err("service uninstallation not supported on this OS".to_string())
|
Err("service uninstallation not supported on this OS".to_string())
|
||||||
}
|
}
|
||||||
@@ -1027,3 +1258,57 @@ fn untrust_ca() -> Result<(), String> {
|
|||||||
let _ = ca_path; // suppress unused warning on other platforms
|
let _ = ca_path; // suppress unused warning on other platforms
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_ipconfig_dhcp_and_static() {
|
||||||
|
let sample = "\
|
||||||
|
Ethernet adapter Ethernet:
|
||||||
|
|
||||||
|
DHCP Enabled. . . . . . . . . . . : Yes
|
||||||
|
DNS Servers . . . . . . . . . . . : 8.8.8.8
|
||||||
|
8.8.4.4
|
||||||
|
|
||||||
|
Wireless LAN adapter Wi-Fi:
|
||||||
|
|
||||||
|
DHCP Enabled. . . . . . . . . . . : No
|
||||||
|
DNS Servers . . . . . . . . . . . : 1.1.1.1
|
||||||
|
";
|
||||||
|
let result = parse_ipconfig_interfaces(sample);
|
||||||
|
assert_eq!(result.len(), 2);
|
||||||
|
assert_eq!(
|
||||||
|
result["Ethernet"],
|
||||||
|
WindowsInterfaceDns {
|
||||||
|
dhcp: true,
|
||||||
|
servers: vec!["8.8.8.8".into(), "8.8.4.4".into()],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
result["Wi-Fi"],
|
||||||
|
WindowsInterfaceDns {
|
||||||
|
dhcp: false,
|
||||||
|
servers: vec!["1.1.1.1".into()],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_ipconfig_skips_disconnected() {
|
||||||
|
let sample = "\
|
||||||
|
Ethernet adapter Ethernet 2:
|
||||||
|
|
||||||
|
Media State . . . . . . . . . . . : Media disconnected
|
||||||
|
|
||||||
|
Wireless LAN adapter Wi-Fi:
|
||||||
|
|
||||||
|
DHCP Enabled. . . . . . . . . . . : Yes
|
||||||
|
DNS Servers . . . . . . . . . . . : 192.168.1.1
|
||||||
|
";
|
||||||
|
let result = parse_ipconfig_interfaces(sample);
|
||||||
|
assert_eq!(result.len(), 1);
|
||||||
|
assert!(result.contains_key("Wi-Fi"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user