nvpn 4.0.25

CLI and daemon for Nostr VPN private mesh networks
use std::fs;
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};

use clap::CommandFactory;
use nostr_vpn_core::config::AppConfig;

use crate::*;

#[test]
fn clap_service_supports_install_uninstall_status() {
    let command = Cli::command();
    let service = command
        .get_subcommands()
        .find(|subcommand| subcommand.get_name() == "service")
        .expect("service subcommand exists");
    for name in ["install", "enable", "disable", "uninstall", "status"] {
        assert!(
            service
                .get_subcommands()
                .any(|subcommand| subcommand.get_name() == name),
            "missing service subcommand {name}"
        );
    }
}

#[test]
fn linux_service_show_parser_extracts_running_state() {
    let show = "LoadState=loaded\nActiveState=active\nSubState=running\nMainPID=4242\n";
    let (loaded, running, pid) = linux_service_status_from_show_output(show);
    assert!(loaded);
    assert!(running);
    assert_eq!(pid, Some(4242));
}

#[test]
fn macos_service_disabled_parser_extracts_disabled_state() {
    let output = r#"
        disabled services = {
            "to.nostrvpn.nvpn" => disabled
            "com.example.other" => enabled
        }
    "#;

    assert!(
        crate::macos_service::macos_service_disabled_from_print_disabled_output(
            output,
            "to.nostrvpn.nvpn"
        )
    );
    assert!(
        !crate::macos_service::macos_service_disabled_from_print_disabled_output(
            output,
            "com.example.other"
        )
    );
    assert!(
        !crate::macos_service::macos_service_disabled_from_print_disabled_output(
            output,
            "missing.service"
        )
    );
}

#[test]
fn macos_service_plist_runs_service_supervised_daemon() {
    let plist = crate::macos_service::macos_service_plist_content(
        "to.nostrvpn.nvpn",
        Path::new("/Applications/Nostr VPN.app/Contents/MacOS/nvpn"),
        Path::new("/Users/sirius/Library/Application Support/nvpn/config.toml"),
        "utun100",
        20,
        Path::new("/Users/sirius/Library/Logs/nvpn/daemon.log"),
    );

    assert!(plist.contains("<string>daemon</string>"));
    assert!(plist.contains("<string>--service</string>"));
    assert!(plist.contains("<string>--config</string>"));
}

#[test]
fn macos_service_plist_parser_extracts_service_executable() {
    let plist = crate::macos_service::macos_service_plist_content(
        "to.nostrvpn.nvpn",
        Path::new("/Applications/Nostr VPN.app/Contents/MacOS/nvpn"),
        Path::new("/Users/sirius/Library/Application Support/nvpn/config.toml"),
        "utun100",
        20,
        Path::new("/Users/sirius/Library/Logs/nvpn/daemon.log"),
    );

    assert_eq!(
        crate::macos_service::macos_service_executable_path_from_plist_contents(&plist).as_deref(),
        Some("/Applications/Nostr VPN.app/Contents/MacOS/nvpn")
    );
}

#[test]
fn macos_service_label_uses_stable_default_for_main_config() {
    let label = crate::macos_service::macos_service_label(&crate::default_config_path());
    assert_eq!(label, "to.nostrvpn.nvpn");
}

#[test]
fn macos_service_binary_uses_privileged_helper_copy() {
    assert_eq!(
        crate::macos_service::macos_service_binary_path(&crate::default_config_path()),
        Path::new("/Library/PrivilegedHelperTools/to.nostrvpn.nvpn")
    );
}

#[test]
fn macos_service_label_scopes_non_default_configs() {
    let label = crate::macos_service::macos_service_label(Path::new("/tmp/nvpn-debug/config.toml"));
    assert!(label.starts_with("to.nostrvpn.nvpn."));
    assert_ne!(label, "to.nostrvpn.nvpn");
}

