Clarify active Windows client architecture

This commit is contained in:
2026-07-07 21:19:41 +03:00
parent a0f41baa36
commit 59f2264a2e
55 changed files with 19554 additions and 8 deletions

View File

@@ -0,0 +1,442 @@
#[path = "../src/activity.rs"]
mod activity;
#[path = "../src/component_detection.rs"]
mod component_detection;
#[path = "../src/commands.rs"]
mod commands;
#[path = "../src/models.rs"]
mod models;
#[path = "../src/adapters/proxifyre.rs"]
mod proxifyre;
#[path = "../src/adapters/proxy_router.rs"]
mod proxy_router;
#[path = "../src/storage.rs"]
mod storage;
#[path = "../src/validation.rs"]
mod validation;
use commands::{
apply_profiles_with_services, build_status, resolve_component_statuses, resolve_preview,
save_profile_to_storage, save_target_to_storage, Clock, CommandError,
DetectedProxyApplyHelper, HelperApplyRequest, HelperApplyResult, ProfileInputDto,
ProfileItemInputDto, ProxyApplyHelper, TargetInputDto,
};
use component_detection::{
DetectedProxyfier, ProxyfierDetectionHost, ProxyfierEngine, RegistryInstallEntry,
};
use models::{
ComponentId, ComponentState, ComponentStatus, Profile, ProfileItem, ProfileItemType, Protocol,
ProxyProtocol, Target, TargetKind,
};
use proxifyre::ProxiFyreAdapter;
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use storage::JsonStorage;
#[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 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 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");
storage
.write_components(&[proxyfier_running(), singbox_missing()])
.expect("write components");
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"));
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");
storage
.write_components(&[singbox_missing()])
.expect("write components");
let error = apply_profiles_with_services(
&storage,
&ProxiFyreAdapter::default(),
&MockApplyHelper,
&FixedClock,
)
.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(
Vec::new(),
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()),
}),
);
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 detected_proxy_apply_helper_writes_proxifyre_app_config() {
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");
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"));
let helper = DetectedProxyApplyHelper::new(host);
let result = helper
.apply_proxy_config(HelperApplyRequest {
adapter_id: "proxifyre",
config_path: &generated_config,
config_contents: r#"{"proxies":[]}"#,
})
.expect("detected helper should apply");
let applied = fs::read_to_string(install_dir.join("app-config.json"))
.expect("read applied app-config");
assert!(result.success);
assert!(result.changed);
assert_eq!(result.action, "proxifyre.apply-detected-config");
assert_eq!(applied, r#"{"proxies":[]}"#);
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::new(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>,
}
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
}
}
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_running(&self, _service_name: &str) -> bool {
false
}
fn registry_install_entries(&self) -> Vec<RegistryInstallEntry> {
self.registry.clone()
}
}
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!("vpn-proxy-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),
}
}
fn proxyfier_running() -> ComponentStatus {
ComponentStatus {
id: ComponentId::Proxyfier,
name: "ProxiFyre".to_string(),
state: ComponentState::Running,
installed: true,
running: true,
version: Some("2.2.1".to_string()),
path: Some(r"C:\Tools\ProxiFyre".to_string()),
problems: Vec::new(),
actions: vec!["Restart".to_string()],
}
}
fn singbox_missing() -> ComponentStatus {
ComponentStatus {
id: ComponentId::Singbox,
name: "Локальный sing-box".to_string(),
state: ComponentState::Missing,
installed: false,
running: false,
version: None,
path: None,
problems: vec!["Локальный sing-box не установлен".to_string()],
actions: vec!["Установить локальный sing-box".to_string()],
}
}

View File

