nvpn 4.1.15

CLI and daemon for Nostr VPN private mesh networks
use std::collections::BTreeMap;
use std::ffi::OsString;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};

use nostr_vpn_core::config::AppConfig;

#[test]
fn status_commands_do_not_migrate_legacy_config_or_create_sidecars() {
    let dir = TestDir::new();
    let config_path = dir.path().join("legacy.toml");
    let config = AppConfig::generated();
    fs::write(
        &config_path,
        config.plaintext_toml().expect("encode legacy config"),
    )
    .expect("write legacy config");
    assert!(
        AppConfig::config_file_needs_secret_migration(&config_path).expect("inspect legacy config")
    );
    let before = directory_snapshot(dir.path());
    let config_arg = config_path.to_str().expect("utf8 config path");

    for args in [
        vec![
            "status",
            "--config",
            config_arg,
            "--discover-secs",
            "0",
            "--json",
        ],
        vec!["paid-exit", "status", "--config", config_arg, "--json"],
    ] {
        let output = Command::new(env!("CARGO_BIN_EXE_nvpn"))
            .args(&args)
            .output()
            .expect("run nvpn status command");

        assert!(
            output.status.success(),
            "nvpn {args:?} failed\nstdout:\n{}\nstderr:\n{}",
            String::from_utf8_lossy(&output.stdout),
            String::from_utf8_lossy(&output.stderr)
        );
        assert_eq!(directory_snapshot(dir.path()), before, "nvpn {args:?}");
    }
}

#[test]
fn status_reports_fips_connections_while_vpn_is_paused() {
    let dir = TestDir::new();
    let config_path = dir.path().join("config.toml");
    fs::write(
        &config_path,
        AppConfig::generated()
            .plaintext_toml()
            .expect("encode config"),
    )
    .expect("write config");
    let state_path = dir.path().join("daemon.state.json");
    let mut state = serde_json::json!({
        "updated_at": SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(),
        "vpn_enabled": false,
        "vpn_active": false,
        "vpn_status": "Paused",
        "expected_peer_count": 0,
        "connected_peer_count": 0,
        "fips_direct_roster_peer_count": 2,
        "fips_other_peer_count": 3,
        "mesh_ready": false,
    });
    fs::write(&state_path, serde_json::to_vec(&state).unwrap()).unwrap();
    let status = |json: bool| {
        let mut command = Command::new(env!("CARGO_BIN_EXE_nvpn"));
        command
            .args(["status", "--discover-secs", "0", "--config"])
            .arg(&config_path)
            .env("NVPN_DAEMON_STATUS_MODE", "state-file")
            .env("NVPN_DAEMON_STATE_RUNNING_MAX_AGE_SECS", "60");
        if json {
            command.arg("--json");
        }
        let output = command.output().expect("run status");
        assert!(
            output.status.success(),
            "{}",
            String::from_utf8_lossy(&output.stderr)
        );
        String::from_utf8(output.stdout).expect("status is UTF-8")
    };
    let output: serde_json::Value = serde_json::from_str(&status(true)).unwrap();
    assert_eq!(output["daemon"]["running"], true);
    assert_eq!(
        output["daemon"]["state"]["fips_direct_roster_peer_count"],
        2
    );
    assert_eq!(output["daemon"]["state"]["fips_other_peer_count"], 3);
    assert_eq!(output["peer_count"], 0);
    assert_eq!(output["mesh_ready"], false);
    let text = status(false);
    assert!(text.contains("vpn_status: Paused"), "{text}");
    assert!(
        text.contains("fips_peers: 5 connected (2 direct roster, 3 other)"),
        "{text}"
    );

    state["updated_at"] = 0.into();
    fs::write(&state_path, serde_json::to_vec(&state).unwrap()).unwrap();
    let text = status(false);
    assert!(text.contains("daemon: stopped"));
    assert!(
        !text.lines().any(|line| line.starts_with("fips_peers:")),
        "stopped daemon must not report stale links"
    );
}

fn directory_snapshot(path: &Path) -> BTreeMap<OsString, Vec<u8>> {
    fs::read_dir(path)
        .expect("read test directory")
        .map(|entry| {
            let entry = entry.expect("read directory entry");
            let name = entry.file_name();
            let contents = fs::read(entry.path()).expect("read directory entry contents");
            (name, contents)
        })
        .collect()
}

struct TestDir {
    path: PathBuf,
}

impl TestDir {
    fn new() -> Self {
        let nonce = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .expect("clock is after epoch")
            .as_nanos();
        let path = std::env::temp_dir().join(format!(
            "nvpn-status-readonly-{}-{nonce}",
            std::process::id()
        ));
        fs::create_dir_all(&path).expect("create test directory");
        Self { path }
    }

    fn path(&self) -> &Path {
        &self.path
    }
}

impl Drop for TestDir {
    fn drop(&mut self) {
        let _ = fs::remove_dir_all(&self.path);
    }
}