#[test]
fn macos_service_activation_enables_before_bootstrap() {
    let config_path = crate::default_config_path();
    let plist_path = crate::macos_service::macos_service_plist_path(&config_path);
    let commands =
        crate::macos_service::macos_service_activation_commands(&config_path, &plist_path);

    assert_eq!(
        commands,
        vec![
            vec!["enable".to_string(), "system/to.nostrvpn.nvpn".to_string()],
            vec!["bootout".to_string(), "system/to.nostrvpn.nvpn".to_string()],
            vec![
                "bootstrap".to_string(),
                "system".to_string(),
                plist_path.display().to_string()
            ],
            vec![
                "kickstart".to_string(),
                "-k".to_string(),
                "system/to.nostrvpn.nvpn".to_string()
            ],
        ]
    );
}

#[test]
fn service_config_guard_leaves_existing_config_contents_unchanged() {
    let nonce = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .expect("clock is after epoch")
        .as_nanos();
    let dir = std::env::temp_dir().join(format!("nvpn-service-config-guard-{nonce}"));
    fs::create_dir_all(&dir).expect("create test dir");
    let config_path = dir.join("config.toml");
    let config = AppConfig {
        node_name: "existing-config".to_string(),
        ..AppConfig::default()
    };
    config.save(&config_path).expect("save config");
    let before = fs::read_to_string(&config_path).expect("read config before guard");

    crate::service_management::ensure_service_config_exists(&config_path)
        .expect("existing config should validate");

    let after = fs::read_to_string(&config_path).expect("read config after guard");
    assert_eq!(after, before);

    let _ = fs::remove_dir_all(&dir);
}

#[test]
fn service_config_guard_does_not_replace_invalid_existing_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-service-config-invalid-{nonce}"));
    fs::create_dir_all(&dir).expect("create test dir");
    let config_path = dir.join("config.toml");
    fs::write(&config_path, "not valid toml").expect("write invalid config");

    let err = crate::service_management::ensure_service_config_exists(&config_path)
        .expect_err("invalid existing config should fail");

    assert!(
        err.to_string().contains("failed to parse config TOML"),
        "{err}"
    );
    assert_eq!(
        fs::read_to_string(&config_path).expect("read invalid config"),
        "not valid toml"
    );

    let _ = fs::remove_dir_all(&dir);
}

#[test]
fn macos_stop_daemon_hint_prefers_launchd_guidance_for_service_pid() {
    let status = ServiceStatusView {
        supported: true,
        installed: true,
        disabled: false,
        loaded: true,
        running: true,
        pid: Some(4242),
        label: "to.nostrvpn.nvpn".to_string(),
        plist_path: "/Library/LaunchDaemons/to.nostrvpn.nvpn.plist".to_string(),
        binary_path: "/Applications/Nostr VPN.app/Contents/MacOS/nvpn".to_string(),
        binary_version: env!("CARGO_PKG_VERSION").to_string(),
    };

    let hint = crate::macos_stop_daemon_hint_from_service_status(&status, &[4242])
        .expect("launchd-managed service should produce a hint");
    assert!(hint.contains("launchd service to.nostrvpn.nvpn"));
    assert!(hint.contains("service disable"));
    assert!(hint.contains("service enable"));
}

#[test]
fn macos_stop_daemon_hint_ignores_non_service_pid() {
    let status = ServiceStatusView {
        supported: true,
        installed: true,
        disabled: false,
        loaded: true,
        running: true,
        pid: Some(4242),
        label: "to.nostrvpn.nvpn".to_string(),
        plist_path: "/Library/LaunchDaemons/to.nostrvpn.nvpn.plist".to_string(),
        binary_path: "/Applications/Nostr VPN.app/Contents/MacOS/nvpn".to_string(),
        binary_version: env!("CARGO_PKG_VERSION").to_string(),
    };

    assert!(crate::macos_stop_daemon_hint_from_service_status(&status, &[31337]).is_none());
}

#[test]
fn linux_service_unit_runs_service_supervised_daemon() {
    let unit = crate::linux_service_unit_content(
        Path::new("/usr/local/bin/nvpn"),
        Path::new("/home/sirius/.config/nvpn/config.toml"),
        "nvpn",
        20,
        Path::new("/home/sirius/.local/state/nvpn/daemon.log"),
    );

    assert!(unit.contains("ExecStart=\"/usr/local/bin/nvpn\" daemon --service --config"));
    assert!(unit.contains("--iface \"nvpn\""));
    assert!(unit.contains("--mesh-refresh-interval-secs 20"));
    assert!(unit.contains("StandardOutput=append:/home/sirius/.local/state/nvpn/daemon.log"));
    assert!(unit.contains("StandardError=append:/home/sirius/.local/state/nvpn/daemon.log"));
    assert!(!unit.contains("StandardOutput=append:\""));
    assert!(!unit.contains("StandardError=append:\""));
}

