nvpn 4.0.86

CLI and daemon for Nostr VPN private mesh networks
#[test]
fn apply_config_file_writes_target_config() {
    let nonce = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .expect("clock is after epoch")
        .as_nanos();
    let dir = std::env::temp_dir().join(format!("nvpn-apply-config-test-{nonce}"));
    fs::create_dir_all(&dir).expect("create temp dir");

    let source = dir.join("source.toml");
    let target = dir.join("target.toml");
    let mut config = AppConfig::generated();
    activate_first_network(&mut config);
    config.node_name = "windows-box".to_string();
    config.networks[0].devices = vec!["ab".repeat(32)];
    config.save(&source).expect("save source config");

    apply_config_file(&source, &target).expect("apply config should succeed");

    let loaded = AppConfig::load(&target).expect("load target config");
    assert_eq!(loaded.node_name, "windows-box");
    assert_eq!(loaded.participant_pubkeys_hex(), vec!["ab".repeat(32)]);

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

#[test]
fn load_or_default_config_migrates_plaintext_config_secrets() {
    let nonce = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .expect("clock is after epoch")
        .as_nanos();
    let dir = std::env::temp_dir().join(format!("nvpn-load-config-secrets-{nonce}"));
    fs::create_dir_all(&dir).expect("create temp dir");
    let path = dir.join("config.toml");
    let mut config = AppConfig::generated();
    config.wireguard_exit.private_key = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=".to_string();
    config.wireguard_exit.peer_public_key =
        "AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI=".to_string();
    config.wireguard_exit.peer_preshared_key =
        "AwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwM=".to_string();
    let nostr_secret = config.nostr.secret_key.clone();
    let wireguard_private_key = config.wireguard_exit.private_key.clone();
    let wireguard_peer_preshared_key = config.wireguard_exit.peer_preshared_key.clone();
    fs::write(
        &path,
        config.plaintext_toml().expect("encode plaintext config"),
    )
    .expect("write plaintext config");

    let loaded = load_or_default_config(&path).expect("load config");
    let raw = fs::read_to_string(&path).expect("read migrated config");
    AppConfig::delete_persisted_secrets_for_path(&path).expect("delete migrated secrets");

    assert_eq!(loaded.nostr.secret_key, nostr_secret);
    assert_eq!(loaded.wireguard_exit.private_key, wireguard_private_key);
    assert_eq!(
        loaded.wireguard_exit.peer_preshared_key,
        wireguard_peer_preshared_key
    );
    assert!(!raw.contains(&nostr_secret));
    assert!(!raw.contains(&wireguard_private_key));
    assert!(!raw.contains(&wireguard_peer_preshared_key));

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

#[test]
fn stage_daemon_config_apply_writes_staged_file() {
    let nonce = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .expect("clock is after epoch")
        .as_nanos();
    let dir = std::env::temp_dir().join(format!("nvpn-stage-config-test-{nonce}"));
    fs::create_dir_all(&dir).expect("create temp dir");

    let source = dir.join("source.toml");
    let target = dir.join("config.toml");
    let mut config = AppConfig::generated();
    config.node_name = "staged-node".to_string();
    config.save(&source).expect("save source config");

    stage_daemon_config_apply(&target, &source).expect("stage config should succeed");

    let staged = daemon_staged_config_file_path(&target);
    let loaded = AppConfig::load(&staged).expect("load staged config");
    assert_eq!(loaded.node_name, "staged-node");

    AppConfig::delete_persisted_secrets_for_path(&source).expect("delete source secrets");
    AppConfig::delete_persisted_secrets_for_path(&staged).expect("delete staged secrets");
    let _ = fs::remove_dir_all(&dir);
}

#[test]
fn update_daemon_config_from_staged_request_replaces_target_and_cleans_up() {
    let nonce = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .expect("clock is after epoch")
        .as_nanos();
    let dir = std::env::temp_dir().join(format!("nvpn-stage-apply-test-{nonce}"));
    fs::create_dir_all(&dir).expect("create temp dir");

    let source = dir.join("source.toml");
    let target = dir.join("config.toml");
    let mut source_config = AppConfig::generated();
    source_config.node_name = "service-owned".to_string();
    source_config.save(&source).expect("save source config");

    let mut target_config = AppConfig::generated();
    target_config.node_name = "old-name".to_string();
    target_config.save(&target).expect("save target config");

    stage_daemon_config_apply(&target, &source).expect("stage config should succeed");
    update_daemon_config_from_staged_request(&target).expect("apply staged config");

    let loaded = AppConfig::load(&target).expect("load target config");
    assert_eq!(loaded.node_name, "service-owned");
    assert!(
        !daemon_staged_config_file_path(&target).exists(),
        "staged config should be cleaned up"
    );

    AppConfig::delete_persisted_secrets_for_path(&source).expect("delete source secrets");
    AppConfig::delete_persisted_secrets_for_path(&target).expect("delete target secrets");
    let _ = fs::remove_dir_all(&dir);
}

#[test]
fn kill_error_fallback_matcher_detects_permission_denied() {
    assert!(kill_error_requires_control_fallback(
        "kill -TERM 123 failed\nstderr: Operation not permitted"
    ));
    assert!(kill_error_requires_control_fallback(
        "kill -TERM 123 failed\nstderr: permission denied"
    ));
    assert!(!kill_error_requires_control_fallback(
        "kill -TERM 123 failed\nstderr: no such process"
    ));
}

#[test]
fn daemon_control_stop_request_roundtrip() {
    let nonce = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .expect("clock is after epoch")
        .as_nanos();
    let dir = std::env::temp_dir().join(format!("nvpn-control-test-{nonce}"));
    fs::create_dir_all(&dir).expect("create temp dir");
    let config = dir.join("config.toml");
    fs::write(&config, "node_name = \"test\"").expect("write config");

    request_daemon_stop(&config).expect("write stop request");
    assert!(
        take_daemon_control_request(&config) == Some(crate::DaemonControlRequest::Stop),
        "daemon should read stop request"
    );
    request_daemon_reload(&config).expect("write reload request");
    assert!(
        take_daemon_control_request(&config) == Some(crate::DaemonControlRequest::Reload),
        "daemon should read reload request"
    );
    control_daemon_request_for_test(&config, crate::DaemonControlRequest::Pause);
    assert!(
        take_daemon_control_request(&config) == Some(crate::DaemonControlRequest::Pause),
        "daemon should read pause request"
    );
    control_daemon_request_for_test(&config, crate::DaemonControlRequest::Resume);
    assert!(
        take_daemon_control_request(&config) == Some(crate::DaemonControlRequest::Resume),
        "daemon should read resume request"
    );
    let _ = fs::remove_file(daemon_control_file_path(&config));
    assert!(
        take_daemon_control_request(&config).is_none(),
        "without control file there should be no stop request"
    );

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

#[test]
fn daemon_control_timeout_errors_use_generic_service_wording() {
    let nonce = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .expect("clock is after epoch")
        .as_nanos();
    let dir = std::env::temp_dir().join(format!("nvpn-control-timeout-test-{nonce}"));
    fs::create_dir_all(&dir).expect("create temp dir");
    let config = dir.join("config.toml");
    fs::write(&config, "node_name = \"test\"").expect("write config");

    let ack_error = crate::wait_for_daemon_control_ack(&config, Duration::from_millis(0))
        .expect_err("ack wait should time out");
    assert!(
        ack_error
            .to_string()
            .contains("background service may be busy or stuck")
    );
    assert!(!ack_error.to_string().contains("newer nvpn binary"));

    let result_error = crate::wait_for_daemon_control_result(
        &config,
        crate::DaemonControlRequest::Reload,
        Duration::from_millis(0),
    )
    .expect_err("result wait should time out");
    assert!(
        result_error
            .to_string()
            .contains("background service may be busy or stuck")
    );
    assert!(!result_error.to_string().contains("newer nvpn binary"));

    let vpn_error = crate::wait_for_daemon_vpn_enabled(&config, true, Duration::from_millis(0))
        .expect_err("vpn wait should time out");
    assert!(
        vpn_error
            .to_string()
            .contains("background service may be busy or stuck")
    );
    assert!(
        !vpn_error
            .to_string()
            .contains("older nvpn daemon binary is still running")
    );

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

#[test]
fn daemon_control_wait_timeouts_allow_longer_mac_recovery_windows() {
    assert_eq!(
        crate::daemon_control_ack_timeout(crate::DaemonControlRequest::Reload),
        Duration::from_secs(10)
    );
    assert_eq!(
        crate::daemon_control_result_timeout(crate::DaemonControlRequest::Reload),
        Duration::from_secs(15)
    );
    assert_eq!(
        crate::daemon_control_vpn_transition_timeout(crate::DaemonControlRequest::Reload),
        Duration::ZERO
    );

    if cfg!(target_os = "macos") {
        assert_eq!(
            crate::daemon_control_ack_timeout(crate::DaemonControlRequest::Resume),
            Duration::from_secs(15)
        );
        assert_eq!(
            crate::daemon_control_result_timeout(crate::DaemonControlRequest::Resume),
            Duration::from_secs(30)
        );
        assert_eq!(
            crate::daemon_control_vpn_transition_timeout(crate::DaemonControlRequest::Resume),
            Duration::from_secs(30)
        );
    } else {
        assert_eq!(
            crate::daemon_control_ack_timeout(crate::DaemonControlRequest::Resume),
            Duration::from_secs(10)
        );
        assert_eq!(
            crate::daemon_control_result_timeout(crate::DaemonControlRequest::Resume),
            Duration::from_secs(15)
        );
        assert_eq!(
            crate::daemon_control_vpn_transition_timeout(crate::DaemonControlRequest::Resume),
            Duration::from_secs(2)
        );
    }
}