@@ -0,0 +1,141 @@
#[path = "../src/component_detection.rs"]
mod component_detection;
#[path = "../src/models.rs"]
mod models;
use component_detection::{
detect_proxyfier_install_with_host, proxyfier_component_from_detection, ProxyfierDetectionHost,
ProxyfierEngine, RegistryInstallEntry,
};
use models::{ComponentState};
use std::{
collections::{HashMap, HashSet},
path::{Path, PathBuf},
};
#[test]
fn detects_existing_proxifyre_from_registry_install_location() {
let host = MockHost::new()
.with_registry("ProxiFyre", r"C:\Tools\ProxiFyre")
.with_path(r"C:\Tools\ProxiFyre")
.with_service("ProxiFyreService");
let detected = detect_proxyfier_install_with_host(&host)
.expect("existing ProxiFyre install should be detected");
assert_eq!(detected.engine, ProxyfierEngine::ProxiFyre);
assert_eq!(detected.install_dir, PathBuf::from(r"C:\Tools\ProxiFyre"));
assert_eq!(detected.config_path, Some(PathBuf::from(r"C:\Tools\ProxiFyre\app-config.json")));
assert!(detected.running);
let component = proxyfier_component_from_detection(Some(&detected));
assert_eq!(component.state, ComponentState::Running);
assert!(component.installed);
assert!(component.running);
assert_eq!(component.path, Some(r"C:\Tools\ProxiFyre".to_string()));
assert!(component.problems.is_empty());
}
#[test]
fn ignores_plain_proxifier_install() {
let host = MockHost::new()
.with_registry("Proxifier", r"C:\Program Files\Proxifier")
.with_path(r"C:\Program Files\Proxifier")
.with_process("Proxifier.exe");
assert!(detect_proxyfier_install_with_host(&host).is_none());
}
#[test]
fn env_override_can_point_to_portable_proxifyre_install() {
let host = MockHost::new()
.with_env("VPN_PROXY_PROXIFYRE_ROOT", r"D:\Portable\ProxiFyre")
.with_path(r"D:\Portable\ProxiFyre\ProxiFyre.exe");
let detected = detect_proxyfier_install_with_host(&host)
.expect("env override should be checked before common paths");
assert_eq!(detected.engine, ProxyfierEngine::ProxiFyre);
assert_eq!(detected.executable_path, PathBuf::from(r"D:\Portable\ProxiFyre\ProxiFyre.exe"));
}
#[test]
fn missing_proxyfier_returns_install_action_status() {
let component = proxyfier_component_from_detection(None);
assert_eq!(component.state, ComponentState::Missing);
assert!(!component.installed);
assert_eq!(component.actions, vec!["Установить ProxiFyre"]);
}
#[derive(Default)]
struct MockHost {
env: HashMap<String, String>,
paths: HashSet<String>,
processes: HashSet<String>,
services: HashSet<String>,
registry: Vec<RegistryInstallEntry>,
}
impl MockHost {
fn new() -> Self {
Self::default()
}
fn with_env(mut self, name: &str, value: &str) -> Self {
self.env.insert(name.to_string(), value.to_string());
self
}
fn with_path(mut self, path: &str) -> Self {
self.paths.insert(normalize_path(path));
self
}
fn with_process(mut self, process: &str) -> Self {
self.processes.insert(process.to_ascii_lowercase());
self
}
fn with_service(mut self, service: &str) -> Self {
self.services.insert(service.to_ascii_lowercase());
self
}
fn with_registry(mut self, display_name: &str, install_location: &str) -> Self {
self.registry.push(RegistryInstallEntry {
display_name: display_name.to_string(),
install_location: Some(PathBuf::from(install_location)),
display_icon: None,
});
self
}
}
impl ProxyfierDetectionHost for MockHost {
fn env_var(&self, name: &str) -> Option<String> {
self.env.get(name).cloned()
}
fn path_exists(&self, path: &Path) -> bool {
self.paths.contains(&normalize_path(&path.display().to_string()))
}
fn process_running(&self, process_name: &str) -> bool {
self.processes
.contains(&process_name.to_ascii_lowercase())
}
fn service_running(&self, service_name: &str) -> bool {
self.services
.contains(&service_name.to_ascii_lowercase())
}
fn registry_install_entries(&self) -> Vec<RegistryInstallEntry> {
self.registry.clone()
}
}
fn normalize_path(path: &str) -> String {
path.replace('/', "\\").to_ascii_lowercase()
}

View File