#[test]
fn linux_service_binary_uses_stable_path_copy() {
    assert_eq!(
        linux_service_binary_path(),
        Path::new("/usr/local/bin/nvpn")
    );
}

#[test]
fn linux_service_unit_parser_extracts_service_executable() {
    let unit = crate::linux_service_unit_content(
        Path::new("/usr/local/bin/nvpn"),
        Path::new("/home/sirius/.config/nvpn/config.toml"),
        "nvpn",
        20,
        Path::new("/home/sirius/.local/state/nvpn/daemon.log"),
    );

    assert_eq!(
        linux_service_executable_path_from_unit_contents(&unit).as_deref(),
        Some("/usr/local/bin/nvpn")
    );
}

#[test]
fn windows_service_query_parser_extracts_running_state() {
    let query = "SERVICE_NAME: NvpnService\n        TYPE               : 10  WIN32_OWN_PROCESS\n        STATE              : 4  RUNNING\n                                (STOPPABLE, NOT_PAUSABLE, ACCEPTS_SHUTDOWN)\n        WIN32_EXIT_CODE    : 0  (0x0)\n        SERVICE_EXIT_CODE  : 0  (0x0)\n        CHECKPOINT         : 0x0\n        WAIT_HINT          : 0x0\n        PID                : 1234\n        FLAGS              :\n";
    let (running, pid) = windows_service_status_from_query_output(query);
    assert!(running);
    assert_eq!(pid, Some(1234));
}

#[test]
fn windows_service_config_parser_extracts_disabled_state() {
    let query = "SERVICE_NAME: NvpnService\n        TYPE               : 10  WIN32_OWN_PROCESS\n        START_TYPE         : 4   DISABLED\n        ERROR_CONTROL      : 1   NORMAL\n        BINARY_PATH_NAME   : \"C:\\Program Files\\Nostr VPN\\nvpn.exe\" daemon --service --config \"C:\\Users\\sirius\\AppData\\Roaming\\nvpn\\config.toml\"\n";
    assert!(windows_service_disabled_from_qc_output(query));

    let auto_start = "SERVICE_NAME: NvpnService\n        TYPE               : 10  WIN32_OWN_PROCESS\n        START_TYPE         : 2   AUTO_START\n";
    assert!(!windows_service_disabled_from_qc_output(auto_start));
}

#[test]
fn windows_service_config_parser_extracts_binary_path() {
    let query = "SERVICE_NAME: NvpnService\n        TYPE               : 10  WIN32_OWN_PROCESS\n        START_TYPE         : 2   AUTO_START\n        BINARY_PATH_NAME   : \"C:\\Program Files\\Nostr VPN\\nvpn.exe\" daemon --service --config \"C:\\Users\\sirius\\AppData\\Roaming\\nvpn\\config.toml\"\n";
    assert_eq!(
        windows_service_binary_path_from_sc_qc_output(query),
        Some(Path::new(r"C:\Program Files\Nostr VPN\nvpn.exe").to_path_buf())
    );
}

#[test]
fn windows_apply_config_uses_installed_enabled_service() {
    let enabled_service = ServiceStatusView {
        supported: true,
        installed: true,
        disabled: false,
        loaded: true,
        running: false,
        pid: None,
        label: "NvpnService".to_string(),
        plist_path: "NvpnService".to_string(),
        binary_path: r"C:\Program Files\Nostr VPN\nvpn.exe".to_string(),
        binary_version: env!("CARGO_PKG_VERSION").to_string(),
    };
    assert!(windows_should_apply_config_via_service(&enabled_service));

    let disabled_service = ServiceStatusView {
        disabled: true,
        ..enabled_service.clone()
    };
    assert!(!windows_should_apply_config_via_service(&disabled_service));

    let missing_service = ServiceStatusView {
        installed: false,
        loaded: false,
        ..enabled_service
    };
    assert!(!windows_should_apply_config_via_service(&missing_service));
}