Files
ProxyWarden/src-tauri/tests/command_tests.rs
T
dokril efda8eb98f
CI / Windows baseline (push) Canceled after 0s
Release v2.0.0
2026-09-10 20:59:52 +03:00

613 lines
20 KiB
Rust

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<HelperApplyResult, CommandError> {
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<String>,
registry: Vec<RegistryInstallEntry>,
service_paths: HashMap<String, String>,
}
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<String> {
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<String> {
self.service_paths
.contains_key(&_service_name.to_ascii_lowercase())
.then(|| "stopped".to_string())
}
fn service_info(
&self,
service_name: &str,
) -> Option<proxywarden_lib::component_detection::DetectedService> {
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<RegistryInstallEntry> {
self.registry.clone()
}
fn read_text(&self, path: &Path) -> Option<String> {
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),
}
}