use std::fs;
use std::net::Ipv4Addr;
use std::path::Path;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use super::control_daemon_request_for_test;
use crate::*;
#[test]
fn daemon_runtime_state_requires_advertised_routes() {
let raw = r#"{
"updated_at": 1773650797,
"vpn_enabled": true,
"vpn_active": true,
"vpn_status": "Connected",
"expected_peer_count": 1,
"connected_peer_count": 1,
"mesh_ready": true,
"peers": [
{
"participant_pubkey": "ed91c2fcdf6857157e72497d67be9dad91d865db6407bb0440ca53129e10cb1f",
"node_id": "6bea57f5-e06b-49d1-83b5-484ab0a3df12",
"tunnel_ip": "10.44.0.239/32",
"endpoint": "192.168.178.80:51820",
"public_key": "+fi3YvMFH0JQFNuQPiPy5xBXNKvpaCKIbbbgrlXT5yw=",
"last_mesh_seen_at": 1773650779,
"last_fips_seen_at": 1773650779,
"reachable": true,
"last_handshake_at": null,
"error": null
}
]
}"#;
assert!(serde_json::from_str::<DaemonRuntimeState>(raw).is_err());
}
#[test]
fn read_daemon_state_trims_nul_padding() {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock is after epoch")
.as_nanos();
let dir = std::env::temp_dir().join(format!("nvpn-daemon-state-trim-test-{nonce}"));
fs::create_dir_all(&dir).expect("create temp dir");
let state_path = dir.join("daemon.state.json");
let mut raw = br#"{
"updated_at": 1,
"binary_version": "test",
"vpn_enabled": true,
"vpn_active": true,
"vpn_status": "Ready",
"expected_peer_count": 0,
"connected_peer_count": 0,
"mesh_ready": false
}"#
.to_vec();
raw.extend_from_slice(&[0, 0, b'\n']);
fs::write(&state_path, raw).expect("write padded daemon state");
let state = read_daemon_state(&state_path)
.expect("read daemon state")
.expect("daemon state should exist");
assert_eq!(state.vpn_status, "Ready");
let rewritten = fs::read(&state_path).expect("read rewritten daemon state");
assert!(!rewritten.contains(&0));
let _ = fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn atomic_runtime_write_creates_private_file() {
use std::os::unix::fs::PermissionsExt;
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock is after epoch")
.as_nanos();
let dir = std::env::temp_dir().join(format!("nvpn-runtime-mode-test-{nonce}"));
fs::create_dir_all(&dir).expect("create temp dir");
let path = dir.join("staged-config.toml");
write_runtime_file_atomically(&path, b"secret = true\n").expect("write runtime file");
let mode = fs::metadata(&path)
.expect("runtime metadata")
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o600);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn daemon_control_request_projects_desired_vpn_state_immediately() {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock is after epoch")
.as_nanos();
let dir = std::env::temp_dir().join(format!("nvpn-control-project-test-{nonce}"));
fs::create_dir_all(&dir).expect("create temp dir");
let config_path = dir.join("config.toml");
let state_path = daemon_state_file_path(&config_path);
let state = DaemonRuntimeState {
updated_at: 1,
vpn_enabled: true,
vpn_active: true,
vpn_status: "VPN on".to_string(),
expected_peer_count: 1,
connected_peer_count: 1,
mesh_ready: true,
..DaemonRuntimeState::default()
};
write_daemon_state(&state_path, &state).expect("write daemon state");
write_daemon_control_request(&config_path, DaemonControlRequest::Pause)
.expect("write pause control request");
let paused = read_daemon_state(&state_path)
.expect("read projected daemon state")
.expect("daemon state should exist");
assert!(!paused.vpn_enabled);
assert!(paused.vpn_active);
assert_eq!(paused.vpn_status, "Turning VPN off");
write_daemon_control_request(&config_path, DaemonControlRequest::Resume)
.expect("write resume control request");
let resumed = read_daemon_state(&state_path)
.expect("read projected daemon state")
.expect("daemon state should exist");
assert!(resumed.vpn_enabled);
assert_eq!(resumed.vpn_status, "VPN on");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn daemon_log_compaction_leaves_small_log_untouched() {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock is after epoch")
.as_nanos();
let dir = std::env::temp_dir().join(format!("nvpn-daemon-log-small-test-{nonce}"));
fs::create_dir_all(&dir).expect("create temp dir");
let log_path = dir.join("daemon.log");
fs::write(&log_path, "short\n").expect("write daemon log");
assert!(
!compact_log_file_if_needed(&log_path, 64, 16).expect("compact daemon log"),
"small logs should not be compacted"
);
assert_eq!(
fs::read_to_string(&log_path).expect("read daemon log"),
"short\n"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn daemon_log_compaction_keeps_line_aligned_tail() {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock is after epoch")
.as_nanos();
let dir = std::env::temp_dir().join(format!("nvpn-daemon-log-compact-test-{nonce}"));
fs::create_dir_all(&dir).expect("create temp dir");
let log_path = dir.join("daemon.log");
fs::write(&log_path, "old-0\nold-1\nold-2\nkeep-1\nkeep-2\n").expect("write daemon log");
assert!(
compact_log_file_if_needed(&log_path, 20, 14).expect("compact daemon log"),
"oversized logs should be compacted"
);
let compacted = fs::read_to_string(&log_path).expect("read compacted daemon log");
assert!(compacted.starts_with("[nvpn] daemon log compacted at "));
assert!(!compacted.contains("old-0"));
assert!(!compacted.contains("keep-1"));
assert!(compacted.ends_with("keep-2\n"));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn daemon_status_ignores_and_quarantines_corrupt_daemon_state() {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock is after epoch")
.as_nanos();
let dir = std::env::temp_dir().join(format!("nvpn-daemon-status-test-{nonce}"));
fs::create_dir_all(&dir).expect("create temp dir");
let config_path = dir.join("config.toml");
fs::write(&config_path, "").expect("write config placeholder");
let state_path = dir.join("daemon.state.json");
fs::write(&state_path, vec![0; 64]).expect("write corrupt daemon state");
let status = crate::daemon_status(&config_path).expect("daemon status should succeed");
assert!(status.state.is_none());
assert!(!state_path.exists());
let quarantined: Vec<_> = fs::read_dir(&dir)
.expect("list temp dir")
.filter_map(|entry| entry.ok())
.map(|entry| entry.path())
.filter(|path| {
path.file_name()
.and_then(|value| value.to_str())
.is_some_and(|name| name.starts_with("daemon.state.json.corrupt-"))
})
.collect();
assert_eq!(quarantined.len(), 1);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn daemon_status_does_not_repair_network_state_when_daemon_is_stopped() {
let _guard = crate::repair_saved_network_state_call_lock_for_test()
.lock()
.expect("repair call test lock");
crate::reset_repair_saved_network_state_call_count_for_test();
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock is after epoch")
.as_nanos();
let dir = std::env::temp_dir().join(format!("nvpn-daemon-status-pure-test-{nonce}"));
fs::create_dir_all(&dir).expect("create temp dir");
let config_path = dir.join("config.toml");
fs::write(&config_path, "").expect("write config placeholder");
let status = crate::daemon_status(&config_path).expect("daemon status should succeed");
assert!(!status.running);
assert_eq!(crate::repair_saved_network_state_call_count_for_test(), 0);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn persist_daemon_runtime_state_marks_vpn_on_as_active() {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock is after epoch")
.as_nanos();
let dir = std::env::temp_dir().join(format!("nvpn-daemon-state-test-{nonce}"));
fs::create_dir_all(&dir).expect("create temp dir");
let state_path = dir.join("daemon.state.json");
let mut config = AppConfig::generated();
config.networks[0].participants = vec!["11".repeat(32)];
let tunnel_runtime = crate::CliTunnelRuntime::new("utun100");
crate::persist_daemon_runtime_state(
&state_path,
&config,
true,
1,
&tunnel_runtime,
&[],
&std::collections::HashMap::new(),
"VPN on",
&nostr_vpn_core::diagnostics::NetworkSummary::default(),
&nostr_vpn_core::diagnostics::PortMappingStatus::default(),
)
.expect("persist daemon runtime state");
let state = read_daemon_state(&state_path)
.expect("read daemon state")
.expect("daemon state should exist");
assert!(state.vpn_active);
assert_eq!(state.vpn_status, "VPN on");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn fips_runtime_state_is_ready_without_waiting_for_every_peer() {
let mut config = AppConfig::generated();
config.networks[0].participants = vec!["11".repeat(32), "22".repeat(32)];
let tunnel_runtime = crate::CliTunnelRuntime::new("utun100");
let state = crate::build_daemon_runtime_state(
&config,
true,
true,
2,
&tunnel_runtime,
&[],
&std::collections::HashMap::new(),
"VPN on",
&nostr_vpn_core::diagnostics::NetworkSummary::default(),
&nostr_vpn_core::diagnostics::PortMappingStatus::default(),
);
assert_eq!(state.connected_peer_count, 0);
assert!(state.vpn_enabled);
assert!(state.mesh_ready);
assert_eq!(state.vpn_status, "VPN on");
}
#[test]
fn daemon_runtime_state_marks_peers_unreachable_when_vpn_is_off() {
let mut config = AppConfig::generated();
let peer_pubkey = "11".repeat(32);
config.networks[0].participants = vec![peer_pubkey.clone()];
let tunnel_runtime = crate::CliTunnelRuntime::new("utun100");
let peer_status = MeshPeerStatus {
pubkey: peer_pubkey,
connected: true,
endpoint_npub: "npub1endpoint".to_string(),
transport_addr: Some("127.0.0.1:9000".to_string()),
transport_type: Some("loopback".to_string()),
srtt_ms: Some(3),
link_packets_sent: 10,
link_packets_recv: 11,
link_bytes_sent: 120,
link_bytes_recv: 130,
last_seen_at: Some(100),
tx_bytes: 200,
rx_bytes: 300,
error: None,
};
let state = crate::build_daemon_runtime_state(
&config,
false,
false,
1,
&tunnel_runtime,
&[peer_status],
&std::collections::HashMap::new(),
"Paused",
&nostr_vpn_core::diagnostics::NetworkSummary::default(),
&nostr_vpn_core::diagnostics::PortMappingStatus::default(),
);
assert!(!state.vpn_active);
assert!(!state.vpn_enabled);
assert_eq!(state.connected_peer_count, 0);
assert!(!state.mesh_ready);
assert_eq!(state.peers.len(), 1);
assert!(!state.peers[0].reachable);
assert!(state.peers[0].runtime_endpoint.is_none());
}
#[test]
fn macos_route_delete_error_is_absent_matches_missing_route_output() {
assert!(macos_route_delete_error_is_absent(
"command failed: route -n delete -ifscope utun100 default\nstdout: not in table\nstderr:"
));
assert!(macos_route_delete_error_is_absent(
"command failed: route -n delete -ifscope utun100 -net 0.0.0.0/1\nstdout: route: bad interface name\nstderr:"
));
assert!(macos_route_delete_error_is_absent(
"command failed: route -n delete -host 203.0.113.8\nstdout:\nstderr: route: writing to routing socket: No such process"
));
assert!(!macos_route_delete_error_is_absent(
"command failed: route -n delete -host 203.0.113.8\nstdout:\nstderr: permission denied"
));
}
#[test]
fn macos_default_routes_from_netstat_finds_underlay_and_utun_routes() {
let routes = macos_default_routes_from_netstat(
"Routing tables\n\
Internet:\n\
Destination Gateway Flags Netif Expire\n\
default 192.168.64.1 UGScg en0\n\
default link#13 UCSIg utun5\n\
default link#26 UCSIg bridge100 !\n",
);
assert_eq!(
routes,
vec![
crate::MacosRouteSpec {
gateway: Some("192.168.64.1".to_string()),
interface: "en0".to_string(),
},
crate::MacosRouteSpec {
gateway: None,
interface: "utun5".to_string(),
},
crate::MacosRouteSpec {
gateway: None,
interface: "bridge100".to_string(),
},
]
);
assert_eq!(
macos_underlay_default_route_from_routes(&routes),
Some(crate::MacosRouteSpec {
gateway: Some("192.168.64.1".to_string()),
interface: "en0".to_string(),
})
);
}
#[test]
fn macos_split_defaults_are_detected_from_netstat() {
assert!(crate::macos_network::macos_has_tunnel_split_default_routes(
"Routing tables\n\
Internet:\n\
Destination Gateway Flags Netif Expire\n\
0/1 link#13 UCS utun5\n\
128/1 link#13 UCS utun5\n\
"
));
assert!(
!crate::macos_network::macos_has_tunnel_split_default_routes(
"Routing tables\n\
Internet:\n\
Destination Gateway Flags Netif Expire\n\
default 192.168.64.1 UGScg en0\n\
"
)
);
}
#[test]
fn macos_underlay_default_route_detection_requires_real_underlay_route() {
assert!(crate::macos_network::macos_has_underlay_default_route(
"Routing tables\n\
Internet:\n\
Destination Gateway Flags Netif Expire\n\
default 192.168.64.1 UGScg en0\n\
0/1 link#13 UCS utun5\n\
128/1 link#13 UCS utun5\n\
"
));
assert!(!crate::macos_network::macos_has_underlay_default_route(
"Routing tables\n\
Internet:\n\
Destination Gateway Flags Netif Expire\n\
0/1 link#13 UCS utun5\n\
128/1 link#13 UCS utun5\n\
"
));
}
#[test]
fn macos_tunnel_default_route_targets_use_split_defaults() {
assert_eq!(
crate::macos_network::macos_tunnel_default_route_targets(),
&["0.0.0.0/1", "128.0.0.0/1"]
);
}
#[test]
fn macos_gateway_route_args_install_global_host_routes() {
assert_eq!(
crate::macos_network::macos_gateway_route_args_for_test(
"add",
"65.109.48.91/32",
"192.168.64.1",
),
vec![
"-n".to_string(),
"add".to_string(),
"-host".to_string(),
"65.109.48.91".to_string(),
"192.168.64.1".to_string(),
]
);
assert_eq!(
crate::macos_network::macos_gateway_route_args_for_test(
"change",
"0.0.0.0/0",
"192.168.64.1",
),
vec![
"-n".to_string(),
"change".to_string(),
"default".to_string(),
"192.168.64.1".to_string(),
]
);
}
#[test]
fn macos_interface_names_from_ifconfig_list_parses_interfaces() {
assert_eq!(
crate::macos_network::macos_interface_names_from_ifconfig_list(
"lo0 gif0 stf0 anpi0 en0 en1 utun0 utun100\n"
),
vec![
"lo0", "gif0", "stf0", "anpi0", "en0", "en1", "utun0", "utun100"
]
);
}
#[test]
fn macos_ipconfig_router_from_output_parses_ip_and_ip_mult_formats() {
assert_eq!(
crate::macos_network::macos_ipconfig_router_from_output("router (ip): 192.168.64.1\n"),
Some("192.168.64.1".parse().unwrap())
);
assert_eq!(
crate::macos_network::macos_ipconfig_router_from_output(
"router (ip_mult): {192.168.64.1}\n"
),
Some("192.168.64.1".parse().unwrap())
);
}
#[test]
fn macos_ifconfig_has_ipv4_matches_exact_interface_address() {
let output = "utun5: flags=8051<UP,POINTOPOINT,RUNNING,MULTICAST> mtu 1380\n\
\tinet 10.44.10.23 --> 10.44.10.23 netmask 0xffffffff\n\
\tinet6 fe80::1%utun5 prefixlen 64 scopeid 0x8\n";
assert!(macos_ifconfig_has_ipv4(
output,
Ipv4Addr::new(10, 44, 10, 23)
));
assert!(!macos_ifconfig_has_ipv4(
output,
Ipv4Addr::new(10, 44, 10, 24)
));
}
#[test]
fn daemon_runtime_state_tracks_live_endpoint_and_listen_port() {
let mut config = AppConfig::generated();
config.node.endpoint = "198.51.100.10:51820".to_string();
let mut tunnel_runtime = crate::CliTunnelRuntime::new("utun100");
tunnel_runtime.active_listen_port = Some(53083);
let state = crate::build_daemon_runtime_state(
&config,
true,
true,
0,
&tunnel_runtime,
&[],
&std::collections::HashMap::new(),
"Connected",
&nostr_vpn_core::diagnostics::NetworkSummary::default(),
&nostr_vpn_core::diagnostics::PortMappingStatus::default(),
);
let daemon = crate::DaemonStatus {
running: true,
pid: Some(1),
pid_file: std::path::PathBuf::from("/tmp/nvpn.pid"),
log_file: std::path::PathBuf::from("/tmp/nvpn.log"),
state_file: std::path::PathBuf::from("/tmp/nvpn.state.json"),
state: Some(state.clone()),
};
assert_eq!(state.listen_port, 53083);
let expected_endpoint = crate::local_signal_endpoint(&config, 53083);
assert_eq!(state.local_endpoint, expected_endpoint);
assert_eq!(state.advertised_endpoint, expected_endpoint);
assert_eq!(crate::status_endpoint(&config, &daemon), expected_endpoint);
assert_eq!(crate::status_listen_port(&config, &daemon), 53083);
}
#[test]
fn daemon_pid_scan_matches_processes_for_config() {
let config_path = Path::new("/Users/sirius/Library/Application Support/nvpn/config.toml");
let ps = " 42063 /Applications/Nostr VPN.app/Contents/MacOS/nvpn daemon --config /Users/sirius/Library/Application Support/nvpn/config.toml --iface utun100\n\
97597 /Applications/Nostr VPN.app/Contents/MacOS/nvpn daemon --config /Users/sirius/Library/Application Support/nvpn/config.toml --iface utun100\n\
55555 /Applications/Nostr VPN.app/Contents/MacOS/nvpn daemon --config /tmp/other.toml --iface utun100\n";
let pids = daemon_pids_from_ps_output(ps, config_path);
assert_eq!(pids, vec![42063, 97597]);
}
#[test]
fn daemon_pid_scan_ignores_exiting_processes_for_config() {
let config_path = Path::new("/Users/sirius/Library/Application Support/nvpn/config.toml");
let ps = " 42063 UNE /Applications/Nostr VPN.app/Contents/MacOS/nvpn daemon --config /Users/sirius/Library/Application Support/nvpn/config.toml --iface utun100\n\
97597 Ss /Applications/Nostr VPN.app/Contents/MacOS/nvpn daemon --config /Users/sirius/Library/Application Support/nvpn/config.toml --iface utun100\n";
let pids = daemon_pids_from_ps_output(ps, config_path);
assert_eq!(pids, vec![97597]);
}
#[test]
fn daemon_pid_scan_matches_macos_service_helper_path() {
let config_path = Path::new("/Users/sirius/Library/Application Support/nvpn/config.toml");
let ps = " 2853 Ss /Library/PrivilegedHelperTools/to.nostrvpn.nvpn daemon --service --config /Users/sirius/Library/Application Support/nvpn/config.toml --iface utun --mesh-refresh-interval-secs 20\n";
let pids = daemon_pids_from_ps_output(ps, config_path);
assert_eq!(pids, vec![2853]);
}
#[test]
fn daemon_pid_scan_matches_macos_service_helper_with_config_suffix() {
let config_path = Path::new("/tmp/custom/config.toml");
let ps = " 3001 Ss /Library/PrivilegedHelperTools/to.nostrvpn.nvpn.tmp_custom daemon --service --config /tmp/custom/config.toml\n";
let pids = daemon_pids_from_ps_output(ps, config_path);
assert_eq!(pids, vec![3001]);
}
#[test]
fn daemon_pid_scan_ignores_shell_wrappers_that_mention_nvpn_daemon() {
let config_path = Path::new("/root/.config/nvpn/config.toml");
let ps = "2433278 bash -c set -e; nohup /root/nostr-vpn-current/target/debug/nvpn daemon --config /root/.config/nvpn/config.toml --iface utun100 >/root/.config/nvpn/launch.out 2>&1 </dev/null & sleep 5\n\
2433301 /root/nostr-vpn-current/target/debug/nvpn daemon --config /root/.config/nvpn/config.toml --iface utun100 --mesh-refresh-interval-secs 20\n";
let pids = daemon_pids_from_ps_output(ps, config_path);
assert_eq!(pids, vec![2433301]);
}
#[test]
fn windows_daemon_pid_scan_matches_processes_for_config() {
let config_path = Path::new("C:\\Users\\sirius\\AppData\\Roaming\\nvpn\\config.toml");
let cim_json = r#"[{"ProcessId":42063,"CommandLine":"\"C:\\Program Files\\Nostr VPN\\nvpn.exe\" daemon --config \"C:\\Users\\sirius\\AppData\\Roaming\\nvpn\\config.toml\" --iface NostrVPN"},{"ProcessId":97597,"CommandLine":"\"C:\\Program Files\\Nostr VPN\\nvpn.exe\" daemon --config \"C:\\Users\\sirius\\AppData\\Roaming\\nvpn\\config.toml\" --iface NostrVPN"},{"ProcessId":55555,"CommandLine":"\"C:\\Program Files\\Nostr VPN\\nvpn.exe\" daemon --config \"C:\\temp\\other.toml\" --iface NostrVPN"}]"#;
let pids = crate::daemon_pids_from_windows_cim_json(cim_json, config_path);
assert_eq!(pids, vec![42063, 97597]);
}
#[test]
fn windows_daemon_pid_scan_accepts_single_process_object() {
let config_path = Path::new("C:\\Users\\sirius\\AppData\\Roaming\\nvpn\\config.toml");
let cim_json = r#"{"ProcessId":42063,"CommandLine":"\"C:\\Program Files\\Nostr VPN\\nvpn.exe\" daemon --config \"C:\\Users\\sirius\\AppData\\Roaming\\nvpn\\config.toml\" --iface NostrVPN"}"#;
let pids = crate::daemon_pids_from_windows_cim_json(cim_json, config_path);
assert_eq!(pids, vec![42063]);
}
#[test]
fn windows_service_bin_path_runs_hidden_service_daemon_with_same_config() {
let executable = Path::new("C:\\Program Files\\Nostr VPN\\nvpn.exe");
let config_path = Path::new("C:\\Users\\sirius\\AppData\\Roaming\\nvpn\\config.toml");
let command = windows_service_bin_path(executable, config_path, "nvpn", 20);
assert!(command.starts_with("\"C:\\Program Files\\Nostr VPN\\nvpn.exe\""));
assert!(command.contains(" daemon "));
assert!(command.contains(" --service "));
assert!(command.contains(" --config "));
assert!(command.contains("\"C:\\Users\\sirius\\AppData\\Roaming\\nvpn\\config.toml\""));
assert!(command.contains(" --iface \"nvpn\""));
assert!(command.contains(" --mesh-refresh-interval-secs 20"));
}
#[test]
fn windows_tasklist_pid_parser_extracts_pids() {
let output = "\"nvpn.exe\",\"9564\",\"Services\",\"0\",\"172,248 K\"\n\"nvpn.exe\",\"3496\",\"Console\",\"1\",\"19,472 K\"\n";
assert_eq!(crate::tasklist_pids_from_output(output), vec![3496, 9564]);
}
#[test]
fn windows_tasklist_pid_parser_ignores_no_tasks_message() {
let output = "INFO: No tasks are running which match the specified criteria.\r\n";
assert!(crate::tasklist_pids_from_output(output).is_empty());
}
#[test]
fn recent_windows_daemon_pid_candidate_uses_fresh_state_and_single_other_process() {
let state = DaemonRuntimeState {
updated_at: 100,
..Default::default()
};
assert_eq!(
crate::recent_windows_daemon_pid_candidate(Some(&state), 42, &[42, 9564], 103),
Some(9564)
);
assert_eq!(
crate::recent_windows_daemon_pid_candidate(Some(&state), 42, &[42, 9564], 106),
None
);
assert_eq!(
crate::recent_windows_daemon_pid_candidate(Some(&state), 42, &[42, 9564, 97597], 103),
None
);
assert_eq!(
crate::recent_windows_daemon_pid_candidate(None, 42, &[42, 9564], 103),
None
);
}
#[test]
fn visible_daemon_state_for_status_keeps_state_while_running() {
let state = DaemonRuntimeState {
vpn_active: true,
..Default::default()
};
let visible = crate::visible_daemon_state_for_status(true, Some(&state));
assert!(visible.is_some());
assert!(visible.expect("visible state").vpn_active);
}
#[test]
fn visible_daemon_state_for_status_hides_state_when_stopped() {
let state = DaemonRuntimeState {
vpn_active: true,
..Default::default()
};
assert!(crate::visible_daemon_state_for_status(false, Some(&state)).is_none());
}
#[test]
fn daemon_reload_config_uses_reloaded_network_id() {
let mut app = AppConfig::generated();
app.set_active_network_id("mesh-home")
.expect("set initial network id");
app.networks[0].participants = vec!["11".repeat(32)];
let initial_network_id = app.effective_network_id();
app.set_active_network_id("mesh-work")
.expect("set reloaded network id");
app.networks[0].participants = vec!["22".repeat(32)];
let reloaded_network_id = app.effective_network_id();
assert_ne!(initial_network_id, reloaded_network_id);
let reload = build_daemon_reload_config(app, reloaded_network_id.clone());
assert_eq!(reload.network_id, reloaded_network_id);
}
#[test]
fn daemon_pid_scan_excludes_current_pid_when_filtering_duplicates() {
let config_path = Path::new("/Users/sirius/Library/Application Support/nvpn/config.toml");
let ps = " 42063 /Applications/Nostr VPN.app/Contents/MacOS/nvpn daemon --config /Users/sirius/Library/Application Support/nvpn/config.toml --iface utun100\n\
97597 /Applications/Nostr VPN.app/Contents/MacOS/nvpn daemon --config /Users/sirius/Library/Application Support/nvpn/config.toml --iface utun100\n";
let mut pids = daemon_pids_from_ps_output(ps, config_path);
pids.retain(|pid| *pid != 97597);
assert_eq!(pids, vec![42063]);
}
#[test]
fn unix_process_stat_treats_exiting_and_dead_states_as_not_running() {
assert!(crate::unix_process_stat_counts_as_running("Ss"));
assert!(!crate::unix_process_stat_counts_as_running("UNE"));
assert!(!crate::unix_process_stat_counts_as_running("Z"));
}
#[test]
fn default_cli_install_path_uses_nvpn_filename() {
let path = default_cli_install_path();
assert_eq!(
path.file_name().and_then(|name| name.to_str()),
Some(if cfg!(target_os = "windows") {
"nvpn.exe"
} else {
"nvpn"
})
);
}
#[test]
fn default_tunnel_iface_matches_platform() {
let iface = crate::default_tunnel_iface();
assert_eq!(
iface,
if cfg!(target_os = "windows") {
"nvpn"
} else if cfg!(target_os = "macos") {
"utun"
} else {
"utun100"
}
);
}
#[test]
fn install_cli_and_uninstall_cli_roundtrip_for_custom_path() {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock is after epoch")
.as_nanos();
let dir = std::env::temp_dir().join(format!("nvpn-install-test-{nonce}"));
fs::create_dir_all(&dir).expect("create temp dir");
let target = dir.join("nvpn");
install_cli(InstallCliArgs {
path: Some(target.clone()),
force: false,
})
.expect("install custom cli target");
assert!(target.exists(), "installed target should exist");
let duplicate = install_cli(InstallCliArgs {
path: Some(target.clone()),
force: false,
});
assert!(duplicate.is_err(), "install without --force should fail");
install_cli(InstallCliArgs {
path: Some(target.clone()),
force: true,
})
.expect("force reinstall custom cli target");
uninstall_cli(UninstallCliArgs {
path: Some(target.clone()),
})
.expect("uninstall custom cli target");
assert!(!target.exists(), "uninstall should remove target");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn apply_config_file_writes_target_config() {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock is after epoch")
.as_nanos();
let dir = std::env::temp_dir().join(format!("nvpn-apply-config-test-{nonce}"));
fs::create_dir_all(&dir).expect("create temp dir");
let source = dir.join("source.toml");
let target = dir.join("target.toml");
let mut config = AppConfig::generated();
config.node_name = "windows-box".to_string();
config.networks[0].participants = vec!["ab".repeat(32)];
config.save(&source).expect("save source config");
apply_config_file(&source, &target).expect("apply config should succeed");
let loaded = AppConfig::load(&target).expect("load target config");
assert_eq!(loaded.node_name, "windows-box");
assert_eq!(loaded.participant_pubkeys_hex(), vec!["ab".repeat(32)]);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn stage_daemon_config_apply_writes_staged_file() {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock is after epoch")
.as_nanos();
let dir = std::env::temp_dir().join(format!("nvpn-stage-config-test-{nonce}"));
fs::create_dir_all(&dir).expect("create temp dir");
let source = dir.join("source.toml");
let target = dir.join("config.toml");
let mut config = AppConfig::generated();
config.node_name = "staged-node".to_string();
config.save(&source).expect("save source config");
stage_daemon_config_apply(&target, &source).expect("stage config should succeed");
let staged = daemon_staged_config_file_path(&target);
let loaded = AppConfig::load(&staged).expect("load staged config");
assert_eq!(loaded.node_name, "staged-node");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn update_daemon_config_from_staged_request_replaces_target_and_cleans_up() {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock is after epoch")
.as_nanos();
let dir = std::env::temp_dir().join(format!("nvpn-stage-apply-test-{nonce}"));
fs::create_dir_all(&dir).expect("create temp dir");
let source = dir.join("source.toml");
let target = dir.join("config.toml");
let mut source_config = AppConfig::generated();
source_config.node_name = "service-owned".to_string();
source_config.save(&source).expect("save source config");
let mut target_config = AppConfig::generated();
target_config.node_name = "old-name".to_string();
target_config.save(&target).expect("save target config");
stage_daemon_config_apply(&target, &source).expect("stage config should succeed");
update_daemon_config_from_staged_request(&target).expect("apply staged config");
let loaded = AppConfig::load(&target).expect("load target config");
assert_eq!(loaded.node_name, "service-owned");
assert!(
!daemon_staged_config_file_path(&target).exists(),
"staged config should be cleaned up"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn kill_error_fallback_matcher_detects_permission_denied() {
assert!(kill_error_requires_control_fallback(
"kill -TERM 123 failed\nstderr: Operation not permitted"
));
assert!(kill_error_requires_control_fallback(
"kill -TERM 123 failed\nstderr: permission denied"
));
assert!(!kill_error_requires_control_fallback(
"kill -TERM 123 failed\nstderr: no such process"
));
}
#[test]
fn daemon_control_stop_request_roundtrip() {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock is after epoch")
.as_nanos();
let dir = std::env::temp_dir().join(format!("nvpn-control-test-{nonce}"));
fs::create_dir_all(&dir).expect("create temp dir");
let config = dir.join("config.toml");
fs::write(&config, "node_name = \"test\"").expect("write config");
request_daemon_stop(&config).expect("write stop request");
assert!(
take_daemon_control_request(&config) == Some(crate::DaemonControlRequest::Stop),
"daemon should read stop request"
);
request_daemon_reload(&config).expect("write reload request");
assert!(
take_daemon_control_request(&config) == Some(crate::DaemonControlRequest::Reload),
"daemon should read reload request"
);
control_daemon_request_for_test(&config, crate::DaemonControlRequest::Pause);
assert!(
take_daemon_control_request(&config) == Some(crate::DaemonControlRequest::Pause),
"daemon should read pause request"
);
control_daemon_request_for_test(&config, crate::DaemonControlRequest::Resume);
assert!(
take_daemon_control_request(&config) == Some(crate::DaemonControlRequest::Resume),
"daemon should read resume request"
);
let _ = fs::remove_file(daemon_control_file_path(&config));
assert!(
take_daemon_control_request(&config).is_none(),
"without control file there should be no stop request"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn daemon_control_timeout_errors_use_generic_service_wording() {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock is after epoch")
.as_nanos();
let dir = std::env::temp_dir().join(format!("nvpn-control-timeout-test-{nonce}"));
fs::create_dir_all(&dir).expect("create temp dir");
let config = dir.join("config.toml");
fs::write(&config, "node_name = \"test\"").expect("write config");
let ack_error = crate::wait_for_daemon_control_ack(&config, Duration::from_millis(0))
.expect_err("ack wait should time out");
assert!(
ack_error
.to_string()
.contains("background service may be busy or stuck")
);
assert!(!ack_error.to_string().contains("newer nvpn binary"));
let result_error = crate::wait_for_daemon_control_result(
&config,
crate::DaemonControlRequest::Reload,
Duration::from_millis(0),
)
.expect_err("result wait should time out");
assert!(
result_error
.to_string()
.contains("background service may be busy or stuck")
);
assert!(!result_error.to_string().contains("newer nvpn binary"));
let vpn_error = crate::wait_for_daemon_vpn_enabled(&config, true, Duration::from_millis(0))
.expect_err("vpn wait should time out");
assert!(
vpn_error
.to_string()
.contains("background service may be busy or stuck")
);
assert!(
!vpn_error
.to_string()
.contains("older nvpn daemon binary is still running")
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn daemon_control_wait_timeouts_allow_longer_mac_recovery_windows() {
assert_eq!(
crate::daemon_control_ack_timeout(crate::DaemonControlRequest::Reload),
Duration::from_secs(10)
);
assert_eq!(
crate::daemon_control_result_timeout(crate::DaemonControlRequest::Reload),
Duration::from_secs(15)
);
assert_eq!(
crate::daemon_control_vpn_transition_timeout(crate::DaemonControlRequest::Reload),
Duration::ZERO
);
if cfg!(target_os = "macos") {
assert_eq!(
crate::daemon_control_ack_timeout(crate::DaemonControlRequest::Resume),
Duration::from_secs(15)
);
assert_eq!(
crate::daemon_control_result_timeout(crate::DaemonControlRequest::Resume),
Duration::from_secs(30)
);
assert_eq!(
crate::daemon_control_vpn_transition_timeout(crate::DaemonControlRequest::Resume),
Duration::from_secs(30)
);
} else {
assert_eq!(
crate::daemon_control_ack_timeout(crate::DaemonControlRequest::Resume),
Duration::from_secs(10)
);
assert_eq!(
crate::daemon_control_result_timeout(crate::DaemonControlRequest::Resume),
Duration::from_secs(15)
);
assert_eq!(
crate::daemon_control_vpn_transition_timeout(crate::DaemonControlRequest::Resume),
Duration::from_secs(2)
);
}
}