@@ -0,0 +1,130 @@
#[path = "../src/models.rs"]
mod models;
#[path = "../src/validation.rs"]
mod validation;
use models::{
ComponentId, ProfileInput, ProfileItemInput, ProfileItemType, Protocol, ProxyProtocol,
TargetInput, TargetKind,
};
use validation::{normalize_profile, normalize_target};
#[test]
fn normalizes_profile_source_items() {
let profile = normalize_profile(ProfileInput {
id: Some("Discord + Vesktop".to_string()),
name: " Discord + Vesktop ".to_string(),
enabled: true,
target_id: " home-gateway ".to_string(),
protocols: vec!["tcp".to_string(), "UDP".to_string(), "TCP".to_string()],
items: vec![
ProfileItemInput {
item_type: "process".to_string(),
value: "Discord.exe".to_string(),
recursive: None,
},
ProfileItemInput {
item_type: "folder".to_string(),
value: "%LOCALAPPDATA%\\Vesktop".to_string(),
recursive: Some(true),
},
ProfileItemInput {
item_type: "exe".to_string(),
value: "C:\\Games\\Game\\game.exe".to_string(),
recursive: Some(true),
},
],
})
.expect("profile should normalize");
assert_eq!(profile.id, "discord-vesktop");
assert_eq!(profile.name, "Discord + Vesktop");
assert_eq!(profile.target_id, "home-gateway");
assert_eq!(profile.protocols, vec![Protocol::Tcp, Protocol::Udp]);
assert_eq!(profile.items[0].item_type, ProfileItemType::Process);
assert_eq!(profile.items[0].value, "Discord");
assert!(!profile.items[0].recursive);
assert_eq!(profile.items[1].item_type, ProfileItemType::Folder);
assert!(profile.items[1].recursive);
assert_eq!(profile.items[2].item_type, ProfileItemType::Exe);
assert!(!profile.items[2].recursive);
}
#[test]
fn rejects_unsupported_profile_protocols() {
let error = normalize_profile(ProfileInput {
id: None,
name: "Bad protocol".to_string(),
enabled: true,
target_id: "home-gateway".to_string(),
protocols: vec!["icmp".to_string()],
items: vec![ProfileItemInput {
item_type: "process".to_string(),
value: "Discord".to_string(),
recursive: None,
}],
})
.expect_err("unsupported protocol should fail");
assert!(error.iter().any(|item| item.field == "protocols"));
}
#[test]
fn normalizes_external_target_without_local_singbox() {
let target = normalize_target(TargetInput {
id: Some("Home Gateway".to_string()),
name: " Home Gateway ".to_string(),
kind: "external".to_string(),
protocol: "socks5".to_string(),
host: " 192.168.50.111 ".to_string(),
port: 8080,
requires_component: None,
})
.expect("external target should normalize");
assert_eq!(target.id, "home-gateway");
assert_eq!(target.kind, TargetKind::External);
assert_eq!(target.protocol, ProxyProtocol::Socks5);
assert_eq!(target.host, "192.168.50.111");
assert_eq!(target.port, 8080);
assert_eq!(target.requires_component, None);
}
#[test]
fn local_singbox_target_can_exist_before_component_is_installed() {
let target = normalize_target(TargetInput {
id: Some("local-singbox".to_string()),
name: "Local sing-box".to_string(),
kind: "local".to_string(),
protocol: "socks5".to_string(),
host: "127.0.0.1".to_string(),
port: 1080,
requires_component: Some("singbox".to_string()),
})
.expect("local target definition should not require installed component");
assert_eq!(target.kind, TargetKind::Local);
assert_eq!(target.requires_component, Some(ComponentId::Singbox));
}
#[test]
fn rejects_malformed_target_fields() {
let error = normalize_target(TargetInput {
id: None,
name: "".to_string(),
kind: "external".to_string(),
protocol: "ftp".to_string(),
host: "".to_string(),
port: 70_000,
requires_component: Some("unknown".to_string()),
})
.expect_err("invalid target should fail");
assert!(error.iter().any(|item| item.field == "name"));
assert!(error.iter().any(|item| item.field == "host"));
assert!(error.iter().any(|item| item.field == "port"));
assert!(error.iter().any(|item| item.field == "protocol"));
assert!(error
.iter()
.any(|item| item.field == "requires_component"));
}

View File

