use proxywarden_lib::adapters::proxifyre::ProxiFyreAdapter; use proxywarden_lib::commands::{ self, apply_profiles_with_services, apply_profiles_with_services_and_detection, build_status, read_saved_state, resolve_component_statuses, resolve_preview, save_profile_to_storage, save_target_to_storage, Clock, CommandError, DetectedProxyApplyHelper, HelperApplyRequest, HelperApplyResult, ProfileInputDto, ProfileItemInputDto, ProxyApplyHelper, TargetInputDto, }; use proxywarden_lib::component_detection::{ DetectedProxyfier, ProxyfierDetectionHost, ProxyfierEngine, RegistryInstallEntry, }; use proxywarden_lib::models::{ self, ComponentId, ComponentState, Profile, ProfileItem, ProfileItemType, Protocol, ProxyProtocol, Target, TargetKind, }; #[cfg(windows)] use proxywarden_lib::safe_fs; use proxywarden_lib::storage::JsonStorage; use std::collections::{HashMap, HashSet}; use std::fs; use std::net::TcpListener; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; #[test] fn save_commands_normalize_and_persist_profile_and_target() { let root = test_root("save"); let storage = JsonStorage::new(root.clone()); let target = save_target_to_storage( &storage, TargetInputDto { id: Some("Home Gateway".to_string()), name: " Home Gateway ".to_string(), kind: Some("external".to_string()), protocol: Some("socks5".to_string()), host: " 192.168.50.111 ".to_string(), port: 8080, requires_component: None, }, ) .expect("target command should normalize"); let profile = save_profile_to_storage( &storage, ProfileInputDto { id: Some("Discord".to_string()), name: " Discord ".to_string(), enabled: Some(true), target_id: Some("home-gateway".to_string()), protocols: Some(vec!["tcp".to_string(), "UDP".to_string()]), items: Some(vec![ProfileItemInputDto { item_type: "process".to_string(), value: "Discord.exe".to_string(), recursive: None, }]), }, ) .expect("profile command should normalize"); assert_eq!(target.id, "home-gateway"); assert_eq!(profile.id, "discord"); assert_eq!(profile.target_id, "home-gateway"); assert_eq!(profile.items[0].value, "Discord"); let status = build_status(&storage).expect("status command should read stored state"); assert_eq!( status.route_line, "Выбранные приложения -> ProxiFyre -> внешний прокси 192.168.50.111:8080" ); assert_eq!(status.active_profile_count, 1); assert_eq!(status.routed_app_count, 1); cleanup(&root); } #[test] fn saved_state_read_never_opportunistically_imports_proxifyre_config() { let root = test_root("proxifyre-config-import"); let storage = JsonStorage::new(root.clone()); let install_dir = root.join("ProxiFyre"); let config_path = install_dir.join("app-config.json"); fs::create_dir_all(&install_dir).expect("create proxifyre dir"); fs::write( &config_path, r#"{ "logLevel": "Info", "bypassLan": true, "proxies": [ { "appNames": ["Discord.exe", "C:\\Games\\Launcher.exe"], "socks5ProxyEndpoint": "127.0.0.1:1090", "supportedProtocols": ["TCP", "UDP"] } ] }"#, ) .expect("write proxifyre config"); let source_before = fs::read(&config_path).expect("read proxifyre config before normal read"); let state = read_saved_state(&storage).expect("normal read should ignore legacy runtime config"); assert!(state.profiles.is_empty()); assert!(state.targets.is_empty()); assert!(!storage.paths().profiles_file.exists()); assert!(!storage.paths().targets_file.exists()); assert_eq!( fs::read(&config_path).expect("read proxifyre config after normal read"), source_before ); cleanup(&root); } #[test] fn saved_state_keeps_existing_proxywarden_profiles_over_proxifyre_config() { let root = test_root("proxifyre-config-keeps-state"); let storage = JsonStorage::new(root.clone()); let install_dir = root.join("ProxiFyre"); let config_path = install_dir.join("app-config.json"); fs::create_dir_all(&install_dir).expect("create proxifyre dir"); fs::write( &config_path, r#"{ "logLevel": "Info", "bypassLan": true, "proxies": [ { "appNames": ["Telegram.exe"], "socks5ProxyEndpoint": "127.0.0.1:1091", "supportedProtocols": ["TCP"] } ] }"#, ) .expect("write proxifyre config"); storage .write_profiles(&[discord_profile("home-gateway")]) .expect("write profiles"); storage .write_targets(&[external_socks5_target()]) .expect("write targets"); let state = read_saved_state(&storage).expect("state should keep proxywarden storage"); assert_eq!(state.profiles.len(), 1); assert_eq!(state.profiles[0].id, "discord"); assert_eq!(state.profiles[0].items[0].value, "Discord"); assert_eq!(state.targets[0].id, "home-gateway"); cleanup(&root); } #[test] fn resolve_preview_returns_structured_apps_without_filesystem_scan() { let preview = resolve_preview(ProfileInputDto { id: Some("Game".to_string()), name: "Game".to_string(), enabled: Some(true), target_id: Some("home-gateway".to_string()), protocols: Some(vec!["TCP".to_string()]), items: Some(vec![ ProfileItemInputDto { item_type: "process".to_string(), value: "Discord.exe".to_string(), recursive: None, }, ProfileItemInputDto { item_type: "folder".to_string(), value: r"C:\Games\Launcher".to_string(), recursive: Some(true), }, ]), }) .expect("preview should normalize profile input"); assert_eq!(preview.profile_id, "game"); assert_eq!(preview.apps.len(), 2); assert_eq!(preview.apps[0].app_name, "Discord"); assert_eq!(preview.apps[1].source_type, ProfileItemType::Folder); assert!(preview .warnings .iter() .any(|warning| warning.contains("Сканирование папок отложено"))); } #[test] fn ping_proxy_target_reports_open_tcp_endpoint() { let listener = TcpListener::bind("127.0.0.1:0").expect("bind local listener"); let port = listener.local_addr().expect("read local addr").port(); let result = commands::ping_proxy_target_endpoint_with_probes( commands::PingProxyTargetInputDto { host: "127.0.0.1".to_string(), port, }, &[], ) .expect("ping should return response"); assert_eq!(result.tag, "route-proxy"); assert_eq!(result.server, "127.0.0.1"); assert_eq!(result.server_port, port); assert!(result.ok); assert!(result.latency.is_some()); assert!(result.probes.is_empty()); } #[test] fn apply_generates_derived_config_and_records_activity_with_mock_helper() { let root = test_root("apply"); let storage = JsonStorage::new(root.clone()); storage .write_profiles(&[discord_profile("home-gateway")]) .expect("write profiles"); storage .write_targets(&[external_socks5_target()]) .expect("write targets"); let response = apply_profiles_with_services( &storage, &ProxiFyreAdapter::default(), &MockApplyHelper, &FixedClock, ) .expect("apply command should generate config and call helper"); let generated_path = PathBuf::from(&response.generated_config_path); let generated_contents = fs::read_to_string(&generated_path).expect("read generated config"); let activity = storage.read_activity().expect("read activity"); assert!(response.success); assert!(response.changed); assert_eq!(response.adapter_id, "proxifyre"); assert_eq!(response.enabled_profiles, 1); assert_eq!(response.routed_apps, 1); assert_eq!(response.helper.action, "proxyfier.apply.mock"); assert!(generated_contents.contains("\"appNames\"")); assert!(generated_contents.contains("Discord")); assert!(generated_path.ends_with("proxifyre-app-config.json")); #[cfg(windows)] safe_fs::verify_path_protected_for_owner_admin_system(&generated_path) .expect("generated ProxiFyre config keeps restricted ACL"); assert_eq!(activity.len(), 1); assert_eq!(activity[0].at, "2026-07-03T00:00:00Z"); assert_eq!(activity[0].title, "Конфиг ProxiFyre создан"); cleanup(&root); } #[test] fn apply_blocks_local_singbox_target_when_component_is_missing() { let root = test_root("missing-singbox"); let storage = JsonStorage::new(root.clone()); storage .write_profiles(&[discord_profile("local-singbox")]) .expect("write profiles"); storage .write_targets(&[local_singbox_target()]) .expect("write targets"); let error = apply_profiles_with_services_and_detection( &storage, &ProxiFyreAdapter::default(), &MockApplyHelper, &FixedClock, None, None, ) .expect_err("missing sing-box should block local target apply"); let activity = storage.read_activity().expect("read blocked activity"); assert_eq!(error.code, "required_component_not_running"); assert_eq!(activity.len(), 1); assert_eq!(activity[0].level, models::ActivityLevel::Error); assert_eq!(activity[0].title, "Применение ProxiFyre заблокировано"); cleanup(&root); } #[test] fn component_status_merges_detected_existing_proxifyre() { let components = resolve_component_statuses( Some(DetectedProxyfier { engine: ProxyfierEngine::ProxiFyre, name: "ProxiFyre".to_string(), install_dir: PathBuf::from(r"C:\Tools\ProxiFyre"), executable_path: PathBuf::from(r"C:\Tools\ProxiFyre\ProxiFyre.exe"), config_path: Some(PathBuf::from(r"C:\Tools\ProxiFyre\app-config.json")), running: true, service_name: Some("ProxiFyreService".to_string()), service_status: Some("running".to_string()), version: Some("2.2.1.0".to_string()), }), None, ); let proxyfier = components .iter() .find(|component| component.id == ComponentId::Proxyfier) .expect("proxyfier component"); assert_eq!(proxyfier.state, ComponentState::Running); assert!(proxyfier.installed); assert!(proxyfier.running); assert_eq!(proxyfier.path, Some(r"C:\Tools\ProxiFyre".to_string())); assert!(proxyfier.problems.is_empty()); } #[test] fn component_status_reports_missing_when_detection_is_missing() { let components = resolve_component_statuses(None, None); let proxyfier = components .iter() .find(|component| component.id == ComponentId::Proxyfier) .expect("proxyfier component"); assert_eq!(proxyfier.state, ComponentState::Missing); assert!(!proxyfier.installed); assert!(!proxyfier.running); assert_eq!(proxyfier.path, None); } #[test] fn managed_current_apply_stages_generated_config_without_writing_sealed_runtime_snapshot() { let root = test_root("detected-proxifyre"); let install_dir = root.join("ProxiFyre"); fs::create_dir_all(&install_dir).expect("install dir"); fs::write(install_dir.join("ProxiFyre.exe"), "mock exe").expect("mock exe"); fs::write(install_dir.join("app-config.json"), "{}").expect("existing config"); fs::write( install_dir.join("proxywarden-component.json"), serde_json::to_vec_pretty(&serde_json::json!({ "manager": "ProxyWarden", "component": "proxifyre", "serviceName": "ProxiFyreService", "installRoot": install_dir.display().to_string(), "packetFilterInstalledByProxyWarden": false })) .expect("marker JSON"), ) .expect("managed marker"); let generated_config = root.join("generated").join("proxifyre-app-config.json"); let host = DetectionHost::new() .with_registry("ProxiFyre", &install_dir) .with_path(&install_dir) .with_path(&install_dir.join("ProxiFyre.exe")) .with_service_path( "ProxiFyreService", &format!( r#""{}" --service"#, install_dir.join("ProxiFyre.exe").display() ), ); let helper = DetectedProxyApplyHelper::with_current_root(host, install_dir.clone()); let result = helper .apply_proxy_config(HelperApplyRequest { adapter_id: "proxifyre", config_path: &generated_config, config_contents: r#"{"proxies":[]}"#, }) .expect("detected helper should apply"); assert!(result.success); assert!(result.changed); assert_eq!(result.action, "proxifyre.stage-managed-config"); assert_eq!( fs::read_to_string(install_dir.join("app-config.json")) .expect("read unchanged runtime snapshot"), "{}" ); assert!(!install_dir.join("app-config.json.bak").exists()); assert!(result.message.contains("следующем явном запуске")); cleanup(&root); } #[test] fn detected_proxy_apply_helper_does_not_write_for_foreign_service_collision() { let root = test_root("detected-proxifyre-foreign-service"); let install_dir = root.join("ProxiFyre"); let config_path = install_dir.join("app-config.json"); fs::create_dir_all(&install_dir).expect("install dir"); fs::write(install_dir.join("ProxiFyre.exe"), "mock exe").expect("mock exe"); fs::write(&config_path, "original").expect("existing config"); fs::write( install_dir.join("proxywarden-component.json"), serde_json::to_vec_pretty(&serde_json::json!({ "manager": "ProxyWarden", "component": "proxifyre", "serviceName": "ProxiFyreService", "installRoot": install_dir.display().to_string(), "packetFilterInstalledByProxyWarden": false })) .expect("marker JSON"), ) .expect("managed marker"); let generated_config = root.join("generated").join("proxifyre-app-config.json"); let host = DetectionHost::new() .with_path(&install_dir) .with_path(&install_dir.join("ProxiFyre.exe")) .with_service_path( "ProxiFyreService", r#""C:\Foreign\ProxiFyre.exe" --service"#, ); let helper = DetectedProxyApplyHelper::with_current_root(host, install_dir.clone()); let error = helper .apply_proxy_config(HelperApplyRequest { adapter_id: "proxifyre", config_path: &generated_config, config_contents: r#"{"proxies":[]}"#, }) .expect_err("foreign service collision must fail before config write"); assert_eq!(error.code, "ownership_mismatch"); assert_eq!(fs::read_to_string(&config_path).unwrap(), "original"); assert!(!install_dir.join("app-config.json.bak").exists()); cleanup(&root); } #[test] fn detected_proxy_apply_helper_ignores_plain_proxifier_install() { let root = test_root("detected-proxifier"); let install_dir = root.join("Proxifier"); fs::create_dir_all(&install_dir).expect("install dir"); fs::write(install_dir.join("Proxifier.exe"), "mock exe").expect("mock exe"); let generated_config = root.join("generated").join("proxifyre-app-config.json"); let host = DetectionHost::new() .with_registry("Proxifier", &install_dir) .with_path(&install_dir) .with_path(&install_dir.join("Proxifier.exe")); let helper = DetectedProxyApplyHelper::from(host); let result = helper .apply_proxy_config(HelperApplyRequest { adapter_id: "proxifyre", config_path: &generated_config, config_contents: r#"{"proxies":[]}"#, }) .expect("plain Proxifier should be ignored and config should be staged"); assert!(result.success); assert!(result.changed); assert_eq!(result.action, "proxifyre.stage-generated-config"); assert!(result .message .contains("совместимая установка ProxiFyre не найдена")); cleanup(&root); } struct MockApplyHelper; impl ProxyApplyHelper for MockApplyHelper { fn apply_proxy_config( &self, request: HelperApplyRequest<'_>, ) -> Result { assert_eq!(request.adapter_id, "proxifyre"); assert!(request.config_contents.contains("Discord")); assert!(request.config_path.ends_with("proxifyre-app-config.json")); Ok(HelperApplyResult { success: true, changed: true, action: "proxyfier.apply.mock".to_string(), message: "Mock helper accepted generated ProxiFyre config".to_string(), }) } } struct FixedClock; impl Clock for FixedClock { fn now(&self) -> String { "2026-07-03T00:00:00Z".to_string() } } #[derive(Default)] struct DetectionHost { paths: HashSet, registry: Vec, service_paths: HashMap, } impl DetectionHost { fn new() -> Self { Self::default() } fn with_path(mut self, path: &Path) -> Self { self.paths.insert(normalize_path(path)); self } fn with_registry(mut self, display_name: &str, install_location: &Path) -> Self { self.registry.push(RegistryInstallEntry { display_name: display_name.to_string(), install_location: Some(install_location.to_path_buf()), display_icon: None, }); self } fn with_service_path(mut self, service_name: &str, path_name: &str) -> Self { self.service_paths .insert(service_name.to_ascii_lowercase(), path_name.to_string()); self } } impl ProxyfierDetectionHost for DetectionHost { fn env_var(&self, _name: &str) -> Option { None } fn path_exists(&self, path: &Path) -> bool { self.paths.contains(&normalize_path(path)) } fn process_running(&self, _process_name: &str) -> bool { false } fn service_status(&self, _service_name: &str) -> Option { self.service_paths .contains_key(&_service_name.to_ascii_lowercase()) .then(|| "stopped".to_string()) } fn service_info( &self, service_name: &str, ) -> Option { self.service_paths .get(&service_name.to_ascii_lowercase()) .map( |path_name| proxywarden_lib::component_detection::DetectedService { name: service_name.to_string(), status: "stopped".to_string(), path_name: Some(path_name.clone()), }, ) } fn registry_install_entries(&self) -> Vec { self.registry.clone() } fn read_text(&self, path: &Path) -> Option { fs::read_to_string(path).ok() } } fn normalize_path(path: &Path) -> String { path.display() .to_string() .replace('/', "\\") .to_ascii_lowercase() } fn test_root(name: &str) -> PathBuf { let timestamp = SystemTime::now() .duration_since(UNIX_EPOCH) .expect("system clock before unix epoch") .as_nanos(); std::env::temp_dir().join(format!("proxywarden-commands-{name}-{timestamp}")) } fn cleanup(root: &Path) { let _ = fs::remove_dir_all(root); } fn discord_profile(target_id: &str) -> Profile { Profile { id: "discord".to_string(), name: "Discord".to_string(), enabled: true, target_id: target_id.to_string(), protocols: vec![Protocol::Tcp, Protocol::Udp], items: vec![ProfileItem { item_type: ProfileItemType::Process, value: "Discord".to_string(), recursive: false, }], } } fn external_socks5_target() -> Target { Target { id: "home-gateway".to_string(), name: "Домашний шлюз".to_string(), kind: TargetKind::External, protocol: ProxyProtocol::Socks5, host: "192.168.50.111".to_string(), port: 8080, requires_component: None, } } fn local_singbox_target() -> Target { Target { id: "local-singbox".to_string(), name: "Локальный sing-box".to_string(), kind: TargetKind::Local, protocol: ProxyProtocol::Socks5, host: "127.0.0.1".to_string(), port: 1080, requires_component: Some(ComponentId::Singbox), } }