448 lines
14 KiB
Rust
448 lines
14 KiB
Rust
#[path = "../src/activity.rs"]
|
|
mod activity;
|
|
#[path = "../src/commands.rs"]
|
|
mod commands;
|
|
#[path = "../src/component_detection.rs"]
|
|
mod component_detection;
|
|
#[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()],
|
|
}
|
|
}
|