@@ -0,0 +1,139 @@
#[path = "../src/helper.rs"]
mod helper;
#[path = "../src/models.rs"]
mod models;
use helper::{
helper_action_requires_elevation, install_request, parse_helper_response,
proxifyre_apply_request, service_request, HelperAction, HelperCommandOutput,
HelperCommandRunner, HelperCommandSpec, HelperError, HelperResponse, StructuredHelper,
};
use models::ComponentId;
use serde_json::json;
use std::cell::RefCell;
use std::path::PathBuf;
#[test]
fn structured_helper_serializes_request_and_parses_json_response() {
let runner = MockRunner {
output: HelperCommandOutput {
status_code: 0,
stdout: serde_json::to_string(&HelperResponse {
success: true,
action: HelperAction::ProxyfierApply,
changed: true,
message: "Applied".to_string(),
details: json!({ "serviceName": "ProxiFyreService" }),
})
.expect("response json"),
stderr: String::new(),
},
seen: RefCell::new(Vec::new()),
};
let helper = StructuredHelper::new("vpn-proxy-helper.exe", runner);
let response = helper
.execute(&proxifyre_apply_request(
r"C:\ProgramData\VpnProxy\generated\proxifyre-app-config.json",
"ProxiFyreService",
))
.expect("helper response");
assert!(response.success);
assert_eq!(response.action, HelperAction::ProxyfierApply);
assert_eq!(response.details["serviceName"], "ProxiFyreService");
}
#[test]
fn helper_runner_receives_json_stdin_and_elevation_flag() {
let runner = MockRunner {
output: HelperCommandOutput {
status_code: 0,
stdout: r#"{"success":true,"action":"service.restart","changed":true,"message":"Restarted","details":{}}"#.to_string(),
stderr: String::new(),
},
seen: RefCell::new(Vec::new()),
};
let helper = StructuredHelper::new("vpn-proxy-helper.exe", runner);
let request = service_request(ComponentId::Proxyfier, HelperAction::ServiceRestart);
let _ = helper.execute(&request).expect("helper response");
let seen = helper.runner().seen.borrow();
let spec = seen.first().expect("runner should be called");
let stdin: serde_json::Value = serde_json::from_str(&spec.stdin).expect("stdin json");
assert_eq!(spec.program, PathBuf::from("vpn-proxy-helper.exe"));
assert_eq!(spec.args, vec!["--json"]);
assert!(spec.requires_elevation);
assert_eq!(stdin["action"], "service.restart");
assert_eq!(stdin["component"], "proxyfier");
}
#[test]
fn install_requests_are_explicit_component_actions() {
let control = install_request(ComponentId::ControlApp);
let proxyfier = install_request(ComponentId::Proxyfier);
let singbox = install_request(ComponentId::Singbox);
assert_eq!(control.action, HelperAction::InstallControlApp);
assert_eq!(proxyfier.action, HelperAction::InstallProxyfier);
assert_eq!(singbox.action, HelperAction::InstallSingbox);
assert!(helper_action_requires_elevation(&proxyfier.action));
}
#[test]
fn apply_request_does_not_encode_installer_action() {
let request = proxifyre_apply_request(
r"C:\ProgramData\VpnProxy\generated\proxifyre-app-config.json",
"ProxiFyreService",
);
assert_eq!(request.action, HelperAction::ProxyfierApply);
assert_eq!(request.component, Some(ComponentId::Proxyfier));
assert_eq!(
request.payload["configPath"],
r"C:\ProgramData\VpnProxy\generated\proxifyre-app-config.json"
);
}
#[test]
fn non_json_helper_stdout_is_rejected() {
let error = parse_helper_response("Proxyfier restarted successfully")
.expect_err("raw stdout should not be accepted");
assert_eq!(error.code, "helper_response_decode");
}
#[test]
fn failed_helper_exit_is_structured_error() {
let runner = MockRunner {
output: HelperCommandOutput {
status_code: 5,
stdout: String::new(),
stderr: "Access denied".to_string(),
},
seen: RefCell::new(Vec::new()),
};
let helper = StructuredHelper::new("vpn-proxy-helper.exe", runner);
let error = helper
.execute(&service_request(
ComponentId::Proxyfier,
HelperAction::ServiceRestart,
))
.expect_err("failed exit should become helper error");
assert_eq!(error.code, "helper_exit");
assert!(error.message.contains("Access denied"));
}
struct MockRunner {
output: HelperCommandOutput,
seen: RefCell<Vec<HelperCommandSpec>>,
}
impl HelperCommandRunner for MockRunner {
fn run(&self, spec: &HelperCommandSpec) -> Result<HelperCommandOutput, HelperError> {
self.seen.borrow_mut().push(spec.clone());
Ok(self.output.clone())
}
}

View File

