use super::*;
use crate::core::scanner::ActiveSession;
use std::time::Instant;
fn init_test_env() {
use std::sync::Once;
static INIT: Once = Once::new();
INIT.call_once(|| {
let dir = tempfile::Builder::new()
.prefix("vortix_unit_test_")
.tempdir()
.expect("failed to create test temp dir");
let path = dir.path().to_path_buf();
std::mem::forget(dir);
let _ = std::fs::create_dir_all(&path);
crate::config::set_config_dir(path);
});
}
fn test_app() -> App {
init_test_env();
let mut runtime = crate::vpn_runtime::VpnRuntime::new_test();
runtime.config_dir = std::env::temp_dir().join(format!("vortix_test_{}", std::process::id()));
App {
runtime,
engine_handle: None,
registry: crate::vortix_core::engine::TunnelRegistry::new(),
should_quit: false,
logs_scroll: 0,
logs_auto_scroll: true,
logs_max_scroll: 0,
log_level_filter: None,
focused_panel: FocusedPanel::Sidebar,
zoomed_panel: None,
panel_flipped: std::collections::HashSet::new(),
flip_animation: None,
input_mode: InputMode::Normal,
show_config: false,
show_action_menu: false,
show_bulk_menu: false,
action_menu_state: ratatui::widgets::ListState::default(),
config_scroll: 0,
cached_config: None,
search_match_count: 0,
profile_list_state: ratatui::widgets::TableState::default(),
panel_areas: std::collections::HashMap::new(),
toast: None,
terminal_size: (80, 24),
}
}
fn set_connected(app: &mut App, name: &str) {
if !app.runtime.profiles.iter().any(|p| p.name == name) {
add_profiles(app, &[name]);
}
app.runtime.session_start = Some(Instant::now());
let details = DetailedConnectionInfo {
interface: "wg0".to_string(),
pid: Some(12345),
..Default::default()
};
app.mirror_connect_into_registry(name, &details, Instant::now());
}
fn set_disconnecting(app: &mut App, name: &str) {
use crate::vortix_core::profile::ProfileId;
if app.registry.snapshot(&ProfileId::new(name)).is_none() {
set_connected(app, name);
}
app.mirror_disconnecting_into_registry(name);
}
fn fake_session(name: &str) -> ActiveSession {
ActiveSession {
name: name.to_string(),
interface: "wg0".to_string(),
interface_authoritative: true,
endpoint: "1.2.3.4:51820".to_string(),
internal_ip: "10.0.0.2".to_string(),
mtu: "1420".to_string(),
public_key: String::new(),
listen_port: "51820".to_string(),
transfer_rx: "100 KiB".to_string(),
transfer_tx: "50 KiB".to_string(),
latest_handshake: "5 seconds ago".to_string(),
pid: Some(12345),
started_at: None,
}
}
#[test]
fn test_disconnect_result_success_transitions_to_disconnected() {
let mut app = test_app();
set_disconnecting(&mut app, "test-vpn");
app.handle_message(Message::DisconnectResult {
profile: "test-vpn".to_string(),
success: true,
error: None,
});
assert!(
matches!(app.legacy_state(), ConnectionState::Disconnected),
"Expected Disconnected after successful DisconnectResult"
);
assert!(app.runtime.session_start.is_none());
}
#[test]
fn test_disconnect_result_failure_stays_disconnecting() {
let mut app = test_app();
set_disconnecting(&mut app, "test-vpn");
app.handle_message(Message::DisconnectResult {
profile: "test-vpn".to_string(),
success: false,
error: Some("permission denied".to_string()),
});
assert!(
matches!(app.legacy_state(), ConnectionState::Disconnecting { .. }),
"Should remain Disconnecting after failed disconnect (VPN may still be running)"
);
let toast = app.toast.as_ref().expect("toast should be set");
assert_eq!(toast.toast_type, ToastType::Error);
assert!(toast.message.contains("Disconnect failed"));
assert!(toast.message.contains("force-disconnect"));
}
#[test]
fn test_disconnect_result_success_from_non_disconnecting_state() {
let mut app = test_app();
app.handle_message(Message::DisconnectResult {
profile: "test-vpn".to_string(),
success: true,
error: None,
});
assert!(matches!(app.legacy_state(), ConnectionState::Disconnected));
}
#[test]
fn test_scanner_never_overrides_disconnecting_to_connected() {
let mut app = test_app();
set_disconnecting(&mut app, "test-vpn");
let sessions = vec![fake_session("test-vpn")];
app.handle_message(Message::SyncSystemState {
sessions,
default_route_interface: None,
});
assert!(
matches!(app.legacy_state(), ConnectionState::Disconnecting { .. }),
"Scanner must never override Disconnecting to Connected, got {:?}",
app.legacy_state()
);
}
#[test]
fn test_scanner_confirms_disconnect_when_interface_gone() {
let mut app = test_app();
set_disconnecting(&mut app, "test-vpn");
app.handle_message(Message::SyncSystemState {
sessions: vec![],
default_route_interface: None,
});
assert!(
matches!(app.legacy_state(), ConnectionState::Disconnected),
"Scanner should confirm Disconnected when interface is gone"
);
assert!(app.runtime.session_start.is_none());
}
#[test]
fn test_scanner_safety_timeout_after_30s() {
use crate::vortix_core::profile::ProfileId;
let mut app = test_app();
set_connected(&mut app, "test-vpn");
let past = std::time::SystemTime::now() - std::time::Duration::from_secs(31);
app.registry
.set_disconnecting(&ProfileId::new("test-vpn"), past);
let sessions = vec![fake_session("test-vpn")];
app.handle_message(Message::SyncSystemState {
sessions,
default_route_interface: None,
});
assert!(
matches!(app.legacy_state(), ConnectionState::Disconnected),
"Should time out to Disconnected after 30s"
);
let toast = app.toast.as_ref().expect("timeout should show toast");
assert_eq!(toast.toast_type, ToastType::Warning);
assert!(toast.message.contains("timed out"));
}
#[test]
fn test_scanner_disconnecting_does_not_affect_other_profiles() {
let mut app = test_app();
set_disconnecting(&mut app, "vpn-a");
let sessions = vec![fake_session("vpn-b")];
app.handle_message(Message::SyncSystemState {
sessions,
default_route_interface: None,
});
assert!(
matches!(app.legacy_state(), ConnectionState::Disconnected),
"Should detect our profile is gone even if other profiles are active"
);
}
#[test]
fn test_d_while_disconnecting_escalates_to_force() {
let mut app = test_app();
set_disconnecting(&mut app, "test-vpn");
add_profiles(&mut app, &["test-vpn"]);
let before = if let ConnectionState::Disconnecting { started, .. } = &app.legacy_state() {
*started
} else {
panic!("expected Disconnecting");
};
app.handle_message(Message::Disconnect);
assert!(matches!(
app.legacy_state(),
ConnectionState::Disconnecting { .. }
));
if let ConnectionState::Disconnecting { started, .. } = &app.legacy_state() {
assert!(*started >= before);
}
let toast = app.toast.as_ref().expect("force disconnect shows toast");
assert_eq!(toast.toast_type, ToastType::Warning);
assert!(toast.message.contains("Force"));
}
#[test]
fn test_d_while_disconnected_is_noop() {
let mut app = test_app();
app.handle_message(Message::Disconnect);
assert!(matches!(app.legacy_state(), ConnectionState::Disconnected));
}
fn set_connecting(app: &mut App, name: &str) {
if !app.runtime.profiles.iter().any(|p| p.name == name) {
add_profiles(app, &[name]);
}
app.mirror_connecting_into_registry(name);
}
fn add_profiles(app: &mut App, names: &[&str]) {
for name in names {
app.runtime.profiles.push(VpnProfile {
name: (*name).to_string(),
protocol: Protocol::WireGuard,
config_path: std::path::PathBuf::from(format!("/tmp/{name}.conf")),
location: "Test".to_string(),
last_used: None,
});
}
}
#[test]
fn toggle_connected_different_profile_opens_takeover_overlay() {
let mut app = test_app();
add_profiles(&mut app, &["vpn-a", "vpn-b"]);
set_connected(&mut app, "vpn-a");
app.toggle_connection(1);
assert!(
matches!(
app.input_mode,
InputMode::ConfirmDefaultRouteTakeover { ref to_profile_id, .. }
if to_profile_id.as_str() == "vpn-b"
),
"Expected ConfirmDefaultRouteTakeover dialog, got {:?}",
app.input_mode
);
}
#[test]
fn confirm_default_route_takeover_message_runs_multi_connect_path() {
let mut app = test_app();
add_profiles(&mut app, &["vpn-a", "vpn-b"]);
set_connected(&mut app, "vpn-a");
app.handle_message(Message::ConfirmDefaultRouteTakeover { idx: 1 });
assert!(
app.runtime.pending_connect.is_none(),
"multi-connect path must not queue a pending_connect; got {:?}",
app.runtime.pending_connect
);
assert!(
!matches!(app.legacy_state(), ConnectionState::Disconnecting { .. }),
"multi-connect path must not transition to Disconnecting; got {:?}",
app.legacy_state()
);
}
#[test]
fn mirror_connecting_makes_registry_hold_connecting_state() {
use crate::vortix_core::engine::state::Connection;
use crate::vortix_core::profile::ProfileId;
let mut app = test_app();
add_profiles(&mut app, &["vpn-a"]);
set_connecting(&mut app, "vpn-a");
app.mirror_connecting_into_registry("vpn-a");
let snap = app
.registry
.snapshot(&ProfileId::new("vpn-a"))
.expect("registry must hold the Connecting entry");
assert!(
matches!(snap.state, Connection::Connecting { .. }),
"expected Connection::Connecting, got {:?}",
snap.state
);
}
#[test]
fn mirror_disconnecting_transitions_existing_connected_entry() {
use crate::vortix_core::engine::state::Connection;
use crate::vortix_core::profile::ProfileId;
let mut app = test_app();
add_profiles(&mut app, &["vpn-a"]);
set_connected(&mut app, "vpn-a");
assert!(matches!(
app.registry
.snapshot(&ProfileId::new("vpn-a"))
.unwrap()
.state,
Connection::Connected { .. }
));
app.mirror_disconnecting_into_registry("vpn-a");
let snap = app
.registry
.snapshot(&ProfileId::new("vpn-a"))
.expect("registry entry must persist through Disconnecting");
assert!(
matches!(snap.state, Connection::Disconnecting { .. }),
"expected Connection::Disconnecting, got {:?}",
snap.state
);
}
#[test]
fn mirror_disconnecting_no_op_when_registry_has_no_entry() {
let mut app = test_app();
add_profiles(&mut app, &["vpn-a"]);
app.mirror_disconnecting_into_registry("vpn-a");
assert_eq!(
app.registry.tunnel_count(),
0,
"Disconnecting mirror must not insert when nothing existed"
);
}
#[test]
fn mirror_failed_makes_registry_hold_disconnected_with_failure() {
use crate::vortix_core::engine::state::Connection;
use crate::vortix_core::profile::ProfileId;
let mut app = test_app();
add_profiles(&mut app, &["vpn-a"]);
set_connecting(&mut app, "vpn-a");
app.handle_message(Message::ConnectResult {
profile: "vpn-a".to_string(),
success: false,
error: Some("handshake timeout".to_string()),
interface: None,
pid: None,
});
let snap = app
.registry
.snapshot(&ProfileId::new("vpn-a"))
.expect("registry must hold the failed entry");
let Connection::Disconnected { last_failure } = snap.state else {
panic!("expected Disconnected, got {:?}", snap.state);
};
assert!(
last_failure.is_some(),
"failure must be marked so sidebar renders the ✗ badge"
);
}
#[test]
fn takeover_y_key_dispatches_switch_path() {
let mut app = test_app();
add_profiles(&mut app, &["vpn-a", "vpn-b"]);
set_connected(&mut app, "vpn-a");
app.toggle_connection(1);
assert!(
matches!(
app.input_mode,
InputMode::ConfirmDefaultRouteTakeover { .. }
),
"expected takeover overlay open"
);
{
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
}
assert_eq!(
app.runtime.pending_connect,
Some(1),
"vpn-b should be queued for after-disconnect connect"
);
assert!(
matches!(app.legacy_state(), ConnectionState::Disconnecting { .. }),
"expected Disconnecting state, got {:?}",
app.legacy_state()
);
assert!(matches!(app.input_mode, InputMode::Normal));
}
#[test]
fn takeover_b_key_dispatches_multi_connect_path() {
let mut app = test_app();
add_profiles(&mut app, &["vpn-a", "vpn-b"]);
set_connected(&mut app, "vpn-a");
app.runtime.is_root = true;
app.toggle_connection(1);
app.handle_key(key_char('b'));
assert!(
app.runtime.pending_connect.is_none(),
"multi-connect path must not queue a pending_connect"
);
assert!(
!matches!(app.legacy_state(), ConnectionState::Disconnecting { .. }),
"multi-connect path must not transition to Disconnecting; got {:?}",
app.legacy_state()
);
assert!(matches!(app.input_mode, InputMode::Normal));
}
#[test]
fn switch_path_disconnect_completion_removes_old_profile_from_registry() {
use crate::vortix_core::profile::ProfileId;
let mut app = test_app();
add_profiles(&mut app, &["vpn-a", "vpn-b"]);
set_connected(&mut app, "vpn-a");
assert_eq!(app.registry.tunnel_count(), 1, "setup precondition");
app.toggle_connection(1);
{
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
}
assert_eq!(
app.runtime.pending_connect,
Some(1),
"setup precondition: pending switch queued"
);
assert!(
matches!(app.legacy_state(), ConnectionState::Disconnecting { .. }),
"setup precondition"
);
app.handle_message(Message::DisconnectResult {
profile: "vpn-a".to_string(),
success: true,
error: None,
});
assert!(
app.registry.snapshot(&ProfileId::new("vpn-a")).is_none(),
"vpn-a must be removed from registry after switch-path disconnect completes"
);
}
#[test]
fn takeover_capital_b_also_dispatches_multi_connect() {
let mut app = test_app();
add_profiles(&mut app, &["vpn-a", "vpn-b"]);
set_connected(&mut app, "vpn-a");
app.runtime.is_root = true;
app.toggle_connection(1);
app.handle_key(key_char('B'));
assert!(app.runtime.pending_connect.is_none());
assert!(!matches!(
app.legacy_state(),
ConnectionState::Disconnecting { .. }
));
}
#[test]
fn test_toggle_connected_same_profile_disconnects_without_pending() {
let mut app = test_app();
add_profiles(&mut app, &["vpn-a"]);
set_connected(&mut app, "vpn-a");
app.toggle_connection(0);
assert_eq!(
app.runtime.pending_connect, None,
"Same-profile toggle should not set pending"
);
assert!(!matches!(
app.legacy_state(),
ConnectionState::Connected { .. }
));
}
#[test]
fn test_toggle_while_disconnecting_queues_pending() {
let mut app = test_app();
add_profiles(&mut app, &["vpn-a", "vpn-b"]);
set_disconnecting(&mut app, "vpn-a");
app.toggle_connection(1);
assert_eq!(app.runtime.pending_connect, Some(1));
assert!(matches!(
app.legacy_state(),
ConnectionState::Disconnecting { .. }
));
}
#[test]
fn test_toggle_while_connecting_is_rejected() {
let mut app = test_app();
add_profiles(&mut app, &["vpn-a", "vpn-b"]);
set_connecting(&mut app, "vpn-a");
app.toggle_connection(1);
assert!(matches!(
app.legacy_state(),
ConnectionState::Connecting { .. }
));
assert_eq!(app.runtime.pending_connect, None);
}
#[test]
fn test_pending_connect_drained_on_disconnect_success() {
let mut app = test_app();
add_profiles(&mut app, &["vpn-a", "vpn-b"]);
set_disconnecting(&mut app, "vpn-a");
app.runtime.pending_connect = Some(1);
app.runtime.is_root = true;
app.handle_message(Message::DisconnectResult {
profile: "vpn-a".to_string(),
success: true,
error: None,
});
assert_eq!(app.runtime.pending_connect, None);
assert!(
matches!(app.legacy_state(), ConnectionState::Connecting { ref profile, .. } if profile == "vpn-b"),
"Expected Connecting to vpn-b, got {:?}",
app.legacy_state()
);
}
#[test]
fn test_pending_connect_drained_on_scanner_interface_gone() {
let mut app = test_app();
add_profiles(&mut app, &["vpn-a", "vpn-b"]);
set_disconnecting(&mut app, "vpn-a");
app.runtime.pending_connect = Some(1);
app.runtime.is_root = true;
app.handle_message(Message::SyncSystemState {
sessions: vec![],
default_route_interface: None,
});
assert_eq!(app.runtime.pending_connect, None);
assert!(
matches!(app.legacy_state(), ConnectionState::Connecting { ref profile, .. } if profile == "vpn-b"),
"Expected auto-connect to vpn-b after scanner confirms disconnect"
);
}
#[test]
fn test_pending_preserved_on_disconnect_failure() {
let mut app = test_app();
add_profiles(&mut app, &["vpn-a", "vpn-b"]);
set_disconnecting(&mut app, "vpn-a");
app.runtime.pending_connect = Some(1);
app.handle_message(Message::DisconnectResult {
profile: "vpn-a".to_string(),
success: false,
error: Some("permission denied".to_string()),
});
assert_eq!(app.runtime.pending_connect, Some(1));
assert!(
matches!(app.legacy_state(), ConnectionState::Disconnecting { .. }),
"Should remain Disconnecting after failed disconnect"
);
}
#[test]
fn test_pending_cleared_on_30s_timeout() {
use crate::vortix_core::profile::ProfileId;
let mut app = test_app();
add_profiles(&mut app, &["vpn-a", "vpn-b"]);
set_connected(&mut app, "vpn-a");
let past = std::time::SystemTime::now() - std::time::Duration::from_secs(31);
app.registry
.set_disconnecting(&ProfileId::new("vpn-a"), past);
app.runtime.pending_connect = Some(1);
let sessions = vec![fake_session("vpn-a")];
app.handle_message(Message::SyncSystemState {
sessions,
default_route_interface: None,
});
assert_eq!(app.runtime.pending_connect, None);
assert!(matches!(app.legacy_state(), ConnectionState::Disconnected));
}
#[test]
fn test_connect_result_success_transitions_to_connected() {
let mut app = test_app();
add_profiles(&mut app, &["test-vpn"]);
set_connecting(&mut app, "test-vpn");
app.handle_message(Message::ConnectResult {
profile: "test-vpn".to_string(),
success: true,
error: None,
interface: None,
pid: None,
});
assert!(
matches!(app.legacy_state(), ConnectionState::Connected { ref profile, .. } if profile == "test-vpn"),
"Successful ConnectResult should transition to Connected"
);
}
#[test]
fn test_connect_result_failure_transitions_to_disconnected() {
let mut app = test_app();
app.runtime.config.connect_max_retries = 0;
set_connecting(&mut app, "test-vpn");
app.handle_message(Message::ConnectResult {
profile: "test-vpn".to_string(),
success: false,
error: Some("wg-quick: already exists".to_string()),
interface: None,
pid: None,
});
assert!(
matches!(app.legacy_state(), ConnectionState::Disconnected),
"Failed ConnectResult should transition to Disconnected"
);
let toast = app.toast.as_ref().expect("should show error toast");
assert_eq!(toast.toast_type, ToastType::Error);
assert!(toast.message.contains("Failed to connect"));
}
#[test]
fn test_connect_result_failure_clears_pending() {
let mut app = test_app();
set_connecting(&mut app, "test-vpn");
app.runtime.pending_connect = Some(1);
app.handle_message(Message::ConnectResult {
profile: "test-vpn".to_string(),
success: false,
error: Some("error".to_string()),
interface: None,
pid: None,
});
assert_eq!(
app.runtime.pending_connect, None,
"Connect failure should clear pending"
);
}
#[test]
fn test_disconnect_from_connecting_state() {
let mut app = test_app();
add_profiles(&mut app, &["test-vpn"]);
set_connecting(&mut app, "test-vpn");
app.disconnect();
assert!(
matches!(app.legacy_state(), ConnectionState::Disconnecting { .. }),
"disconnect() should work from Connecting state, got {:?}",
app.legacy_state()
);
}
#[test]
fn test_d_key_from_connecting_state_disconnects() {
let mut app = test_app();
add_profiles(&mut app, &["test-vpn"]);
set_connecting(&mut app, "test-vpn");
app.handle_message(Message::Disconnect);
assert!(
matches!(app.legacy_state(), ConnectionState::Disconnecting { .. }),
"d key should cancel Connecting state"
);
}
#[test]
fn test_reconnect_sets_pending_not_immediate_connect() {
let mut app = test_app();
add_profiles(&mut app, &["test-vpn"]);
set_connected(&mut app, "test-vpn");
app.reconnect();
assert_eq!(app.runtime.pending_connect, Some(0));
assert!(
matches!(app.legacy_state(), ConnectionState::Disconnecting { .. }),
"Reconnect should disconnect first"
);
}
#[test]
fn test_reconnect_auto_connects_after_disconnect_completes() {
let mut app = test_app();
add_profiles(&mut app, &["test-vpn"]);
set_disconnecting(&mut app, "test-vpn");
app.runtime.pending_connect = Some(0);
app.runtime.is_root = true;
app.handle_message(Message::DisconnectResult {
profile: "test-vpn".to_string(),
success: true,
error: None,
});
assert_eq!(app.runtime.pending_connect, None);
assert!(
matches!(app.legacy_state(), ConnectionState::Connecting { ref profile, .. } if profile == "test-vpn"),
"Reconnect should auto-connect after disconnect"
);
}
#[test]
fn test_quick_connect_while_connected_shows_confirm() {
let mut app = test_app();
add_profiles(&mut app, &["vpn-a", "vpn-b", "vpn-c"]);
set_connected(&mut app, "vpn-a");
app.handle_message(Message::QuickConnect(1));
assert!(
matches!(
app.input_mode,
InputMode::ConfirmDefaultRouteTakeover { ref to_profile_id, .. }
if to_profile_id.as_str() == "vpn-b"
),
"Expected ConfirmDefaultRouteTakeover dialog for QuickConnect, got {:?}",
app.input_mode,
);
}
#[test]
fn test_quick_connect_while_disconnecting_updates_pending() {
let mut app = test_app();
add_profiles(&mut app, &["vpn-a", "vpn-b", "vpn-c"]);
set_disconnecting(&mut app, "vpn-a");
app.runtime.pending_connect = Some(1);
app.handle_message(Message::QuickConnect(2));
assert_eq!(
app.runtime.pending_connect,
Some(2),
"Should update pending to new choice"
);
}
#[test]
fn test_quick_connect_from_disconnected() {
let mut app = test_app();
add_profiles(&mut app, &["vpn-a"]);
app.runtime.is_root = true;
app.handle_message(Message::QuickConnect(0));
assert!(
matches!(app.legacy_state(), ConnectionState::Connecting { .. }),
"QuickConnect from Disconnected should go to Connecting"
);
assert_eq!(app.runtime.pending_connect, None);
}
fn add_openvpn_profiles_with_auth(app: &mut App, names: &[&str], dir: &std::path::Path) {
let _ = std::fs::create_dir_all(dir);
for name in names {
let config_path = dir.join(format!("{name}.ovpn"));
std::fs::write(
&config_path,
"client\nremote example.com 1194\nauth-user-pass\ndev tun\nproto udp\n",
)
.unwrap();
app.runtime.profiles.push(VpnProfile {
name: (*name).to_string(),
protocol: Protocol::OpenVPN,
config_path,
location: "Test".to_string(),
last_used: None,
});
}
}
fn add_openvpn_profiles_with_static_challenge(
app: &mut App,
names: &[&str],
dir: &std::path::Path,
) {
let _ = std::fs::create_dir_all(dir);
for name in names {
let config_path = dir.join(format!("{name}.ovpn"));
std::fs::write(
&config_path,
"client\nremote example.com 1194\nauth-user-pass\nstatic-challenge \"Enter TOTP code\" 1\ndev tun\nproto udp\n",
)
.unwrap();
app.runtime.profiles.push(VpnProfile {
name: (*name).to_string(),
protocol: Protocol::OpenVPN,
config_path,
location: "Test".to_string(),
last_used: None,
});
}
}
fn add_openvpn_profiles_no_auth(app: &mut App, names: &[&str], dir: &std::path::Path) {
let _ = std::fs::create_dir_all(dir);
for name in names {
let config_path = dir.join(format!("{name}.ovpn"));
std::fs::write(
&config_path,
"client\nremote example.com 1194\ndev tun\nproto udp\n<ca>\n</ca>\n",
)
.unwrap();
app.runtime.profiles.push(VpnProfile {
name: (*name).to_string(),
protocol: Protocol::OpenVPN,
config_path,
location: "Test".to_string(),
last_used: None,
});
}
}
#[test]
fn test_auth_prompt_shown_for_openvpn_with_auth_user_pass() {
let mut app = test_app();
let tmp = tempfile::Builder::new()
.prefix("vortix_auth_")
.tempdir()
.unwrap();
add_openvpn_profiles_with_auth(&mut app, &["auth-vpn"], tmp.path());
app.runtime.is_root = true;
crate::utils::delete_openvpn_auth_file("auth-vpn");
app.connect_profile(0);
assert!(
matches!(app.input_mode, InputMode::AuthPrompt { .. }),
"OpenVPN with auth-user-pass and no saved creds should show AuthPrompt"
);
assert!(
matches!(app.legacy_state(), ConnectionState::Disconnected),
"Should not start connecting before credentials are provided"
);
}
#[test]
fn test_auth_prompt_skipped_when_creds_saved() {
let mut app = test_app();
let tmp = tempfile::Builder::new()
.prefix("vortix_auth_")
.tempdir()
.unwrap();
add_openvpn_profiles_with_auth(&mut app, &["saved-vpn"], tmp.path());
app.runtime.is_root = true;
let _ = crate::utils::write_openvpn_auth_file("saved-vpn", "user", "pass");
app.connect_profile(0);
assert!(
!matches!(app.input_mode, InputMode::AuthPrompt { .. }),
"Should not show AuthPrompt when creds are already saved"
);
assert!(
matches!(app.legacy_state(), ConnectionState::Connecting { .. }),
"Should proceed to Connecting with saved credentials"
);
crate::utils::delete_openvpn_auth_file("saved-vpn");
}
#[test]
fn test_auth_prompt_fires_for_static_challenge_even_with_saved_creds() {
let mut app = test_app();
let tmp = tempfile::Builder::new()
.prefix("vortix_auth_")
.tempdir()
.unwrap();
add_openvpn_profiles_with_static_challenge(&mut app, &["mfa-saved"], tmp.path());
app.runtime.is_root = true;
let _ = crate::utils::write_openvpn_auth_file("mfa-saved", "user", "pass");
app.connect_profile(0);
if let InputMode::AuthPrompt {
username,
password,
focused_field,
static_challenge_prompt,
..
} = &app.input_mode
{
assert_eq!(username, "user", "username should be pre-filled");
assert_eq!(password, "pass", "password should be pre-filled");
assert_eq!(
focused_field,
&AuthField::Otp,
"focus should jump to the OTP field when creds are pre-filled"
);
assert_eq!(
static_challenge_prompt.as_deref(),
Some("Enter TOTP code"),
"the directive's prompt text should reach the overlay"
);
} else {
panic!(
"Expected AuthPrompt overlay for static-challenge profile with saved creds; got {:?}",
app.input_mode
);
}
crate::utils::delete_openvpn_auth_file("mfa-saved");
}
#[test]
fn test_auth_prompt_fires_for_static_challenge_without_saved_creds() {
let mut app = test_app();
let tmp = tempfile::Builder::new()
.prefix("vortix_auth_")
.tempdir()
.unwrap();
add_openvpn_profiles_with_static_challenge(&mut app, &["mfa-fresh"], tmp.path());
app.runtime.is_root = true;
crate::utils::delete_openvpn_auth_file("mfa-fresh");
app.connect_profile(0);
if let InputMode::AuthPrompt {
username,
password,
focused_field,
static_challenge_prompt,
..
} = &app.input_mode
{
assert!(username.is_empty());
assert!(password.is_empty());
assert_eq!(focused_field, &AuthField::Username);
assert_eq!(static_challenge_prompt.as_deref(), Some("Enter TOTP code"));
} else {
panic!(
"Expected AuthPrompt overlay for static-challenge profile without saved creds; got {:?}",
app.input_mode
);
}
}
#[test]
fn test_auth_prompt_skipped_for_wireguard() {
let mut app = test_app();
add_profiles(&mut app, &["wg-vpn"]);
app.runtime.is_root = true;
app.connect_profile(0);
assert!(
!matches!(app.input_mode, InputMode::AuthPrompt { .. }),
"WireGuard profiles should never show AuthPrompt"
);
}
#[test]
fn test_auth_prompt_skipped_for_openvpn_without_auth_directive() {
let mut app = test_app();
let tmp = tempfile::Builder::new()
.prefix("vortix_noauth_")
.tempdir()
.unwrap();
add_openvpn_profiles_no_auth(&mut app, &["noauth-vpn"], tmp.path());
app.runtime.is_root = true;
app.connect_profile(0);
assert!(
!matches!(app.input_mode, InputMode::AuthPrompt { .. }),
"OpenVPN without auth-user-pass should not show AuthPrompt"
);
assert!(
matches!(app.legacy_state(), ConnectionState::Connecting { .. }),
"Should proceed to Connecting directly"
);
}
#[test]
fn test_auth_submit_triggers_connect() {
let mut app = test_app();
let tmp = tempfile::Builder::new()
.prefix("vortix_auth_")
.tempdir()
.unwrap();
add_openvpn_profiles_with_auth(&mut app, &["submit-vpn"], tmp.path());
app.runtime.is_root = true;
crate::utils::delete_openvpn_auth_file("submit-vpn");
app.handle_message(Message::AuthSubmit {
idx: 0,
username: "testuser".to_string(),
password: "testpass".to_string(),
otp: None,
save: true,
connect_after: true,
});
assert_eq!(app.input_mode, InputMode::Normal);
assert!(
matches!(app.legacy_state(), ConnectionState::Connecting { .. }),
"AuthSubmit should trigger connect_profile"
);
let creds = crate::utils::read_openvpn_saved_auth("submit-vpn");
assert!(creds.is_some());
let (user, pass) = creds.unwrap();
assert_eq!(user, "testuser");
assert_eq!(pass, "testpass");
crate::utils::delete_openvpn_auth_file("submit-vpn");
}
#[test]
fn test_auth_submit_with_otp_and_save_restores_plain_after_connect() {
let mut app = test_app();
let tmp = tempfile::Builder::new()
.prefix("vortix_auth_")
.tempdir()
.unwrap();
add_openvpn_profiles_with_auth(&mut app, &["mfa-save-vpn"], tmp.path());
app.runtime.is_root = true;
crate::utils::delete_openvpn_auth_file("mfa-save-vpn");
app.handle_message(Message::AuthSubmit {
idx: 0,
username: "u".to_string(),
password: "p".to_string(),
otp: Some("123456".to_string()),
save: true,
connect_after: true,
});
let creds = crate::utils::read_openvpn_saved_auth("mfa-save-vpn");
assert!(creds.is_some(), "saved file must exist after save+connect");
let (_, line2) = creds.unwrap();
assert!(
!line2.starts_with("SCRV1:"),
"auth file must be restored to plain after connect; got line 2 = {line2:?}"
);
assert_eq!(line2, "p", "expected plain password, got {line2:?}");
crate::utils::delete_openvpn_auth_file("mfa-save-vpn");
}
#[test]
fn test_auth_submit_does_not_reopen_overlay_for_static_challenge_profile() {
let mut app = test_app();
let tmp = tempfile::Builder::new()
.prefix("vortix_auth_")
.tempdir()
.unwrap();
add_openvpn_profiles_with_static_challenge(&mut app, &["mfa-resubmit"], tmp.path());
app.runtime.is_root = true;
crate::utils::delete_openvpn_auth_file("mfa-resubmit");
app.handle_message(Message::AuthSubmit {
idx: 0,
username: "u".to_string(),
password: "p".to_string(),
otp: Some("123456".to_string()),
save: true,
connect_after: true,
});
assert!(
!matches!(app.input_mode, InputMode::AuthPrompt { .. }),
"AuthSubmit must NOT re-open the AuthPrompt overlay for a static-challenge profile; got {:?}",
app.input_mode
);
crate::utils::delete_openvpn_auth_file("mfa-resubmit");
}
#[test]
fn test_auth_submit_with_otp_no_save_deletes_file() {
let mut app = test_app();
let tmp = tempfile::Builder::new()
.prefix("vortix_auth_")
.tempdir()
.unwrap();
add_openvpn_profiles_with_auth(&mut app, &["mfa-no-save-vpn"], tmp.path());
app.runtime.is_root = true;
crate::utils::delete_openvpn_auth_file("mfa-no-save-vpn");
app.handle_message(Message::AuthSubmit {
idx: 0,
username: "u".to_string(),
password: "p".to_string(),
otp: Some("123456".to_string()),
save: false,
connect_after: true,
});
assert!(
crate::utils::read_openvpn_saved_auth("mfa-no-save-vpn").is_none(),
"auth file must be deleted after one-time MFA connect"
);
}
#[test]
fn test_auth_cancel_returns_to_normal() {
let mut app = test_app();
let tmp = tempfile::Builder::new()
.prefix("vortix_auth_")
.tempdir()
.unwrap();
add_openvpn_profiles_with_auth(&mut app, &["cancel-vpn"], tmp.path());
app.runtime.is_root = true;
crate::utils::delete_openvpn_auth_file("cancel-vpn");
app.connect_profile(0);
assert!(matches!(app.input_mode, InputMode::AuthPrompt { .. }));
app.handle_message(Message::CloseOverlay);
assert_eq!(app.input_mode, InputMode::Normal);
assert!(
matches!(app.legacy_state(), ConnectionState::Disconnected),
"Cancelling auth should keep Disconnected state"
);
}
#[test]
fn test_auth_field_otp_appears_in_tab_cycle_for_static_challenge_profile() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut app = test_app();
app.input_mode = InputMode::AuthPrompt {
profile_idx: 0,
profile_name: "mfa".to_string(),
username: String::new(),
username_cursor: 0,
password: String::new(),
password_cursor: 0,
otp: String::new(),
otp_cursor: 0,
focused_field: AuthField::Username,
save_credentials: true,
connect_after: true,
static_challenge_prompt: Some("Enter code".to_string()),
};
let expected = [
AuthField::Password,
AuthField::Otp,
AuthField::SaveCheckbox,
AuthField::Username,
];
for expected_field in &expected {
app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
if let InputMode::AuthPrompt { focused_field, .. } = &app.input_mode {
assert_eq!(focused_field, expected_field, "tab cycle drifted");
} else {
panic!("Expected AuthPrompt");
}
}
}
#[test]
fn test_auth_field_switching() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut app = test_app();
app.input_mode = InputMode::AuthPrompt {
profile_idx: 0,
profile_name: "test".to_string(),
username: String::new(),
username_cursor: 0,
password: String::new(),
password_cursor: 0,
otp: String::new(),
otp_cursor: 0,
focused_field: AuthField::Username,
save_credentials: true,
connect_after: true,
static_challenge_prompt: None,
};
app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
if let InputMode::AuthPrompt { focused_field, .. } = &app.input_mode {
assert_eq!(*focused_field, AuthField::Password);
} else {
panic!("Expected AuthPrompt");
}
app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
if let InputMode::AuthPrompt { focused_field, .. } = &app.input_mode {
assert_eq!(*focused_field, AuthField::SaveCheckbox);
} else {
panic!("Expected AuthPrompt");
}
app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
if let InputMode::AuthPrompt { focused_field, .. } = &app.input_mode {
assert_eq!(*focused_field, AuthField::Username);
} else {
panic!("Expected AuthPrompt");
}
}
#[test]
fn test_auth_delete_profile_cleans_auth_file() {
let mut app = test_app();
let tmp = tempfile::Builder::new()
.prefix("vortix_auth_")
.tempdir()
.unwrap();
add_openvpn_profiles_with_auth(&mut app, &["del-vpn"], tmp.path());
app.profile_list_state.select(Some(0));
let auth_path = crate::utils::write_openvpn_auth_file("del-vpn", "user", "pass").unwrap();
assert!(auth_path.exists());
app.confirm_delete(0);
assert!(
!auth_path.exists(),
"Auth file should be deleted when profile is deleted"
);
}
#[test]
fn test_last_security_check_updated_on_ip_telemetry() {
use crate::core::telemetry::TelemetryUpdate;
let mut app = test_app();
assert!(app.runtime.last_security_check.is_none());
app.handle_message(Message::Telemetry(TelemetryUpdate::PublicIp(
"1.2.3.4".to_string(),
)));
assert!(app.runtime.last_security_check.is_some());
}
#[test]
fn test_last_security_check_updated_on_dns_telemetry() {
use crate::core::telemetry::TelemetryUpdate;
let mut app = test_app();
assert!(app.runtime.last_security_check.is_none());
app.handle_message(Message::Telemetry(TelemetryUpdate::Dns(
"1.1.1.1".to_string(),
)));
assert!(app.runtime.last_security_check.is_some());
}
#[test]
fn test_last_security_check_updated_on_ipv6_telemetry() {
use crate::core::telemetry::TelemetryUpdate;
let mut app = test_app();
assert!(app.runtime.last_security_check.is_none());
app.handle_message(Message::Telemetry(TelemetryUpdate::PublicIpv6(None)));
assert!(app.runtime.last_security_check.is_some());
}
#[test]
fn test_publicipv6_caches_real_ipv6_when_safe_to_cache() {
use crate::core::telemetry::TelemetryUpdate;
let mut app = test_app();
app.runtime.scanner_first_tick_done = true;
app.runtime.last_kernel_session_count = 0;
app.handle_message(Message::Telemetry(TelemetryUpdate::PublicIpv6(Some(
"2401:4900::1".to_string(),
))));
assert_eq!(
app.runtime.real_ipv6.as_deref(),
Some("2401:4900::1"),
"real_ipv6 should be cached when fully disconnected"
);
assert_eq!(
app.runtime.public_ipv6.as_deref(),
Some("2401:4900::1"),
"public_ipv6 should always update"
);
}
#[test]
fn test_publicipv6_clears_when_probe_returns_none() {
use crate::core::telemetry::TelemetryUpdate;
let mut app = test_app();
app.runtime.public_ipv6 = Some("2401:4900::1".to_string());
app.handle_message(Message::Telemetry(TelemetryUpdate::PublicIpv6(None)));
assert!(
app.runtime.public_ipv6.is_none(),
"public_ipv6 should reset when probe fails"
);
}
#[test]
fn test_reconnect_from_disconnected_with_last_profile() {
let mut app = test_app();
add_profiles(&mut app, &["my-vpn"]);
app.runtime.last_connected_profile = Some("my-vpn".to_string());
app.runtime.is_root = true;
app.reconnect();
assert!(
matches!(app.legacy_state(), ConnectionState::Connecting { ref profile, .. } if profile == "my-vpn"),
"Should initiate connection to last used profile"
);
}
#[test]
fn test_reconnect_from_disconnected_without_last_profile_is_noop() {
let mut app = test_app();
add_profiles(&mut app, &["my-vpn"]);
assert!(app.runtime.last_connected_profile.is_none());
app.reconnect();
assert!(
matches!(app.legacy_state(), ConnectionState::Disconnected),
"Should stay disconnected when no last_connected_profile"
);
}
#[test]
fn test_connection_timeout_shows_error_toast() {
let mut app = test_app();
add_profiles(&mut app, &["timeout-vpn"]);
set_connecting(&mut app, "timeout-vpn");
app.handle_message(Message::ConnectionTimeout("timeout-vpn".to_string()));
assert!(app.toast.is_some(), "Should show a toast");
assert_eq!(
app.toast.as_ref().unwrap().toast_type,
crate::state::ToastType::Error,
"Timeout toast should be Error, not Warning"
);
}
#[test]
fn test_last_connected_profile_set_on_connect_success() {
let mut app = test_app();
add_profiles(&mut app, &["success-vpn"]);
set_connecting(&mut app, "success-vpn");
app.handle_message(Message::ConnectResult {
profile: "success-vpn".to_string(),
success: true,
error: None,
interface: None,
pid: None,
});
assert_eq!(
app.runtime.last_connected_profile,
Some("success-vpn".to_string()),
"Should track last connected profile"
);
}
#[test]
fn test_quick_connect_moves_selection_cursor() {
let mut app = test_app();
add_profiles(&mut app, &["alpha", "beta", "gamma"]);
app.profile_list_state.select(Some(0));
app.handle_message(Message::QuickConnect(2));
assert_eq!(
app.profile_list_state.selected(),
Some(2),
"Quick-connect should move selection to the connected profile"
);
}
#[test]
fn test_quick_connect_out_of_range_does_not_change_selection() {
let mut app = test_app();
add_profiles(&mut app, &["alpha"]);
app.profile_list_state.select(Some(0));
app.handle_message(Message::QuickConnect(5));
assert_eq!(
app.profile_list_state.selected(),
Some(0),
"Out-of-range quick-connect should not change selection"
);
}
#[test]
fn test_help_mode_opens_and_closes() {
let mut app = test_app();
assert!(matches!(app.input_mode, InputMode::Normal));
app.input_mode = InputMode::Help {
scroll: 0,
tab: crate::state::HelpTab::Keys,
};
assert!(matches!(app.input_mode, InputMode::Help { .. }));
app.handle_message(Message::CloseOverlay);
assert!(matches!(app.input_mode, InputMode::Normal));
}
#[test]
fn test_search_mode_opens() {
let mut app = test_app();
app.input_mode = InputMode::Search {
query: String::new(),
cursor: 0,
};
assert!(matches!(app.input_mode, InputMode::Search { .. }));
}
#[test]
fn test_search_filter_selects_matching_profile() {
let mut app = test_app();
add_profiles(&mut app, &["amsterdam", "berlin", "chicago"]);
app.profile_list_state.select(Some(0));
app.apply_search_filter("ber");
assert_eq!(
app.profile_list_state.selected(),
Some(1),
"Search for 'ber' should select 'berlin'"
);
}
#[test]
fn test_search_filter_empty_resets_to_first() {
let mut app = test_app();
add_profiles(&mut app, &["amsterdam", "berlin"]);
app.profile_list_state.select(Some(1));
app.apply_search_filter("");
assert_eq!(
app.profile_list_state.selected(),
Some(0),
"Empty query should reset to first profile"
);
}
#[test]
fn test_search_filter_no_match_keeps_selection() {
let mut app = test_app();
add_profiles(&mut app, &["amsterdam", "berlin"]);
app.profile_list_state.select(Some(0));
app.apply_search_filter("zzzzz");
assert_eq!(
app.profile_list_state.selected(),
Some(0),
"No match should not change selection"
);
}
#[test]
fn test_open_config_caches_content_and_close_clears() {
let mut app = test_app();
let tmp = tempfile::Builder::new().suffix(".conf").tempfile().unwrap();
std::fs::write(tmp.path(), "[Interface]\nAddress = 10.0.0.1/24").unwrap();
app.runtime.profiles.push(VpnProfile {
name: "test-vpn".to_string(),
protocol: Protocol::WireGuard,
config_path: tmp.path().to_path_buf(),
location: "Test".to_string(),
last_used: None,
});
app.profile_list_state.select(Some(0));
app.handle_message(Message::OpenConfig);
assert!(app.show_config, "Config viewer should be open");
assert!(
app.cached_config.is_some(),
"Config content should be cached"
);
assert!(app
.cached_config
.as_ref()
.unwrap()
.content
.contains("[Interface]"));
app.handle_message(Message::CloseOverlay);
assert!(!app.show_config, "Config viewer should be closed");
assert!(
app.cached_config.is_none(),
"Cached content should be cleared on close"
);
}
#[test]
fn test_close_overlay_preserves_zoom() {
let mut app = test_app();
app.zoomed_panel = Some(FocusedPanel::Logs);
app.show_action_menu = true;
app.handle_message(Message::CloseOverlay);
assert!(!app.show_action_menu);
assert_eq!(
app.zoomed_panel,
Some(FocusedPanel::Logs),
"Zoom should be preserved when closing overlay"
);
}
#[test]
fn test_search_match_count_updated() {
let mut app = test_app();
add_profiles(&mut app, &["amsterdam", "ankara", "berlin"]);
app.profile_list_state.select(Some(0));
app.apply_search_filter("an");
assert_eq!(app.search_match_count, 1, "Should match ankara");
app.apply_search_filter("a");
assert_eq!(
app.search_match_count, 2,
"Should match amsterdam and ankara"
);
app.apply_search_filter("");
assert_eq!(app.search_match_count, 3, "Empty query should match all");
}
#[test]
fn test_confirm_switch_when_already_disconnected_connects_directly() {
let mut app = test_app();
add_profiles(&mut app, &["vpn-a", "vpn-b"]);
app.profile_list_state.select(Some(0));
app.runtime.is_root = true;
assert!(matches!(app.legacy_state(), ConnectionState::Disconnected));
app.handle_message(Message::ConfirmDefaultRouteTakeover { idx: 1 });
assert!(
app.runtime.pending_connect.is_none(),
"Should not set pending_connect when already disconnected"
);
assert!(
matches!(app.legacy_state(), ConnectionState::Connecting { ref profile, .. } if profile == "vpn-b"),
"Should connect directly when already disconnected, got {:?}",
app.legacy_state()
);
}
#[test]
fn test_cycle_sort_order() {
use crate::state::ProfileSortOrder;
let mut app = test_app();
add_profiles(&mut app, &["charlie", "alpha", "bravo"]);
app.profile_list_state.select(Some(0));
assert_eq!(app.runtime.sort_order, ProfileSortOrder::NameAsc);
app.handle_message(Message::CycleSortOrder);
assert_eq!(app.runtime.sort_order, ProfileSortOrder::NameDesc);
assert_eq!(app.runtime.profiles[0].name, "charlie");
app.handle_message(Message::CycleSortOrder);
assert_eq!(app.runtime.sort_order, ProfileSortOrder::LastUsed);
app.handle_message(Message::CycleSortOrder);
assert_eq!(app.runtime.sort_order, ProfileSortOrder::Protocol);
app.handle_message(Message::CycleSortOrder);
assert_eq!(app.runtime.sort_order, ProfileSortOrder::NameAsc);
assert_eq!(app.runtime.profiles[0].name, "alpha");
}
#[test]
fn test_sort_preserves_selection() {
let mut app = test_app();
add_profiles(&mut app, &["charlie", "alpha", "bravo"]);
app.profile_list_state.select(Some(1));
let selected_name = app.runtime.profiles[1].name.clone();
assert_eq!(selected_name, "alpha");
app.handle_message(Message::CycleSortOrder);
let new_idx = app.profile_list_state.selected().unwrap();
assert_eq!(
app.runtime.profiles[new_idx].name, "alpha",
"Selection should follow the profile after re-sort"
);
}
#[test]
fn test_text_field_multibyte_insert_and_backspace() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut text = String::new();
let mut cursor: usize = 0;
for c in ['c', 'a', 'f', 'é'] {
App::handle_text_field_input(
KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE),
&mut text,
&mut cursor,
);
}
assert_eq!(text, "café");
assert_eq!(cursor, 4);
App::handle_text_field_input(
KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE),
&mut text,
&mut cursor,
);
assert_eq!(text, "caf");
assert_eq!(cursor, 3);
}
#[test]
fn test_text_field_cursor_movement_with_multibyte() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut text = "日本語".to_string();
let mut cursor: usize = 3;
App::handle_text_field_input(
KeyEvent::new(KeyCode::Left, KeyModifiers::NONE),
&mut text,
&mut cursor,
);
assert_eq!(cursor, 2);
App::handle_text_field_input(
KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE),
&mut text,
&mut cursor,
);
assert_eq!(text, "日本");
assert_eq!(cursor, 2);
App::handle_text_field_input(
KeyEvent::new(KeyCode::Home, KeyModifiers::NONE),
&mut text,
&mut cursor,
);
assert_eq!(cursor, 0);
App::handle_text_field_input(
KeyEvent::new(KeyCode::End, KeyModifiers::NONE),
&mut text,
&mut cursor,
);
assert_eq!(cursor, 2);
}
#[test]
fn test_text_field_insert_at_middle_of_multibyte() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut text = "ab".to_string();
let mut cursor: usize = 1;
App::handle_text_field_input(
KeyEvent::new(KeyCode::Char('ñ'), KeyModifiers::NONE),
&mut text,
&mut cursor,
);
assert_eq!(text, "añb");
assert_eq!(cursor, 2);
}
#[test]
fn test_q_in_normal_mode_quits_while_connected() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut app = test_app();
add_profiles(&mut app, &["vpn-a"]);
set_connected(&mut app, "vpn-a");
app.handle_key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE));
assert!(app.should_quit);
assert!(matches!(app.input_mode, InputMode::Normal));
}
#[test]
fn test_q_in_normal_mode_quits_while_disconnected() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut app = test_app();
app.handle_key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE));
assert!(app.should_quit);
}
#[test]
fn test_help_scroll_down_clamps_at_max() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut app = test_app();
let max_scroll = crate::state::help_max_scroll_for_terminal_height(
app.terminal_size.1,
crate::ui::help_total_lines(crate::state::HelpTab::Keys),
);
app.input_mode = InputMode::Help {
scroll: 0,
tab: crate::state::HelpTab::Keys,
};
for _ in 0..(usize::from(max_scroll) + 10) {
app.handle_key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));
}
assert!(matches!(
app.input_mode,
InputMode::Help { scroll, .. } if scroll == max_scroll
));
}
#[test]
fn test_help_scroll_does_not_move_when_terminal_size_unknown() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut app = test_app();
app.terminal_size = (0, 0);
app.input_mode = InputMode::Help {
scroll: 0,
tab: crate::state::HelpTab::Keys,
};
app.handle_key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));
assert!(matches!(
app.input_mode,
InputMode::Help {
scroll: 0,
tab: crate::state::HelpTab::Keys
}
));
}
#[test]
fn test_help_scroll_clamps_after_resize_before_key_handling() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut app = test_app();
let max_scroll = crate::state::help_max_scroll_for_terminal_height(
app.terminal_size.1,
crate::ui::help_total_lines(crate::state::HelpTab::Keys),
);
app.input_mode = InputMode::Help {
scroll: max_scroll.saturating_add(10),
tab: crate::state::HelpTab::Keys,
};
app.handle_key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE));
assert!(matches!(
app.input_mode,
InputMode::Help { scroll, .. } if scroll == max_scroll.saturating_sub(1)
));
}
#[test]
fn test_help_end_jumps_to_max_scroll() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut app = test_app();
let max_scroll = crate::state::help_max_scroll_for_terminal_height(
app.terminal_size.1,
crate::ui::help_total_lines(crate::state::HelpTab::Keys),
);
app.input_mode = InputMode::Help {
scroll: 0,
tab: crate::state::HelpTab::Keys,
};
app.handle_key(KeyEvent::new(KeyCode::End, KeyModifiers::NONE));
assert!(matches!(
app.input_mode,
InputMode::Help { scroll, .. } if scroll == max_scroll
));
}
#[test]
fn test_help_mouse_scroll_down_clamps_at_max() {
use crossterm::event::{KeyModifiers, MouseEvent, MouseEventKind};
let mut app = test_app();
let max_scroll = crate::state::help_max_scroll_for_terminal_height(
app.terminal_size.1,
crate::ui::help_total_lines(crate::state::HelpTab::Keys),
);
app.input_mode = InputMode::Help {
scroll: 0,
tab: crate::state::HelpTab::Keys,
};
for _ in 0..20 {
app.handle_mouse(MouseEvent {
kind: MouseEventKind::ScrollDown,
column: 0,
row: 0,
modifiers: KeyModifiers::NONE,
});
}
assert!(matches!(
app.input_mode,
InputMode::Help { scroll, .. } if scroll == max_scroll
));
}
#[test]
fn test_help_mouse_scroll_up_clamps_after_resize() {
use crossterm::event::{KeyModifiers, MouseEvent, MouseEventKind};
let mut app = test_app();
let max_scroll = crate::state::help_max_scroll_for_terminal_height(
app.terminal_size.1,
crate::ui::help_total_lines(crate::state::HelpTab::Keys),
);
app.input_mode = InputMode::Help {
scroll: max_scroll.saturating_add(9),
tab: crate::state::HelpTab::Keys,
};
app.handle_mouse(MouseEvent {
kind: MouseEventKind::ScrollUp,
column: 0,
row: 0,
modifiers: KeyModifiers::NONE,
});
assert!(matches!(
app.input_mode,
InputMode::Help { scroll, .. } if scroll == max_scroll.saturating_sub(3)
));
}
#[test]
fn test_home_in_sidebar_moves_to_first_profile() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut app = test_app();
add_profiles(&mut app, &["vpn-a", "vpn-b", "vpn-c"]);
app.profile_list_state.select(Some(2));
app.focused_panel = FocusedPanel::Sidebar;
app.handle_key(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE));
assert_eq!(app.profile_list_state.selected(), Some(0));
}
#[test]
fn test_end_in_sidebar_moves_to_last_profile() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut app = test_app();
add_profiles(&mut app, &["vpn-a", "vpn-b", "vpn-c"]);
app.profile_list_state.select(Some(0));
app.focused_panel = FocusedPanel::Sidebar;
app.handle_key(KeyEvent::new(KeyCode::End, KeyModifiers::NONE));
assert_eq!(app.profile_list_state.selected(), Some(2));
}
#[test]
fn test_home_in_logs_scrolls_to_top() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut app = test_app();
add_profiles(&mut app, &["vpn-a", "vpn-b", "vpn-c"]);
app.profile_list_state.select(Some(2));
app.focused_panel = FocusedPanel::Logs;
app.logs_scroll = 10;
app.logs_auto_scroll = false;
app.handle_key(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE));
assert_eq!(app.logs_scroll, 0, "Home in Logs should scroll to top");
assert_eq!(
app.profile_list_state.selected(),
Some(2),
"Profile selection should not change"
);
}
#[test]
fn test_end_in_logs_enables_auto_scroll() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut app = test_app();
app.focused_panel = FocusedPanel::Logs;
app.logs_auto_scroll = false;
app.handle_key(KeyEvent::new(KeyCode::End, KeyModifiers::NONE));
assert!(
app.logs_auto_scroll,
"End in Logs should re-enable auto-scroll"
);
}
#[test]
fn test_rename_updates_last_connected_profile() {
let mut app = test_app();
let dir = tempfile::tempdir().unwrap();
let conf_path = dir.path().join("old-name.conf");
std::fs::write(&conf_path, "dummy").unwrap();
app.runtime.profiles.push(VpnProfile {
name: "old-name".to_string(),
protocol: Protocol::WireGuard,
config_path: conf_path,
location: String::new(),
last_used: None,
});
app.profile_list_state.select(Some(0));
app.runtime.last_connected_profile = Some("old-name".to_string());
app.rename_profile(0, "new-name");
assert_eq!(
app.runtime.last_connected_profile.as_deref(),
Some("new-name"),
"Rename should update last_connected_profile"
);
}
#[test]
fn test_rename_on_active_profile_is_refused_at_overlay() {
let mut app = test_app();
let dir = tempfile::tempdir().unwrap();
let conf_path = dir.path().join("active-vpn.conf");
std::fs::write(&conf_path, "dummy").unwrap();
app.runtime.profiles.push(VpnProfile {
name: "active-vpn".to_string(),
protocol: Protocol::WireGuard,
config_path: conf_path,
location: String::new(),
last_used: None,
});
app.profile_list_state.select(Some(0));
set_connected(&mut app, "active-vpn");
app.handle_message(Message::OpenRename);
assert!(
!matches!(app.input_mode, InputMode::Rename { .. }),
"Rename overlay must refuse to open for an active profile"
);
}
#[test]
fn test_ip_unchanged_warning_fires_once() {
use crate::core::telemetry::TelemetryUpdate;
let mut app = test_app();
set_connected(&mut app, "test");
app.runtime.public_ip = "1.2.3.4".to_string();
app.handle_message(Message::Telemetry(TelemetryUpdate::PublicIp(
"1.2.3.4".to_string(),
)));
assert!(app.runtime.ip_unchanged_warned, "First warning should fire");
let warned_before = app.runtime.ip_unchanged_warned;
app.handle_message(Message::Telemetry(TelemetryUpdate::PublicIp(
"1.2.3.4".to_string(),
)));
assert!(
warned_before && app.runtime.ip_unchanged_warned,
"Second identical IP should not change the warning state"
);
}
#[test]
fn test_cannot_delete_connecting_profile() {
let mut app = test_app();
add_profiles(&mut app, &["my-vpn"]);
app.profile_list_state.select(Some(0));
set_connecting(&mut app, "my-vpn");
app.request_delete(0);
assert!(
!matches!(app.input_mode, InputMode::ConfirmDelete { .. }),
"Should not open confirm dialog for a connecting profile"
);
}
#[test]
fn test_cannot_delete_disconnecting_profile() {
let mut app = test_app();
add_profiles(&mut app, &["my-vpn"]);
app.profile_list_state.select(Some(0));
set_connected(&mut app, "my-vpn");
set_disconnecting(&mut app, "my-vpn");
app.request_delete(0);
assert!(
!matches!(app.input_mode, InputMode::ConfirmDelete { .. }),
"Should not open confirm dialog for a disconnecting profile"
);
}
#[test]
fn test_connect_selected_targets_sidebar_selection() {
let mut app = test_app();
add_profiles(&mut app, &["alpha", "beta"]);
app.profile_list_state.select(Some(1));
set_disconnecting(&mut app, "alpha");
app.handle_message(Message::ConnectSelected);
assert_eq!(
app.runtime.pending_connect,
Some(1),
"ConnectSelected should queue the sidebar-selected profile (index 1)"
);
}
#[test]
fn test_connect_selected_reconnects_active_profile() {
let mut app = test_app();
add_profiles(&mut app, &["alpha", "beta"]);
app.profile_list_state.select(Some(0));
set_connected(&mut app, "alpha");
app.handle_message(Message::ConnectSelected);
assert_eq!(
app.runtime.pending_connect,
Some(0),
"ConnectSelected on active profile should queue reconnect"
);
assert!(
matches!(app.legacy_state(), ConnectionState::Disconnecting { .. }),
"Should start disconnecting for reconnect"
);
}
fn setup_rename_app() -> App {
let mut app = test_app();
add_profiles(&mut app, &["existing-vpn"]);
app.profile_list_state.select(Some(0));
app
}
fn assert_rename_rejected(app: &App) {
assert_eq!(
app.runtime.profiles[0].name, "existing-vpn",
"name should be unchanged"
);
let toast_msg = app.toast.as_ref().map_or("", |t| t.message.as_str());
assert!(
toast_msg.contains("Invalid name"),
"should produce validation warning toast, got: {toast_msg:?}"
);
}
#[test]
fn rename_rejects_empty_name() {
let mut app = setup_rename_app();
app.rename_profile(0, " ");
assert_rename_rejected(&app);
}
#[test]
fn rename_rejects_forward_slash() {
let mut app = setup_rename_app();
app.rename_profile(0, "../etc/passwd");
assert_rename_rejected(&app);
}
#[test]
fn rename_rejects_backslash() {
let mut app = setup_rename_app();
app.rename_profile(0, "..\\windows\\system32");
assert_rename_rejected(&app);
}
#[test]
fn rename_rejects_dot_dot_traversal() {
let mut app = setup_rename_app();
app.rename_profile(0, "foo..bar");
assert_rename_rejected(&app);
}
#[test]
fn rename_rejects_hidden_file_prefix() {
let mut app = setup_rename_app();
app.rename_profile(0, ".hidden");
assert_rename_rejected(&app);
}
#[test]
fn rename_accepts_valid_alphanumeric() {
let mut app = setup_rename_app();
app.rename_profile(0, "my-vpn-2024");
let last_toast = app.toast.as_ref().map(|t| t.message.clone());
assert!(
!last_toast.as_deref().unwrap_or("").contains("Invalid name"),
"Valid name should not trigger validation error"
);
}
#[test]
fn rename_accepts_unicode_name() {
let mut app = setup_rename_app();
app.rename_profile(0, "日本-VPN");
let last_toast = app.toast.as_ref().map(|t| t.message.clone());
assert!(
!last_toast.as_deref().unwrap_or("").contains("Invalid name"),
"Unicode name should not trigger validation error"
);
}
#[test]
fn rename_accepts_spaces_and_hyphens() {
let mut app = setup_rename_app();
app.rename_profile(0, "My Work VPN - US East");
let last_toast = app.toast.as_ref().map(|t| t.message.clone());
assert!(
!last_toast.as_deref().unwrap_or("").contains("Invalid name"),
"Name with spaces and hyphens should not trigger validation error"
);
}
fn complete_flip(app: &mut App) {
if let Some(anim) = app.flip_animation.take() {
if anim.to_back {
app.panel_flipped.insert(anim.panel);
} else {
app.panel_flipped.remove(&anim.panel);
}
}
}
#[test]
fn flip_starts_animation() {
let mut app = test_app();
app.focused_panel = FocusedPanel::Chart;
app.handle_message(Message::ToggleFlip);
assert!(app.flip_animation.is_some());
assert!(!app.is_flipped(&FocusedPanel::Chart));
}
#[test]
fn flip_toggles_chart_panel_after_animation() {
let mut app = test_app();
app.focused_panel = FocusedPanel::Chart;
assert!(!app.is_flipped(&FocusedPanel::Chart));
app.handle_message(Message::ToggleFlip);
complete_flip(&mut app);
assert!(app.is_flipped(&FocusedPanel::Chart));
app.handle_message(Message::ToggleFlip);
complete_flip(&mut app);
assert!(!app.is_flipped(&FocusedPanel::Chart));
}
#[test]
fn flip_toggles_security_panel() {
let mut app = test_app();
app.focused_panel = FocusedPanel::Security;
app.handle_message(Message::ToggleFlip);
complete_flip(&mut app);
assert!(app.is_flipped(&FocusedPanel::Security));
assert!(!app.is_flipped(&FocusedPanel::Chart));
}
#[test]
fn flip_toggles_connection_details_panel() {
let mut app = test_app();
app.focused_panel = FocusedPanel::ConnectionDetails;
app.handle_message(Message::ToggleFlip);
complete_flip(&mut app);
assert!(app.is_flipped(&FocusedPanel::ConnectionDetails));
}
#[test]
fn flip_ignores_sidebar() {
let mut app = test_app();
app.focused_panel = FocusedPanel::Sidebar;
app.handle_message(Message::ToggleFlip);
assert!(app.flip_animation.is_none());
assert!(app.panel_flipped.is_empty());
}
#[test]
fn flip_ignores_logs() {
let mut app = test_app();
app.focused_panel = FocusedPanel::Logs;
app.handle_message(Message::ToggleFlip);
assert!(app.flip_animation.is_none());
assert!(app.panel_flipped.is_empty());
}
#[test]
fn flip_blocked_during_active_animation() {
let mut app = test_app();
app.focused_panel = FocusedPanel::Chart;
app.handle_message(Message::ToggleFlip);
assert!(app.flip_animation.is_some());
let started = app.flip_animation.as_ref().unwrap().started;
app.handle_message(Message::ToggleFlip);
assert_eq!(app.flip_animation.as_ref().unwrap().started, started);
}
#[test]
fn flip_state_persists_across_focus_changes() {
let mut app = test_app();
app.focused_panel = FocusedPanel::Chart;
app.handle_message(Message::ToggleFlip);
complete_flip(&mut app);
assert!(app.is_flipped(&FocusedPanel::Chart));
app.focused_panel = FocusedPanel::Security;
assert!(app.is_flipped(&FocusedPanel::Chart));
}
#[test]
fn flip_multiple_panels_independently() {
let mut app = test_app();
app.focused_panel = FocusedPanel::Chart;
app.handle_message(Message::ToggleFlip);
complete_flip(&mut app);
app.focused_panel = FocusedPanel::Security;
app.handle_message(Message::ToggleFlip);
complete_flip(&mut app);
assert!(app.is_flipped(&FocusedPanel::Chart));
assert!(app.is_flipped(&FocusedPanel::Security));
assert!(!app.is_flipped(&FocusedPanel::ConnectionDetails));
}
#[test]
fn flip_effective_state_at_midpoint() {
let mut app = test_app();
app.focused_panel = FocusedPanel::Chart;
assert!(!app.effective_flipped(&FocusedPanel::Chart));
app.handle_message(Message::ToggleFlip);
assert!(!app.effective_flipped(&FocusedPanel::Chart));
}
#[test]
fn flip_state_cleared_on_disconnect() {
let mut app = test_app();
add_profiles(&mut app, &["test-profile"]);
set_connected(&mut app, "test-profile");
app.focused_panel = FocusedPanel::Chart;
app.handle_message(Message::ToggleFlip);
complete_flip(&mut app);
app.focused_panel = FocusedPanel::Security;
app.handle_message(Message::ToggleFlip);
complete_flip(&mut app);
assert_eq!(app.panel_flipped.len(), 2);
app.complete_disconnect("test-profile");
assert!(app.panel_flipped.is_empty());
}
#[test]
fn advance_animation_completes_to_back() {
let mut app = test_app();
app.flip_animation = Some(crate::state::FlipAnimation {
panel: FocusedPanel::Chart,
started: std::time::Instant::now()
.checked_sub(std::time::Duration::from_millis(
crate::constants::FLIP_ANIMATION_DURATION_MS + 10,
))
.unwrap(),
to_back: true,
});
assert!(!app.is_flipped(&FocusedPanel::Chart));
app.advance_animation();
assert!(app.flip_animation.is_none());
assert!(app.is_flipped(&FocusedPanel::Chart));
}
#[test]
fn advance_animation_completes_to_front() {
let mut app = test_app();
app.panel_flipped.insert(FocusedPanel::Security);
app.flip_animation = Some(crate::state::FlipAnimation {
panel: FocusedPanel::Security,
started: std::time::Instant::now()
.checked_sub(std::time::Duration::from_millis(
crate::constants::FLIP_ANIMATION_DURATION_MS + 10,
))
.unwrap(),
to_back: false,
});
assert!(app.is_flipped(&FocusedPanel::Security));
app.advance_animation();
assert!(app.flip_animation.is_none());
assert!(!app.is_flipped(&FocusedPanel::Security));
}
#[test]
fn advance_animation_noop_when_still_running() {
let mut app = test_app();
app.focused_panel = FocusedPanel::Chart;
app.handle_message(Message::ToggleFlip);
assert!(app.flip_animation.is_some());
app.advance_animation();
assert!(app.flip_animation.is_some());
}
#[test]
fn effective_flipped_shows_target_after_midpoint() {
let mut app = test_app();
app.flip_animation = Some(crate::state::FlipAnimation {
panel: FocusedPanel::Chart,
started: std::time::Instant::now()
.checked_sub(std::time::Duration::from_millis(
crate::constants::FLIP_ANIMATION_DURATION_MS * 3 / 4,
))
.unwrap(),
to_back: true,
});
assert!(app.effective_flipped(&FocusedPanel::Chart));
}
#[test]
fn disconnect_clears_animation() {
let mut app = test_app();
add_profiles(&mut app, &["p1"]);
set_connected(&mut app, "p1");
app.focused_panel = FocusedPanel::Chart;
app.handle_message(Message::ToggleFlip);
assert!(app.flip_animation.is_some());
app.complete_disconnect("p1");
assert!(app.flip_animation.is_none());
}
fn key_char(c: char) -> crossterm::event::KeyEvent {
crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char(c),
crossterm::event::KeyModifiers::NONE,
)
}
fn key_shift_char(c: char) -> crossterm::event::KeyEvent {
crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char(c),
crossterm::event::KeyModifiers::SHIFT,
)
}
#[test]
fn u19_enter_on_disconnected_row_routes_to_connect() {
let mut app = test_app();
add_profiles(&mut app, &["p1"]);
app.profile_list_state.select(Some(0));
app.handle_message(Message::ToggleConnect(Some(0)));
assert!(
matches!(
app.input_mode,
InputMode::PermissionDenied { .. } | InputMode::DependencyError { .. }
),
"expected connect path to fire, got {:?}",
app.input_mode
);
}
#[test]
fn u19_enter_on_connected_primary_routes_to_disconnect() {
let mut app = test_app();
add_profiles(&mut app, &["p1"]);
set_connected(&mut app, "p1");
app.profile_list_state.select(Some(0));
app.handle_message(Message::ToggleConnect(Some(0)));
assert!(
!matches!(app.legacy_state(), ConnectionState::Connected { .. }),
"expected tunnel torn down after Enter on Connected, got {:?}",
app.legacy_state()
);
}
#[test]
fn u19_disconnect_profile_message_disconnects_legacy_match() {
let mut app = test_app();
add_profiles(&mut app, &["p1"]);
set_connected(&mut app, "p1");
app.handle_message(Message::DisconnectProfile { idx: 0 });
assert!(
!matches!(app.legacy_state(), ConnectionState::Connected { .. }),
"expected the tunnel torn down, got {:?}",
app.legacy_state()
);
}
#[test]
fn u19_disconnect_profile_idempotent_for_inactive_row() {
let mut app = test_app();
add_profiles(&mut app, &["p1", "p2"]);
set_connected(&mut app, "p1");
app.handle_message(Message::DisconnectProfile { idx: 1 });
assert!(
matches!(app.legacy_state(), ConnectionState::Connected { .. }),
"DisconnectProfile on inactive row must leave Connected state intact, got {:?}",
app.legacy_state(),
);
}
#[test]
fn u19_shift_d_with_n_le_1_acts_like_plain_d() {
let mut app = test_app();
add_profiles(&mut app, &["p1"]);
set_connected(&mut app, "p1");
app.profile_list_state.select(Some(0));
app.focused_panel = FocusedPanel::Sidebar;
app.handle_key(key_shift_char('D'));
assert!(
!matches!(app.input_mode, InputMode::ConfirmDisconnectAll { .. }),
"Shift+D with N≤1 must not open the confirm dialog, got {:?}",
app.input_mode
);
}
#[test]
fn u19_request_disconnect_all_opens_confirm_when_multi() {
let mut app = test_app();
add_profiles(&mut app, &["p1", "p2"]);
set_connected(&mut app, "p1");
app.profile_list_state.select(Some(0));
app.focused_panel = FocusedPanel::Sidebar;
app.input_mode = InputMode::Normal;
let n = app.active_tunnel_count();
if n > 1 {
app.handle_message(Message::RequestDisconnectAll);
assert!(matches!(
app.input_mode,
InputMode::ConfirmDisconnectAll { .. }
));
} else {
app.handle_message(Message::RequestDisconnectAll);
assert!(
!matches!(app.input_mode, InputMode::ConfirmDisconnectAll { .. }),
"RequestDisconnectAll with N≤1 must not open the confirm overlay"
);
}
}
#[test]
fn u19_confirm_disconnect_all_closes_overlay() {
let mut app = test_app();
add_profiles(&mut app, &["p1"]);
set_connected(&mut app, "p1");
app.input_mode = InputMode::ConfirmDisconnectAll {
count: 2,
confirm_selected: true,
};
app.handle_message(Message::ConfirmDisconnectAll);
assert!(matches!(app.input_mode, InputMode::Normal));
assert!(
!matches!(app.legacy_state(), ConnectionState::Connected { .. }),
"confirm-disconnect-all must tear down the active tunnel"
);
}
#[test]
fn shift_d_disconnect_all_processes_every_active_tunnel() {
use crate::vortix_core::engine::state::Connection;
use crate::vortix_core::profile::ProfileId;
let mut app = test_app();
add_profiles(&mut app, &["alpha", "beta"]);
set_connected(&mut app, "alpha");
set_connected(&mut app, "beta");
for name in ["alpha", "beta"] {
app.runtime.retry_state.insert(
ProfileId::new(name),
crate::state::RetryState {
attempt: 1,
profile_idx: 0,
auto_reconnect: true,
},
);
}
assert_eq!(app.runtime.retry_state.len(), 2);
assert_eq!(app.active_tunnel_count(), 2);
app.disconnect_all_active();
assert!(
app.runtime.retry_state.is_empty(),
"disconnect_all_active must clear retry_state for EVERY active \
profile, not just the primary; got: {:?}",
app.runtime.retry_state
);
for name in ["alpha", "beta"] {
let snap = app
.registry
.snapshot(&ProfileId::new(name))
.expect("registry entry must exist post disconnect");
assert!(
matches!(
snap.state,
Connection::Disconnected { .. } | Connection::Disconnecting { .. }
),
"{name} should be Disconnecting/Disconnected after \
disconnect_all_active; got {:?}",
snap.state
);
}
}
#[test]
fn shift_d_disconnect_profile_by_idx_works_for_secondary() {
use crate::vortix_core::engine::state::Connection;
use crate::vortix_core::profile::ProfileId;
let mut app = test_app();
add_profiles(&mut app, &["alpha", "beta"]);
set_connected(&mut app, "alpha");
set_connected(&mut app, "beta");
app.runtime.retry_state.insert(
ProfileId::new("beta"),
crate::state::RetryState {
attempt: 1,
profile_idx: 1,
auto_reconnect: true,
},
);
app.disconnect_profile_by_idx(1);
assert!(
!app.runtime
.retry_state
.contains_key(&ProfileId::new("beta")),
"beta's retry_state must be cleared by disconnect_profile_by_idx"
);
let beta_snap = app
.registry
.snapshot(&ProfileId::new("beta"))
.expect("beta entry must remain in registry");
assert!(
!matches!(beta_snap.state, Connection::Connected { .. }),
"beta should leave Connected after disconnect_profile_by_idx(1); \
got {:?}",
beta_snap.state
);
let alpha_snap = app
.registry
.snapshot(&ProfileId::new("alpha"))
.expect("alpha entry should remain");
assert!(
matches!(alpha_snap.state, Connection::Connected { .. }),
"alpha must stay Connected when we only disconnected beta; \
got {:?}",
alpha_snap.state
);
}
#[test]
fn u19_connection_details_follows_sidebar_selection() {
let mut app = test_app();
add_profiles(&mut app, &["alpha", "beta"]);
set_connected(&mut app, "alpha");
set_connected(&mut app, "beta");
app.profile_list_state.select(Some(1)); assert_eq!(
app.connection_details_focused_idx(),
Some(1),
"Connection Details should follow sidebar selection"
);
app.profile_list_state.select(Some(0)); assert_eq!(
app.connection_details_focused_idx(),
Some(0),
"Switching sidebar selection should switch the Details focus"
);
}
#[test]
fn u19_cancel_connect_message_drives_disconnect_on_legacy_connecting() {
let mut app = test_app();
add_profiles(&mut app, &["p1"]);
set_connecting(&mut app, "p1");
app.handle_message(Message::CancelConnect { idx: 0 });
assert!(
!matches!(app.legacy_state(), ConnectionState::Connecting { .. }),
"CancelConnect must move tunnel out of Connecting, got {:?}",
app.legacy_state()
);
}
#[test]
fn u19_active_tunnel_count_reflects_registry_after_connect() {
let mut app = test_app();
add_profiles(&mut app, &["p1"]);
assert_eq!(app.active_tunnel_count(), 0);
set_connected(&mut app, "p1");
assert_eq!(app.active_tunnel_count(), 1);
}
#[test]
fn u19_confirm_disconnect_all_overlay_y_key_confirms() {
let mut app = test_app();
add_profiles(&mut app, &["p1"]);
set_connected(&mut app, "p1");
app.input_mode = InputMode::ConfirmDisconnectAll {
count: 2,
confirm_selected: true,
};
app.handle_key(key_char('y'));
assert!(matches!(app.input_mode, InputMode::Normal));
}
#[test]
fn u19_confirm_disconnect_all_overlay_n_key_cancels() {
let mut app = test_app();
add_profiles(&mut app, &["p1"]);
set_connected(&mut app, "p1");
app.input_mode = InputMode::ConfirmDisconnectAll {
count: 3,
confirm_selected: true,
};
app.handle_key(key_char('n'));
assert!(matches!(app.input_mode, InputMode::Normal));
assert!(matches!(
app.legacy_state(),
ConnectionState::Connected { .. }
));
}
#[test]
fn connect_result_success_mirrors_into_registry() {
use crate::vortix_core::engine::state::Connection;
use crate::vortix_core::profile::ProfileId;
let mut app = test_app();
add_profiles(&mut app, &["mirror-test"]);
set_connecting(&mut app, "mirror-test");
assert_eq!(app.registry.tunnel_count(), 1);
app.handle_message(Message::ConnectResult {
profile: "mirror-test".to_string(),
success: true,
error: None,
interface: None,
pid: None,
});
let profile_id = ProfileId::new("mirror-test");
let snap = app
.registry
.snapshot(&profile_id)
.expect("renderer-facing registry snapshot must exist after a successful connect");
assert!(
matches!(snap.state, Connection::Connected { .. }),
"registry FSM must be in Connected state, got {:?}",
snap.state
);
assert_eq!(
app.registry.tunnel_count(),
1,
"header tunnel_count must reflect the live connection"
);
}
#[test]
fn scanner_promotion_from_connecting_to_connected_mirrors_into_registry() {
use crate::vortix_core::engine::state::Connection;
use crate::vortix_core::profile::ProfileId;
let mut app = test_app();
add_profiles(&mut app, &["AWS_VPN"]);
set_connecting(&mut app, "AWS_VPN");
app.handle_message(Message::SyncSystemState {
sessions: vec![fake_session("AWS_VPN")],
default_route_interface: None,
});
let snap = app
.registry
.snapshot(&ProfileId::new("AWS_VPN"))
.expect("registry must have a snapshot for the in-flight profile");
assert!(
matches!(snap.state, Connection::Connecting { .. }),
"registry FSM must stay Connecting until the protocol layer reports success; got {:?}",
snap.state
);
assert!(
matches!(
app.legacy_state(),
ConnectionState::Connecting { ref profile, .. } if profile == "AWS_VPN"
),
"legacy state mirrors registry — still Connecting"
);
}
#[test]
fn scanner_drop_from_connected_clears_registry() {
use crate::vortix_core::profile::ProfileId;
let mut app = test_app();
add_profiles(&mut app, &["AWS_VPN"]);
set_connected(&mut app, "AWS_VPN");
assert_eq!(app.registry.tunnel_count(), 1, "setup precondition");
app.handle_message(Message::SyncSystemState {
sessions: vec![],
default_route_interface: None,
});
assert_eq!(
app.registry.tunnel_count(),
0,
"registry must drop the entry when scanner reports tunnel gone"
);
assert!(app.registry.snapshot(&ProfileId::new("AWS_VPN")).is_none());
}
#[test]
fn mirrored_registry_entry_uses_real_interface_not_mock0() {
use crate::vortix_core::engine::state::Connection;
use crate::vortix_core::profile::ProfileId;
let mut app = test_app();
add_profiles(&mut app, &["AWS_VPN"]);
set_connected(&mut app, "AWS_VPN");
let snap = app
.registry
.snapshot(&ProfileId::new("AWS_VPN"))
.expect("registry snapshot must exist");
let Connection::Connected { details, .. } = snap.state else {
panic!("expected Connected, got {:?}", snap.state);
};
assert_eq!(
details.interface, "wg0",
"registry must store the iface from the authoritative Tunnel::up path"
);
assert_eq!(details.pid, Some(12345), "registry must store the real pid");
}
#[test]
fn mirrored_registry_entry_carries_full_rich_details_not_just_interface_and_pid() {
use crate::vortix_core::engine::state::Connection;
use crate::vortix_core::profile::ProfileId;
let mut app = test_app();
add_profiles(&mut app, &["AWS_VPN"]);
set_connected(&mut app, "AWS_VPN");
let session = fake_session("AWS_VPN");
app.handle_message(Message::SyncSystemState {
sessions: vec![session],
default_route_interface: None,
});
let snap = app
.registry
.snapshot(&ProfileId::new("AWS_VPN"))
.expect("registry snapshot must exist");
let Connection::Connected { details, .. } = snap.state else {
panic!("expected Connected, got {:?}", snap.state);
};
assert_eq!(details.interface, "wg0");
assert_eq!(details.pid, Some(12345));
assert_eq!(
details.endpoint, "1.2.3.4:51820",
"Server column reads details.endpoint"
);
assert_eq!(
details.internal_ip, "10.0.0.2",
"VPN IP column reads details.internal_ip"
);
assert_eq!(details.mtu, "1420", "MTU column reads details.mtu");
assert_eq!(details.listen_port, "51820");
assert_eq!(
details.transfer_rx, "100 KiB",
"Transfer column reads details.transfer_rx/tx"
);
assert_eq!(details.transfer_tx, "50 KiB");
assert_eq!(details.latest_handshake, "5 seconds ago");
}
#[test]
fn mirror_refresh_updates_registry_when_details_change() {
use crate::vortix_core::engine::state::Connection;
use crate::vortix_core::profile::ProfileId;
let mut app = test_app();
add_profiles(&mut app, &["wg-test"]);
set_connected(&mut app, "wg-test");
{
let snap = app
.registry
.snapshot(&ProfileId::new("wg-test"))
.expect("setup precondition");
let Connection::Connected { details, .. } = snap.state else {
panic!("expected Connected setup, got {:?}", snap.state);
};
assert_eq!(details.interface, "wg0", "setup precondition");
}
let mut session = fake_session("wg-test");
session.interface = "utun99-wrong-from-scanner".to_string();
app.handle_message(Message::SyncSystemState {
sessions: vec![session],
default_route_interface: None,
});
let snap = app
.registry
.snapshot(&ProfileId::new("wg-test"))
.expect("registry snapshot must exist after refresh");
let Connection::Connected { details, .. } = snap.state else {
panic!("expected Connected, got {:?}", snap.state);
};
assert_eq!(
details.interface, "wg0",
"scanner refresh must NOT overwrite the authoritative iface set by Tunnel::up — preserves the contract that prevents bugs #3 / #12"
);
assert_eq!(details.endpoint, "1.2.3.4:51820");
}
#[test]
fn disconnect_result_success_removes_from_registry() {
use crate::vortix_core::profile::ProfileId;
let mut app = test_app();
add_profiles(&mut app, &["mirror-test"]);
set_connecting(&mut app, "mirror-test");
app.handle_message(Message::ConnectResult {
profile: "mirror-test".to_string(),
success: true,
error: None,
interface: None,
pid: None,
});
assert_eq!(app.registry.tunnel_count(), 1, "setup precondition");
app.mirror_disconnecting_into_registry("mirror-test");
app.handle_message(Message::DisconnectResult {
profile: "mirror-test".to_string(),
success: true,
error: None,
});
assert_eq!(
app.registry.tunnel_count(),
0,
"registry must reflect that the tunnel is gone after disconnect"
);
assert!(
app.registry
.snapshot(&ProfileId::new("mirror-test"))
.is_none(),
"no leftover snapshot for the disconnected profile"
);
}
#[test]
fn connect_result_success_arrives_after_scanner_observes_kernel_session() {
use crate::vortix_core::profile::ProfileId;
let mut app = test_app();
add_profiles(&mut app, &["race-test"]);
set_connecting(&mut app, "race-test");
app.handle_message(Message::SyncSystemState {
sessions: vec![fake_session("race-test")],
default_route_interface: None,
});
assert!(
matches!(
app.legacy_state(),
ConnectionState::Connecting { ref profile, .. } if profile == "race-test"
),
"registry must stay Connecting — scanner can't drive promotion under U4"
);
app.handle_message(Message::ConnectResult {
profile: "race-test".to_string(),
success: true,
error: None,
interface: None,
pid: None,
});
assert_eq!(
app.runtime.last_connected_profile.as_deref(),
Some("race-test"),
"last_connected_profile must be set by the success handler"
);
let snap = app
.registry
.snapshot(&ProfileId::new("race-test"))
.expect("registry must contain the now-Connected tunnel");
assert!(
matches!(
snap.state,
crate::vortix_core::engine::state::Connection::Connected { .. }
),
"Connected state lands via the authoritative ConnectResult path"
);
}
#[test]
fn connect_result_success_seeds_authoritative_iface_into_registry() {
use crate::vortix_core::engine::state::Connection;
use crate::vortix_core::profile::ProfileId;
let mut app = test_app();
add_profiles(&mut app, &["ovpn-cert"]);
set_connecting(&mut app, "ovpn-cert");
app.handle_message(Message::ConnectResult {
profile: "ovpn-cert".to_string(),
success: true,
error: None,
interface: Some("utun8".to_string()),
pid: Some(7155),
});
let snap = app
.registry
.snapshot(&ProfileId::new("ovpn-cert"))
.expect("registry must have a snapshot for the connected profile");
let Connection::Connected { details, .. } = snap.state else {
panic!("expected Connected, got {:?}", snap.state);
};
assert_eq!(
details.interface, "utun8",
"ConnectResult must seed the registry entry's interface field — empty iface breaks primary-election"
);
assert_eq!(details.pid, Some(7155), "PID seeded same path");
assert!(
details.interface_authoritative,
"ConnectResult success path is authoritative by construction (came from Tunnel::up's log scrape)"
);
}
#[test]
fn connect_result_for_secondary_profile_during_takeover_is_not_stale() {
use crate::vortix_core::engine::state::Connection;
use crate::vortix_core::profile::ProfileId;
let mut app = test_app();
add_profiles(&mut app, &["ovpn-cert", "vpn-secondary"]);
set_connected(&mut app, "ovpn-cert");
set_connecting(&mut app, "vpn-secondary");
app.handle_message(Message::ConnectResult {
profile: "vpn-secondary".to_string(),
success: true,
error: None,
interface: Some("utun9".to_string()),
pid: Some(8888),
});
let snap = app
.registry
.snapshot(&ProfileId::new("vpn-secondary"))
.expect("vpn-secondary must be in the registry after a successful ConnectResult");
let Connection::Connected { details, .. } = snap.state else {
panic!(
"expected vpn-secondary Connected (NOT stuck in Connecting); got {:?}",
snap.state
);
};
assert_eq!(
details.interface, "utun9",
"the second profile's iface must seed into the registry from its ConnectResult — \
the stale check must not drop this message just because the legacy view points at ovpn-cert"
);
}
#[test]
fn cached_config_view_precomputes_total_lines_and_highlighted_vec() {
use crate::app::CachedConfigView;
let content = "[Interface]\nAddress = 10.0.0.2/24\nPrivateKey = abc\n\n[Peer]\nPublicKey = def\nAllowedIPs = 0.0.0.0/0\n";
let view = CachedConfigView::from_content(content.to_string());
assert_eq!(view.total_lines, 7, "total_lines must be pre-computed");
assert_eq!(
view.highlighted_lines.len(),
7,
"highlighted_lines must have one entry per content line"
);
assert_eq!(view.content, content, "raw content preserved verbatim");
}
#[test]
fn get_config_max_scroll_reads_from_cache() {
use crate::app::CachedConfigView;
let mut app = test_app();
let mut content = String::new();
for i in 0..200 {
use std::fmt::Write;
let _ = writeln!(content, "line {i}");
}
app.terminal_size = (120, 40);
app.cached_config = Some(CachedConfigView::from_content(content));
let max = app.get_config_max_scroll();
assert!(
max > 0,
"200 lines must produce a positive max-scroll on a 40-row terminal"
);
assert_eq!(app.get_config_max_scroll(), max);
}
#[test]
fn refresh_registry_preserves_authoritative_iface_across_scanner_ticks() {
use crate::vortix_core::engine::state::Connection;
use crate::vortix_core::profile::ProfileId;
let mut app = App::new_test();
add_profiles(&mut app, &["ovpn-cert"]);
let details = DetailedConnectionInfo {
interface: "utun8".to_string(),
pid: Some(7155),
..Default::default()
};
app.mirror_connect_into_registry("ovpn-cert", &details, Instant::now());
let mut session = fake_session("ovpn-cert");
session.interface = "utun3".to_string();
app.refresh_registry_from_session("ovpn-cert", &session);
let snap = app
.registry
.snapshot(&ProfileId::new("ovpn-cert"))
.expect("registry entry");
let iface = match snap.state {
Connection::Connected { details, .. } => details.interface.clone(),
other => panic!("expected Connected, got {other:?}"),
};
assert_eq!(
iface, "utun8",
"scanner must NOT overwrite authoritative iface set by Tunnel::up()"
);
}
#[test]
fn real_ip_not_cached_when_scanner_has_not_ticked_yet() {
use crate::core::telemetry::TelemetryUpdate;
let mut app = test_app();
assert!(!app.runtime.scanner_first_tick_done);
assert!(app.runtime.real_ip.is_none());
app.handle_message(Message::Telemetry(TelemetryUpdate::PublicIp(
"46.101.235.146".to_string(),
)));
assert!(
app.runtime.real_ip.is_none(),
"real_ip must stay None until scanner reports kernel state"
);
}
#[test]
fn real_ip_not_cached_when_kernel_has_active_sessions() {
use crate::core::telemetry::TelemetryUpdate;
let mut app = test_app();
add_profiles(&mut app, &["vpn-a"]);
app.handle_message(Message::SyncSystemState {
sessions: vec![fake_session("vpn-a")],
default_route_interface: Some("wg0".to_string()),
});
assert!(app.runtime.scanner_first_tick_done);
assert_eq!(app.runtime.last_kernel_session_count, 1);
let pid = crate::vortix_core::profile::ProfileId::new("vpn-a");
app.registry.set_disconnected(&pid);
assert!(!app.has_active_connection());
assert_eq!(app.runtime.last_kernel_session_count, 1);
app.handle_message(Message::Telemetry(TelemetryUpdate::PublicIp(
"46.101.235.146".to_string(),
)));
assert!(
app.runtime.real_ip.is_none(),
"real_ip must stay None while kernel reports any VPN session"
);
}
#[test]
fn real_ip_cached_after_clean_scanner_tick_with_zero_sessions() {
use crate::core::telemetry::TelemetryUpdate;
let mut app = test_app();
app.handle_message(Message::SyncSystemState {
sessions: vec![],
default_route_interface: None,
});
assert!(app.runtime.scanner_first_tick_done);
assert_eq!(app.runtime.last_kernel_session_count, 0);
app.handle_message(Message::Telemetry(TelemetryUpdate::PublicIp(
"203.0.113.5".to_string(),
)));
assert_eq!(
app.runtime.real_ip.as_deref(),
Some("203.0.113.5"),
"real_ip must cache once scanner confirms zero sessions"
);
}
#[test]
fn real_ip_overwrites_on_disconnected_telemetry_samples() {
use crate::core::telemetry::TelemetryUpdate;
let mut app = test_app();
app.handle_message(Message::SyncSystemState {
sessions: vec![],
default_route_interface: None,
});
app.handle_message(Message::Telemetry(TelemetryUpdate::PublicIp(
"203.0.113.5".to_string(),
)));
assert_eq!(app.runtime.real_ip.as_deref(), Some("203.0.113.5"));
app.handle_message(Message::Telemetry(TelemetryUpdate::PublicIp(
"198.51.100.10".to_string(),
)));
assert_eq!(
app.runtime.real_ip.as_deref(),
Some("198.51.100.10"),
"real_ip must update when user moves networks"
);
}
#[test]
fn real_ip_telemetry_persists_to_disk_cache() {
use crate::core::telemetry::TelemetryUpdate;
let mut app = test_app();
let scratch =
std::env::temp_dir().join(format!("vortix-real-ip-cache-app-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&scratch);
std::fs::create_dir_all(&scratch).expect("scratch dir");
app.runtime.config_dir = scratch.clone();
app.handle_message(Message::SyncSystemState {
sessions: vec![],
default_route_interface: None,
});
app.handle_message(Message::Telemetry(TelemetryUpdate::PublicIp(
"203.0.113.42".to_string(),
)));
assert_eq!(app.runtime.real_ip.as_deref(), Some("203.0.113.42"));
let loaded = crate::core::real_ip_cache::load(&scratch)
.expect("on-disk cache must exist after a safe-to-cache telemetry sample");
assert_eq!(loaded.ip, "203.0.113.42");
let _ = std::fs::remove_dir_all(&scratch);
}
#[test]
fn real_ip_frozen_once_connected_then_thaws_on_disconnect() {
use crate::core::telemetry::TelemetryUpdate;
let mut app = test_app();
app.handle_message(Message::SyncSystemState {
sessions: vec![],
default_route_interface: None,
});
app.handle_message(Message::Telemetry(TelemetryUpdate::PublicIp(
"203.0.113.5".to_string(),
)));
assert_eq!(app.runtime.real_ip.as_deref(), Some("203.0.113.5"));
add_profiles(&mut app, &["vpn-a"]);
app.handle_message(Message::SyncSystemState {
sessions: vec![fake_session("vpn-a")],
default_route_interface: Some("wg0".to_string()),
});
app.handle_message(Message::Telemetry(TelemetryUpdate::PublicIp(
"46.101.235.146".to_string(),
)));
assert_eq!(
app.runtime.real_ip.as_deref(),
Some("203.0.113.5"),
"real_ip must stay frozen while connected"
);
let pid = crate::vortix_core::profile::ProfileId::new("vpn-a");
app.registry.set_disconnected(&pid);
app.handle_message(Message::SyncSystemState {
sessions: vec![],
default_route_interface: None,
});
app.handle_message(Message::Telemetry(TelemetryUpdate::PublicIp(
"198.51.100.99".to_string(),
)));
assert_eq!(
app.runtime.real_ip.as_deref(),
Some("198.51.100.99"),
"real_ip must thaw and update after clean disconnect"
);
}