@@ -0,0 +1,178 @@
#[path = "../src/models.rs"]
mod models;
#[path = "../src/adapters/proxy_router.rs"]
mod proxy_router;
#[path = "../src/adapters/proxifyre.rs"]
mod proxifyre;
use models::{
ComponentId, ComponentState, ComponentStatus, Profile, ProfileItem, ProfileItemType, Protocol,
ProxyProtocol, Target, TargetKind,
};
use proxifyre::{ProxiFyreAdapter, ProxiFyreConfig, PROXIFYRE_OUTPUT_FILE};
use proxy_router::{ProxyRouterAdapter, ProxyRouterErrorKind, ProxyRouterRequest};
#[test]
fn generates_proxifyre_config_for_discord_external_socks5_target() {
let adapter = ProxiFyreAdapter::default();
let profiles = vec![discord_profile("home-gateway")];
let targets = vec![external_socks5_target()];
let components = vec![missing_singbox_component()];
let generated = adapter
.generate_config(ProxyRouterRequest::new(&profiles, &targets, &components))
.expect("external socks5 target should not require sing-box");
let config: ProxiFyreConfig =
serde_json::from_str(&generated.contents).expect("generated config json");
assert_eq!(generated.adapter_id, "proxifyre");
assert_eq!(generated.output_file_name, PROXIFYRE_OUTPUT_FILE);
assert_eq!(generated.enabled_profiles, 1);
assert_eq!(generated.routed_apps, 1);
assert_eq!(config.log_level, "Info");
assert!(config.bypass_lan);
assert_eq!(config.proxies.len(), 1);
assert_eq!(config.proxies[0].app_names, vec!["Discord"]);
assert_eq!(config.proxies[0].socks5_proxy_endpoint, "192.168.50.111:8080");
assert_eq!(config.proxies[0].supported_protocols, vec!["TCP", "UDP"]);
}
#[test]
fn skips_disabled_profiles_when_generating_proxifyre_config() {
let adapter = ProxiFyreAdapter::default();
let mut disabled = discord_profile("home-gateway");
disabled.enabled = false;
let profiles = vec![disabled];
let targets = vec![external_socks5_target()];
let components = Vec::new();
let generated = adapter
.generate_config(ProxyRouterRequest::new(&profiles, &targets, &components))
.expect("disabled profiles should produce empty config");
let config: ProxiFyreConfig =
serde_json::from_str(&generated.contents).expect("generated config json");
assert_eq!(generated.enabled_profiles, 0);
assert_eq!(generated.routed_apps, 0);
assert!(config.proxies.is_empty());
}
#[test]
fn blocks_local_singbox_target_when_required_component_is_missing() {
let adapter = ProxiFyreAdapter::default();
let profiles = vec![discord_profile("local-singbox")];
let targets = vec![local_singbox_target()];
let components = Vec::new();
let error = adapter
.generate_config(ProxyRouterRequest::new(&profiles, &targets, &components))
.expect_err("local sing-box target should require installed running sing-box");
assert_eq!(error.kind, ProxyRouterErrorKind::MissingRequiredComponent);
}
#[test]
fn local_singbox_target_generates_when_required_component_is_running() {
let adapter = ProxiFyreAdapter::default();
let profiles = vec![discord_profile("local-singbox")];
let targets = vec![local_singbox_target()];
let components = vec![running_singbox_component()];
let generated = adapter
.generate_config(ProxyRouterRequest::new(&profiles, &targets, &components))
.expect("running sing-box should satisfy local target dependency");
let config: ProxiFyreConfig =
serde_json::from_str(&generated.contents).expect("generated config json");
assert_eq!(config.proxies.len(), 1);
assert_eq!(config.proxies[0].socks5_proxy_endpoint, "127.0.0.1:1080");
}
#[test]
fn rejects_http_target_because_proxifyre_adapter_is_socks5_only() {
let adapter = ProxiFyreAdapter::default();
let profiles = vec![discord_profile("office-http")];
let targets = vec![Target {
id: "office-http".to_string(),
name: "Office HTTP".to_string(),
kind: TargetKind::External,
protocol: ProxyProtocol::Http,
host: "192.168.50.111".to_string(),
port: 3128,
requires_component: None,
}];
let components = Vec::new();
let error = adapter
.generate_config(ProxyRouterRequest::new(&profiles, &targets, &components))
.expect_err("ProxiFyre should reject HTTP targets");
assert_eq!(error.kind, ProxyRouterErrorKind::UnsupportedTargetProtocol);
}
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: "Home Gateway".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: "Local sing-box".to_string(),
kind: TargetKind::Local,
protocol: ProxyProtocol::Socks5,
host: "127.0.0.1".to_string(),
port: 1080,
requires_component: Some(ComponentId::Singbox),
}
}
fn missing_singbox_component() -> ComponentStatus {
ComponentStatus {
id: ComponentId::Singbox,
name: "Local sing-box".to_string(),
state: ComponentState::Missing,
installed: false,
running: false,
version: None,
path: None,
problems: vec!["Local sing-box is not installed".to_string()],
actions: vec!["Install Local sing-box".to_string()],
}
}
fn running_singbox_component() -> ComponentStatus {
ComponentStatus {
id: ComponentId::Singbox,
name: "Local sing-box".to_string(),
state: ComponentState::Running,
installed: true,
running: true,
version: Some("1.11.0".to_string()),
path: Some(r"C:\Tools\VpnProxy\sing-box\sing-box.exe".to_string()),
problems: Vec::new(),
actions: vec!["Restart".to_string(), "Stop".to_string()],
}
}

View File

@@ -0,0 +1,282 @@
#[path = "../src/models.rs"]
mod models;
#[path = "../src/adapters/proxy_router.rs"]
mod proxy_router;
#[path = "../src/adapters/proxifyre.rs"]
mod proxifyre;
#[path = "../src/adapters/singbox.rs"]
mod singbox;
use models::{
ComponentId, ComponentState, ComponentStatus, Profile, ProfileItem, ProfileItemType, Protocol,
ProxyProtocol, Target, TargetKind,
};
use proxifyre::{ProxiFyreAdapter, ProxiFyreConfig};
use proxy_router::{ProxyRouterAdapter, ProxyRouterRequest};
use singbox::{
SingBoxAdapter, SingBoxCheckResult, SingBoxConfig, SingBoxConfigChecker,
SingBoxConfigError, SingBoxConfigErrorKind, SingBoxGenerationRequest, SINGBOX_OUTPUT_FILE,
};
use std::{
cell::RefCell,
path::{Path, PathBuf},
};
#[test]
fn generates_local_singbox_config_and_runs_check_when_binary_path_is_supplied() {
let adapter = SingBoxAdapter::default();
let targets = vec![local_singbox_target()];
let components = vec![running_singbox_component()];
let checker = RecordingChecker::ok("configuration OK");
let binary_path = Path::new(r"C:\Tools\VpnProxy\sing-box\sing-box.exe");
let generated = adapter
.generate_config(
SingBoxGenerationRequest::new(&targets, &components, Some(binary_path)),
&checker,
)
.expect("running local sing-box should generate config");
let config: SingBoxConfig =
serde_json::from_str(&generated.contents).expect("generated sing-box json");
assert_eq!(generated.adapter_id, "singbox");
assert_eq!(generated.output_file_name, SINGBOX_OUTPUT_FILE);
assert_eq!(generated.local_target_id, "local-singbox");
assert_eq!(generated.listen, "127.0.0.1");
assert_eq!(generated.listen_port, 1080);
assert_eq!(
generated.check,
Some(SingBoxCheckResult {
checked: true,
success: true,
message: "configuration OK".to_string(),
})
);
assert_eq!(config.log.level, "info");
assert_eq!(config.inbounds.len(), 1);
assert_eq!(config.inbounds[0].inbound_type, "mixed");
assert_eq!(config.inbounds[0].listen, "127.0.0.1");
assert_eq!(config.inbounds[0].listen_port, 1080);
assert!(!config.inbounds[0].set_system_proxy);
assert_eq!(config.outbounds[0].outbound_type, "direct");
assert_eq!(config.route.final_outbound, "direct");
let calls = checker.calls.borrow();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].0.as_path(), binary_path);
assert!(calls[0].1.contains(r#""type": "mixed""#));
}
#[test]
fn skips_singbox_check_when_binary_path_is_not_supplied() {
let adapter = SingBoxAdapter::default();
let targets = vec![local_singbox_target()];
let components = vec![running_singbox_component()];
let checker = RecordingChecker::ok("should not run");
let generated = adapter
.generate_config(
SingBoxGenerationRequest::new(&targets, &components, None),
&checker,
)
.expect("binary path is optional");
assert_eq!(generated.check, None);
assert!(checker.calls.borrow().is_empty());
}
#[test]
fn blocks_local_singbox_config_when_required_component_is_missing() {
let adapter = SingBoxAdapter::default();
let targets = vec![local_singbox_target()];
let components = Vec::new();
let checker = RecordingChecker::ok("should not run");
let error = adapter
.generate_config(
SingBoxGenerationRequest::new(&targets, &components, None),
&checker,
)
.expect_err("local sing-box target requires component state");
assert_eq!(error.kind, SingBoxConfigErrorKind::MissingRequiredComponent);
assert!(checker.calls.borrow().is_empty());
}
#[test]
fn blocks_local_singbox_config_when_component_is_not_running() {
let adapter = SingBoxAdapter::default();
let targets = vec![local_singbox_target()];
let components = vec![stopped_singbox_component()];
let checker = RecordingChecker::ok("should not run");
let error = adapter
.generate_config(
SingBoxGenerationRequest::new(&targets, &components, None),
&checker,
)
.expect_err("local sing-box target requires running component");
assert_eq!(error.kind, SingBoxConfigErrorKind::RequiredComponentNotRunning);
assert!(checker.calls.borrow().is_empty());
}
#[test]
fn propagates_failed_singbox_check_as_structured_error() {
let adapter = SingBoxAdapter::default();
let targets = vec![local_singbox_target()];
let components = vec![running_singbox_component()];
let checker = RecordingChecker::err("invalid config");
let error = adapter
.generate_config(
SingBoxGenerationRequest::new(
&targets,
&components,
Some(Path::new("sing-box.exe")),
),
&checker,
)
.expect_err("failed sing-box check should block generated config");
assert_eq!(error.kind, SingBoxConfigErrorKind::CheckFailed);
assert!(error.message.contains("invalid config"));
}
#[test]
fn external_proxifyre_apply_does_not_require_singbox_component() {
let adapter = ProxiFyreAdapter::default();
let profiles = vec![discord_profile("home-gateway")];
let targets = vec![external_socks5_target()];
let components = vec![missing_singbox_component()];
let generated = adapter
.generate_config(ProxyRouterRequest::new(&profiles, &targets, &components))
.expect("external SOCKS5 target should not require local sing-box");
let config: ProxiFyreConfig =
serde_json::from_str(&generated.contents).expect("generated proxifyre json");
assert_eq!(config.proxies.len(), 1);
assert_eq!(config.proxies[0].socks5_proxy_endpoint, "192.168.50.111:8080");
}
struct RecordingChecker {
calls: RefCell<Vec<(PathBuf, String)>>,
result: Result<SingBoxCheckResult, SingBoxConfigError>,
}
impl RecordingChecker {
fn ok(message: &str) -> Self {
Self {
calls: RefCell::new(Vec::new()),
result: Ok(SingBoxCheckResult {
checked: true,
success: true,
message: message.to_string(),
}),
}
}
fn err(message: &str) -> Self {
Self {
calls: RefCell::new(Vec::new()),
result: Err(SingBoxConfigError::new(
SingBoxConfigErrorKind::CheckFailed,
message,
)),
}
}
}
impl SingBoxConfigChecker for RecordingChecker {
fn check_config(
&self,
binary_path: &Path,
config_json: &str,
) -> Result<SingBoxCheckResult, SingBoxConfigError> {
self.calls
.borrow_mut()
.push((binary_path.to_path_buf(), config_json.to_string()));
self.result.clone()
}
}
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: "Home Gateway".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: "Local sing-box".to_string(),
kind: TargetKind::Local,
protocol: ProxyProtocol::Socks5,
host: "127.0.0.1".to_string(),
port: 1080,
requires_component: Some(ComponentId::Singbox),
}
}
fn running_singbox_component() -> ComponentStatus {
ComponentStatus {
id: ComponentId::Singbox,
name: "Local sing-box".to_string(),
state: ComponentState::Running,
installed: true,
running: true,
version: Some("1.11.0".to_string()),
path: Some(r"C:\Tools\VpnProxy\sing-box\sing-box.exe".to_string()),
problems: Vec::new(),
actions: vec!["Restart".to_string(), "Stop".to_string()],
}
}
fn stopped_singbox_component() -> ComponentStatus {
ComponentStatus {
id: ComponentId::Singbox,
name: "Local sing-box".to_string(),
state: ComponentState::Stopped,
installed: true,
running: false,
version: Some("1.11.0".to_string()),
path: Some(r"C:\Tools\VpnProxy\sing-box\sing-box.exe".to_string()),
problems: vec!["Service is stopped".to_string()],
actions: vec!["Start".to_string()],
}
}
fn missing_singbox_component() -> ComponentStatus {
ComponentStatus {
id: ComponentId::Singbox,
name: "Local sing-box".to_string(),
state: ComponentState::Missing,
installed: false,
running: false,
version: None,
path: None,
problems: vec!["Local sing-box is not installed".to_string()],
actions: vec!["Install Local sing-box".to_string()],
}
}

View File

@@ -0,0 +1,195 @@
#[path = "../src/activity.rs"]
mod activity;
#[path = "../src/models.rs"]
mod models;
#[path = "../src/storage.rs"]
mod storage;
use models::{
ActivityEntry, ActivityLevel, ComponentId, ComponentState, ComponentStatus, Profile,
ProfileItem, ProfileItemType, Protocol, ProxyProtocol, Target, TargetKind,
};
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use storage::{backup_path, default_config_root, JsonStorage, StoragePaths};
#[test]
fn storage_defaults_to_programdata_root() {
let expected = PathBuf::from(r"C:\ProgramData\VpnProxy");
assert_eq!(default_config_root(), expected);
assert_eq!(StoragePaths::default().root, expected);
}
#[test]
fn roundtrips_profiles_targets_components_and_activity() {
let root = test_root("roundtrip");
let storage = JsonStorage::new(root.clone());
let profiles = vec![sample_profile("discord")];
let targets = vec![sample_target("home-gateway")];
let components = vec![sample_component()];
let activity = vec![sample_activity(
"created",
"2026-01-01T10:00:00Z",
ActivityLevel::Success,
)];
storage.write_profiles(&profiles).expect("write profiles");
storage.write_targets(&targets).expect("write targets");
storage
.write_components(&components)
.expect("write components");
storage.write_activity(&activity).expect("write activity");
assert_eq!(storage.read_profiles().expect("read profiles"), profiles);
assert_eq!(storage.read_targets().expect("read targets"), targets);
assert_eq!(storage.read_components().expect("read components"), components);
assert_eq!(storage.read_activity().expect("read activity"), activity);
cleanup(&root);
}
#[test]
fn invalid_json_falls_back_to_empty_collection() {
let root = test_root("invalid-json");
let storage = JsonStorage::new(root.clone());
storage.ensure_dirs().expect("create storage dirs");
fs::write(&storage.paths().profiles_file, "{not valid json").expect("write invalid json");
assert_eq!(
storage.read_profiles().expect("invalid profiles fallback"),
Vec::<Profile>::new()
);
cleanup(&root);
}
#[test]
fn write_creates_backup_before_overwriting_source_file() {
let root = test_root("backup");
let storage = JsonStorage::new(root.clone());
let first = vec![sample_profile("first")];
let second = vec![sample_profile("second")];
storage.write_profiles(&first).expect("first write");
storage.write_profiles(&second).expect("second write");
let backup = backup_path(&storage.paths().profiles_file);
assert!(backup.exists(), "backup file should exist");
let backup_contents = fs::read_to_string(backup).expect("read backup");
let backup_profiles: Vec<Profile> =
serde_json::from_str(&backup_contents).expect("backup json");
assert_eq!(backup_profiles, first);
assert_eq!(storage.read_profiles().expect("current profiles"), second);
cleanup(&root);
}
#[test]
fn activity_entries_are_sorted_and_capped() {
let root = test_root("activity");
let storage = JsonStorage::with_activity_limit(root.clone(), 2);
storage
.append_activity(sample_activity(
"old",
"2026-01-01T10:00:00Z",
ActivityLevel::Info,
))
.expect("append old");
storage
.append_activity(sample_activity(
"new",
"2026-01-03T10:00:00Z",
ActivityLevel::Success,
))
.expect("append new");
storage
.append_activity(sample_activity(
"middle",
"2026-01-02T10:00:00Z",
ActivityLevel::Warning,
))
.expect("append middle");
let entries = storage.read_activity().expect("read capped activity");
assert_eq!(entries.len(), 2);
assert_eq!(
entries
.iter()
.map(|entry| entry.id.as_str())
.collect::<Vec<_>>(),
vec!["new", "middle"]
);
cleanup(&root);
}
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!("vpn-proxy-storage-{name}-{timestamp}"))
}
fn cleanup(root: &Path) {
let _ = fs::remove_dir_all(root);
}
fn sample_profile(id: &str) -> Profile {
Profile {
id: id.to_string(),
name: format!("Profile {id}"),
enabled: true,
target_id: "home-gateway".to_string(),
protocols: vec![Protocol::Tcp, Protocol::Udp],
items: vec![ProfileItem {
item_type: ProfileItemType::Process,
value: "Discord".to_string(),
recursive: false,
}],
}
}
fn sample_target(id: &str) -> Target {
Target {
id: id.to_string(),
name: "Home Gateway".to_string(),
kind: TargetKind::External,
protocol: ProxyProtocol::Socks5,
host: "192.168.50.111".to_string(),
port: 8080,
requires_component: None,
}
}
fn sample_component() -> ComponentStatus {
ComponentStatus {
id: ComponentId::Proxyfier,
name: "ProxiFyre".to_string(),
state: ComponentState::Missing,
installed: false,
running: false,
version: None,
path: None,
problems: vec!["ProxiFyre не установлен".to_string()],
actions: vec!["Установить ProxiFyre".to_string()],
}
}
fn sample_activity(id: &str, at: &str, level: ActivityLevel) -> ActivityEntry {
ActivityEntry {
id: id.to_string(),
at: at.to_string(),
level,
title: format!("Activity {id}"),
message: "Storage test activity".to_string(),
}
}