Expand README with architecture and setup details
This commit is contained in:
528
src-tauri/tests/command_tests.rs
Normal file
528
src-tauri/tests/command_tests.rs
Normal file
@@ -0,0 +1,528 @@
|
||||
#[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/adapters/singbox.rs"]
|
||||
mod singbox;
|
||||
#[path = "../src/singbox_service.rs"]
|
||||
mod singbox_service;
|
||||
#[path = "../src/storage.rs"]
|
||||
mod storage;
|
||||
#[path = "../src/subscription.rs"]
|
||||
mod subscription;
|
||||
#[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::net::TcpListener;
|
||||
use std::path::{Path, PathBuf};
|
||||
#[cfg(windows)]
|
||||
use std::process::Command as ProcessCommand;
|
||||
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 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(commands::PingProxyTargetInputDto {
|
||||
host: "127.0.0.1".to_string(),
|
||||
port,
|
||||
})
|
||||
.expect("ping should return response");
|
||||
|
||||
assert_eq!(result.tag, "external-proxy");
|
||||
assert_eq!(result.server, "127.0.0.1");
|
||||
assert_eq!(result.server_port, port);
|
||||
assert!(result.ok);
|
||||
assert!(result.latency.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(windows)]
|
||||
fn proxifyre_install_script_parses_as_powershell() {
|
||||
let root = test_root("proxifyre-install-script");
|
||||
fs::create_dir_all(&root).expect("test root should be created");
|
||||
|
||||
let script = commands::wrap_elevated_package_script(
|
||||
&commands::install_proxifyre_script(&root.join("proxifyre-app-config.json")),
|
||||
&root.join("install.log"),
|
||||
);
|
||||
let script_path = root.join("install.ps1");
|
||||
let mut script_bytes = vec![0xEF, 0xBB, 0xBF];
|
||||
script_bytes.extend_from_slice(script.as_bytes());
|
||||
fs::write(&script_path, script_bytes).expect("script should be written");
|
||||
|
||||
let escaped_path = script_path.display().to_string().replace('\'', "''");
|
||||
let parser = format!(
|
||||
"$tokens = $null; $errors = $null; [System.Management.Automation.Language.Parser]::ParseFile('{escaped_path}', [ref]$tokens, [ref]$errors) | Out-Null; if ($errors.Count -gt 0) {{ $errors | ForEach-Object {{ $_.Message }}; exit 1 }}"
|
||||
);
|
||||
let output = ProcessCommand::new("powershell")
|
||||
.args(["-NoProfile", "-NonInteractive", "-Command", &parser])
|
||||
.output()
|
||||
.expect("powershell parser should run");
|
||||
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"install script should parse\nstdout:\n{}\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
);
|
||||
|
||||
cleanup(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn singbox_runner_preserves_installer_args_with_spaces() {
|
||||
let script = commands::singbox_installer_runner_script(
|
||||
Path::new(r"C:\ProgramData\ProxyWarden\state\install-singbox.ps1"),
|
||||
Path::new(r"C:\ProgramData\ProxyWarden\state\install.log"),
|
||||
&[
|
||||
"-InstallRoot".to_string(),
|
||||
r"C:\Program Files\ProxyWarden\sing-box".to_string(),
|
||||
"-ServiceName".to_string(),
|
||||
"ProxyWardenSingBox".to_string(),
|
||||
"-Uninstall".to_string(),
|
||||
],
|
||||
);
|
||||
|
||||
assert!(script.contains("$installerArgs = @('-InstallRoot', 'C:\\Program Files\\ProxyWarden\\sing-box'"));
|
||||
assert!(script.contains("& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $installerPath @installerArgs"));
|
||||
assert!(!script.contains("Start-Process -FilePath 'powershell.exe' -ArgumentList $argumentList"));
|
||||
}
|
||||
|
||||
#[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()),
|
||||
}),
|
||||
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 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!("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),
|
||||
}
|
||||
}
|
||||
|
||||
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()],
|
||||
}
|
||||
}
|
||||
207
src-tauri/tests/component_detection_tests.rs
Normal file
207
src-tauri/tests/component_detection_tests.rs
Normal file
@@ -0,0 +1,207 @@
|
||||
#[path = "../src/component_detection.rs"]
|
||||
mod component_detection;
|
||||
#[path = "../src/models.rs"]
|
||||
mod models;
|
||||
|
||||
use component_detection::{
|
||||
detect_proxyfier_install_with_host, detect_singbox_install_with_host,
|
||||
proxyfier_component_from_detection, singbox_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("PROXYWARDEN_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"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_running_local_singbox_from_default_install_root_and_service() {
|
||||
let host = MockHost::new()
|
||||
.with_path(r"C:\Program Files\ProxyWarden\sing-box\sing-box.exe")
|
||||
.with_service("ProxyWardenSingBox");
|
||||
|
||||
let detected =
|
||||
detect_singbox_install_with_host(&host).expect("existing sing-box should be detected");
|
||||
|
||||
assert_eq!(
|
||||
detected.executable_path,
|
||||
PathBuf::from(r"C:\Program Files\ProxyWarden\sing-box\sing-box.exe")
|
||||
);
|
||||
assert_eq!(detected.service_name, "ProxyWardenSingBox");
|
||||
assert!(detected.running);
|
||||
|
||||
let component = singbox_component_from_detection(Some(&detected));
|
||||
assert_eq!(component.state, ComponentState::Running);
|
||||
assert!(component.installed);
|
||||
assert!(component.running);
|
||||
assert_eq!(
|
||||
component.path,
|
||||
Some(r"C:\Program Files\ProxyWarden\sing-box\sing-box.exe".to_string())
|
||||
);
|
||||
assert!(component.problems.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_stopped_local_singbox_from_env_override() {
|
||||
let host = MockHost::new()
|
||||
.with_env("PROXYWARDEN_SINGBOX_ROOT", r"D:\Portable\sing-box")
|
||||
.with_path(r"D:\Portable\sing-box\sing-box.exe");
|
||||
|
||||
let detected = detect_singbox_install_with_host(&host).expect("env override should be checked");
|
||||
let component = singbox_component_from_detection(Some(&detected));
|
||||
|
||||
assert_eq!(
|
||||
detected.executable_path,
|
||||
PathBuf::from(r"D:\Portable\sing-box\sing-box.exe")
|
||||
);
|
||||
assert_eq!(component.state, ComponentState::Stopped);
|
||||
assert!(component.installed);
|
||||
assert!(!component.running);
|
||||
assert!(component
|
||||
.problems
|
||||
.iter()
|
||||
.any(|problem| problem.contains("остановлена")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_local_singbox_returns_optional_install_action_status() {
|
||||
let component = singbox_component_from_detection(None);
|
||||
|
||||
assert_eq!(component.state, ComponentState::Missing);
|
||||
assert!(!component.installed);
|
||||
assert!(!component.running);
|
||||
assert_eq!(component.actions, vec!["Установить Local sing-box"]);
|
||||
assert!(component.problems.is_empty());
|
||||
}
|
||||
|
||||
#[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()
|
||||
}
|
||||
128
src-tauri/tests/domain_tests.rs
Normal file
128
src-tauri/tests/domain_tests.rs
Normal file
@@ -0,0 +1,128 @@
|
||||
#[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"));
|
||||
}
|
||||
139
src-tauri/tests/helper_tests.rs
Normal file
139
src-tauri/tests/helper_tests.rs
Normal 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("proxywarden-helper.exe", runner);
|
||||
|
||||
let response = helper
|
||||
.execute(&proxifyre_apply_request(
|
||||
r"C:\ProgramData\ProxyWarden\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("proxywarden-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("proxywarden-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\ProxyWarden\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\ProxyWarden\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("proxywarden-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())
|
||||
}
|
||||
}
|
||||
207
src-tauri/tests/proxifyre_adapter_tests.rs
Normal file
207
src-tauri/tests/proxifyre_adapter_tests.rs
Normal file
@@ -0,0 +1,207 @@
|
||||
#[path = "../src/models.rs"]
|
||||
mod models;
|
||||
#[path = "../src/adapters/proxifyre.rs"]
|
||||
mod proxifyre;
|
||||
#[path = "../src/adapters/proxy_router.rs"]
|
||||
mod proxy_router;
|
||||
|
||||
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 includes_folder_paths_when_generating_proxifyre_config() {
|
||||
let adapter = ProxiFyreAdapter::default();
|
||||
let mut profile = discord_profile("home-gateway");
|
||||
profile.items.push(ProfileItem {
|
||||
item_type: ProfileItemType::Folder,
|
||||
value: r"C:\Games\MyGame".to_string(),
|
||||
recursive: true,
|
||||
});
|
||||
let profiles = vec![profile];
|
||||
let targets = vec![external_socks5_target()];
|
||||
let components = Vec::new();
|
||||
|
||||
let generated = adapter
|
||||
.generate_config(ProxyRouterRequest::new(&profiles, &targets, &components))
|
||||
.expect("folder paths should be accepted by ProxiFyre config generation");
|
||||
let config: ProxiFyreConfig =
|
||||
serde_json::from_str(&generated.contents).expect("generated config json");
|
||||
|
||||
assert_eq!(generated.routed_apps, 2);
|
||||
assert_eq!(
|
||||
config.proxies[0].app_names,
|
||||
vec!["Discord", r"C:\Games\MyGame"]
|
||||
);
|
||||
}
|
||||
|
||||
#[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\ProxyWarden\sing-box\sing-box.exe".to_string()),
|
||||
problems: Vec::new(),
|
||||
actions: vec!["Restart".to_string(), "Stop".to_string()],
|
||||
}
|
||||
}
|
||||
294
src-tauri/tests/singbox_adapter_tests.rs
Normal file
294
src-tauri/tests/singbox_adapter_tests.rs
Normal file
@@ -0,0 +1,294 @@
|
||||
#[path = "../src/models.rs"]
|
||||
mod models;
|
||||
#[path = "../src/adapters/proxifyre.rs"]
|
||||
mod proxifyre;
|
||||
#[path = "../src/adapters/proxy_router.rs"]
|
||||
mod proxy_router;
|
||||
#[path = "../src/adapters/singbox.rs"]
|
||||
mod singbox;
|
||||
|
||||
use models::{
|
||||
ComponentId, ComponentState, ComponentStatus, LocalSingBoxConfig, Profile, ProfileItem,
|
||||
ProfileItemType, Protocol, ProxyProtocol, SubscriptionCache, SubscriptionServer, Target,
|
||||
TargetKind,
|
||||
};
|
||||
use proxifyre::{ProxiFyreAdapter, ProxiFyreConfig};
|
||||
use proxy_router::{ProxyRouterAdapter, ProxyRouterRequest};
|
||||
use singbox::{
|
||||
SingBoxAdapter, SingBoxCheckResult, SingBoxConfigChecker, SingBoxConfigError,
|
||||
SingBoxConfigErrorKind, SingBoxGenerationRequest, DEFAULT_VPN_OUTBOUND_TAG,
|
||||
SINGBOX_OUTPUT_FILE,
|
||||
};
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn generates_selected_outbound_config_and_runs_check_when_binary_path_is_supplied() {
|
||||
let adapter = SingBoxAdapter::default();
|
||||
let config = local_singbox_config("nl-1");
|
||||
let cache = subscription_cache();
|
||||
let checker = RecordingChecker::ok("configuration OK");
|
||||
let binary_path = Path::new(r"C:\Tools\ProxyWarden\sing-box\sing-box.exe");
|
||||
|
||||
let generated = adapter
|
||||
.generate_config(
|
||||
SingBoxGenerationRequest::new(&config, &cache, Some(binary_path)),
|
||||
&checker,
|
||||
)
|
||||
.expect("selected outbound should generate config");
|
||||
let generated_config: serde_json::Value =
|
||||
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.selected_server_tag, "nl-1");
|
||||
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!(generated_config["log"]["level"], "info");
|
||||
assert_eq!(generated_config["inbounds"][0]["type"], "mixed");
|
||||
assert_eq!(generated_config["inbounds"][0]["listen"], "127.0.0.1");
|
||||
assert_eq!(generated_config["inbounds"][0]["listen_port"], 1080);
|
||||
assert_eq!(generated_config["inbounds"][0]["set_system_proxy"], false);
|
||||
assert_eq!(generated_config["outbounds"][0]["type"], "vless");
|
||||
assert_eq!(
|
||||
generated_config["outbounds"][0]["tag"],
|
||||
DEFAULT_VPN_OUTBOUND_TAG
|
||||
);
|
||||
assert_eq!(
|
||||
generated_config["outbounds"][0]["server"],
|
||||
"nl.example.test"
|
||||
);
|
||||
assert_eq!(generated_config["outbounds"][0]["packet_encoding"], "xudp");
|
||||
assert_eq!(generated_config["route"]["final"], DEFAULT_VPN_OUTBOUND_TAG);
|
||||
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""#));
|
||||
assert!(calls[0].1.contains(r#""tag": "vpn""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_singbox_check_when_binary_path_is_not_supplied() {
|
||||
let adapter = SingBoxAdapter::default();
|
||||
let config = local_singbox_config("nl-1");
|
||||
let cache = subscription_cache();
|
||||
let checker = RecordingChecker::ok("should not run");
|
||||
|
||||
let generated = adapter
|
||||
.generate_config(
|
||||
SingBoxGenerationRequest::new(&config, &cache, None),
|
||||
&checker,
|
||||
)
|
||||
.expect("binary path is optional");
|
||||
|
||||
assert_eq!(generated.check, None);
|
||||
assert!(checker.calls.borrow().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocks_config_when_server_is_not_selected() {
|
||||
let adapter = SingBoxAdapter::default();
|
||||
let mut config = local_singbox_config("nl-1");
|
||||
config.selected_server_tag = None;
|
||||
let cache = subscription_cache();
|
||||
let checker = RecordingChecker::ok("should not run");
|
||||
|
||||
let error = adapter
|
||||
.generate_config(
|
||||
SingBoxGenerationRequest::new(&config, &cache, None),
|
||||
&checker,
|
||||
)
|
||||
.expect_err("missing selected server should block config");
|
||||
|
||||
assert_eq!(error.kind, SingBoxConfigErrorKind::MissingSelectedServer);
|
||||
assert!(checker.calls.borrow().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocks_config_when_selected_outbound_is_missing() {
|
||||
let adapter = SingBoxAdapter::default();
|
||||
let config = local_singbox_config("missing-server");
|
||||
let cache = subscription_cache();
|
||||
let checker = RecordingChecker::ok("should not run");
|
||||
|
||||
let error = adapter
|
||||
.generate_config(
|
||||
SingBoxGenerationRequest::new(&config, &cache, None),
|
||||
&checker,
|
||||
)
|
||||
.expect_err("missing outbound should block config");
|
||||
|
||||
assert_eq!(error.kind, SingBoxConfigErrorKind::MissingSelectedOutbound);
|
||||
assert!(error.message.contains("missing-server"));
|
||||
assert!(checker.calls.borrow().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn propagates_failed_singbox_check_as_structured_error() {
|
||||
let adapter = SingBoxAdapter::default();
|
||||
let config = local_singbox_config("nl-1");
|
||||
let cache = subscription_cache();
|
||||
let checker = RecordingChecker::err("invalid config");
|
||||
|
||||
let error = adapter
|
||||
.generate_config(
|
||||
SingBoxGenerationRequest::new(&config, &cache, 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 local_singbox_config(selected_server_tag: &str) -> LocalSingBoxConfig {
|
||||
LocalSingBoxConfig {
|
||||
subscription_url: Some("https://sub.example.test/list".to_string()),
|
||||
selected_server_tag: Some(selected_server_tag.to_string()),
|
||||
listen_host: "127.0.0.1".to_string(),
|
||||
listen_port: 1080,
|
||||
service_name: "ProxyWardenSingBox".to_string(),
|
||||
install_root: r"C:\Program Files\ProxyWarden\sing-box".to_string(),
|
||||
updated_at: Some("2026-07-07T10:00:00Z".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn subscription_cache() -> SubscriptionCache {
|
||||
SubscriptionCache {
|
||||
config: serde_json::json!({
|
||||
"outbounds": [
|
||||
{
|
||||
"type": "vless",
|
||||
"tag": "nl-1",
|
||||
"server": "nl.example.test",
|
||||
"server_port": 443,
|
||||
"uuid": "11111111-1111-1111-1111-111111111111"
|
||||
},
|
||||
{
|
||||
"type": "direct",
|
||||
"tag": "direct"
|
||||
}
|
||||
]
|
||||
}),
|
||||
servers: vec![SubscriptionServer {
|
||||
tag: "nl-1".to_string(),
|
||||
server_type: "vless".to_string(),
|
||||
server: "nl.example.test".to_string(),
|
||||
server_port: 443,
|
||||
}],
|
||||
user_info: serde_json::Map::new(),
|
||||
fetched_at: "2026-07-07T10:00:00Z".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
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 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()],
|
||||
}
|
||||
}
|
||||
397
src-tauri/tests/singbox_command_tests.rs
Normal file
397
src-tauri/tests/singbox_command_tests.rs
Normal file
@@ -0,0 +1,397 @@
|
||||
#[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/adapters/singbox.rs"]
|
||||
mod singbox;
|
||||
#[path = "../src/singbox_service.rs"]
|
||||
mod singbox_service;
|
||||
#[path = "../src/storage.rs"]
|
||||
mod storage;
|
||||
#[path = "../src/subscription.rs"]
|
||||
mod subscription;
|
||||
#[path = "../src/validation.rs"]
|
||||
mod validation;
|
||||
|
||||
use commands::{
|
||||
fetch_singbox_subscription_with_fetcher, forget_singbox_subscription_in_storage,
|
||||
generate_singbox_config_with_services, save_singbox_subscription_to_storage,
|
||||
select_singbox_server_in_storage, Clock, SaveSingBoxSubscriptionInputDto,
|
||||
SelectSingBoxServerInputDto, SubscriptionFetcher,
|
||||
};
|
||||
use models::{
|
||||
ActivityLevel, ComponentId, LocalSingBoxConfig, ProxyProtocol, SubscriptionCache,
|
||||
SubscriptionServer, TargetKind,
|
||||
};
|
||||
use serde_json::{json, Map};
|
||||
use singbox::{SingBoxAdapter, SingBoxCheckResult, SingBoxConfigChecker, SingBoxConfigError};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use storage::JsonStorage;
|
||||
|
||||
#[test]
|
||||
fn saves_subscription_url_without_exposing_secret_query() {
|
||||
let root = test_root("save-subscription");
|
||||
let storage = JsonStorage::new(root.clone());
|
||||
|
||||
let status = save_singbox_subscription_to_storage(
|
||||
&storage,
|
||||
SaveSingBoxSubscriptionInputDto {
|
||||
subscription_url: " https://sub.example.test/path?token=secret ".to_string(),
|
||||
},
|
||||
&FixedClock,
|
||||
)
|
||||
.expect("subscription URL should be saved");
|
||||
let config = storage
|
||||
.read_local_singbox_config()
|
||||
.expect("read local sing-box config");
|
||||
|
||||
assert_eq!(
|
||||
config.subscription_url,
|
||||
Some("https://sub.example.test/path?token=secret".to_string())
|
||||
);
|
||||
assert!(status.config.has_subscription);
|
||||
assert_eq!(
|
||||
status.config.subscription_display_url,
|
||||
Some("https://sub.example.test/...".to_string())
|
||||
);
|
||||
|
||||
cleanup(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_http_subscription_url() {
|
||||
let root = test_root("invalid-subscription");
|
||||
let storage = JsonStorage::new(root.clone());
|
||||
|
||||
let error = save_singbox_subscription_to_storage(
|
||||
&storage,
|
||||
SaveSingBoxSubscriptionInputDto {
|
||||
subscription_url: "file:///C:/sub.txt".to_string(),
|
||||
},
|
||||
&FixedClock,
|
||||
)
|
||||
.expect_err("non-http subscription URL should fail");
|
||||
|
||||
assert_eq!(error.code, "singbox_subscription_url_invalid");
|
||||
|
||||
cleanup(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetches_subscription_cache_and_selects_first_server() {
|
||||
let root = test_root("fetch-subscription");
|
||||
let storage = JsonStorage::new(root.clone());
|
||||
save_singbox_subscription_to_storage(
|
||||
&storage,
|
||||
SaveSingBoxSubscriptionInputDto {
|
||||
subscription_url: "https://sub.example.test/path?token=secret".to_string(),
|
||||
},
|
||||
&FixedClock,
|
||||
)
|
||||
.expect("save subscription URL");
|
||||
|
||||
let status = fetch_singbox_subscription_with_fetcher(
|
||||
&storage,
|
||||
&MockFetcher(sample_cache()),
|
||||
&FixedClock,
|
||||
)
|
||||
.expect("fetch subscription through mock");
|
||||
let config = storage
|
||||
.read_local_singbox_config()
|
||||
.expect("read local sing-box config");
|
||||
let cache = storage
|
||||
.read_singbox_subscription_cache()
|
||||
.expect("read cache")
|
||||
.expect("cache should exist");
|
||||
let activity = storage.read_activity().expect("read activity");
|
||||
|
||||
assert_eq!(status.config.selected_server_tag, Some("nl-1".to_string()));
|
||||
assert_eq!(status.cache.expect("status cache").servers.len(), 2);
|
||||
assert_eq!(config.selected_server_tag, Some("nl-1".to_string()));
|
||||
assert_eq!(cache.servers.len(), 2);
|
||||
assert_eq!(activity[0].level, ActivityLevel::Success);
|
||||
assert_eq!(activity[0].title, "Подписка Local sing-box обновлена");
|
||||
|
||||
cleanup(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selects_server_from_cached_subscription() {
|
||||
let root = test_root("select-server");
|
||||
let storage = JsonStorage::new(root.clone());
|
||||
storage
|
||||
.write_singbox_subscription_cache(&sample_cache())
|
||||
.expect("write cache");
|
||||
|
||||
let status = select_singbox_server_in_storage(
|
||||
&storage,
|
||||
SelectSingBoxServerInputDto {
|
||||
tag: "de-1".to_string(),
|
||||
server: None,
|
||||
server_port: None,
|
||||
},
|
||||
&FixedClock,
|
||||
)
|
||||
.expect("server should be selected");
|
||||
let config = storage
|
||||
.read_local_singbox_config()
|
||||
.expect("read local sing-box config");
|
||||
|
||||
assert_eq!(status.config.selected_server_tag, Some("de-1".to_string()));
|
||||
assert_eq!(config.selected_server_tag, Some("de-1".to_string()));
|
||||
|
||||
cleanup(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selects_server_by_endpoint_when_display_tag_is_sanitized() {
|
||||
let root = test_root("select-server-sanitized-tag");
|
||||
let storage = JsonStorage::new(root.clone());
|
||||
storage
|
||||
.write_singbox_subscription_cache(&sample_cache_with_flag_tag())
|
||||
.expect("write cache");
|
||||
|
||||
let status = select_singbox_server_in_storage(
|
||||
&storage,
|
||||
SelectSingBoxServerInputDto {
|
||||
tag: "Умный".to_string(),
|
||||
server: Some("media.example.test".to_string()),
|
||||
server_port: Some(443),
|
||||
},
|
||||
&FixedClock,
|
||||
)
|
||||
.expect("server should be selected by endpoint fallback");
|
||||
let config = storage
|
||||
.read_local_singbox_config()
|
||||
.expect("read local sing-box config");
|
||||
|
||||
assert_eq!(
|
||||
status.config.selected_server_tag,
|
||||
Some("Умный 🇳🇱->🇷🇺".to_string())
|
||||
);
|
||||
assert_eq!(config.selected_server_tag, Some("Умный 🇳🇱->🇷🇺".to_string()));
|
||||
|
||||
cleanup(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_writes_config_and_local_singbox_target() {
|
||||
let root = test_root("generate-config");
|
||||
let storage = JsonStorage::new(root.clone());
|
||||
storage
|
||||
.write_local_singbox_config(&LocalSingBoxConfig {
|
||||
subscription_url: Some("https://sub.example.test/path".to_string()),
|
||||
selected_server_tag: Some("nl-1".to_string()),
|
||||
..LocalSingBoxConfig::default()
|
||||
})
|
||||
.expect("write local sing-box config");
|
||||
storage
|
||||
.write_singbox_subscription_cache(&sample_cache())
|
||||
.expect("write cache");
|
||||
|
||||
let response = generate_singbox_config_with_services(
|
||||
&storage,
|
||||
&SingBoxAdapter::default(),
|
||||
&MockChecker,
|
||||
&FixedClock,
|
||||
Some(Path::new("sing-box.exe")),
|
||||
)
|
||||
.expect("generate sing-box config");
|
||||
let generated = fs::read_to_string(&response.generated_config_path).expect("read generated");
|
||||
let targets = storage.read_targets().expect("read targets");
|
||||
let target = targets
|
||||
.iter()
|
||||
.find(|target| target.id == "local-singbox")
|
||||
.expect("local sing-box target");
|
||||
let activity = storage.read_activity().expect("read activity");
|
||||
|
||||
assert!(response.success);
|
||||
assert_eq!(response.adapter_id, "singbox");
|
||||
assert_eq!(response.selected_server_tag, "nl-1");
|
||||
assert!(response.check.expect("check result").success);
|
||||
assert!(generated.contains("\"type\": \"mixed\""));
|
||||
assert!(generated.contains("\"tag\": \"vpn\""));
|
||||
assert_eq!(target.kind, TargetKind::Local);
|
||||
assert_eq!(target.protocol, ProxyProtocol::Socks5);
|
||||
assert_eq!(target.host, "127.0.0.1");
|
||||
assert_eq!(target.port, 1080);
|
||||
assert_eq!(target.requires_component, Some(ComponentId::Singbox));
|
||||
assert_eq!(activity[0].title, "Конфиг Local sing-box создан");
|
||||
|
||||
cleanup(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_requires_cached_subscription() {
|
||||
let root = test_root("generate-missing-cache");
|
||||
let storage = JsonStorage::new(root.clone());
|
||||
|
||||
let error = generate_singbox_config_with_services(
|
||||
&storage,
|
||||
&SingBoxAdapter::default(),
|
||||
&MockChecker,
|
||||
&FixedClock,
|
||||
None,
|
||||
)
|
||||
.expect_err("missing cache should block generation");
|
||||
|
||||
assert_eq!(error.code, "singbox_subscription_cache_missing");
|
||||
|
||||
cleanup(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forget_subscription_clears_url_selection_and_cache() {
|
||||
let root = test_root("forget-subscription");
|
||||
let storage = JsonStorage::new(root.clone());
|
||||
storage
|
||||
.write_local_singbox_config(&LocalSingBoxConfig {
|
||||
subscription_url: Some("https://sub.example.test/path".to_string()),
|
||||
selected_server_tag: Some("nl-1".to_string()),
|
||||
..LocalSingBoxConfig::default()
|
||||
})
|
||||
.expect("write local sing-box config");
|
||||
storage
|
||||
.write_singbox_subscription_cache(&sample_cache())
|
||||
.expect("write cache");
|
||||
|
||||
let status =
|
||||
forget_singbox_subscription_in_storage(&storage, &FixedClock).expect("forget subscription");
|
||||
let config = storage
|
||||
.read_local_singbox_config()
|
||||
.expect("read local sing-box config");
|
||||
let cache = storage
|
||||
.read_singbox_subscription_cache()
|
||||
.expect("read cache");
|
||||
|
||||
assert!(!status.config.has_subscription);
|
||||
assert_eq!(config.subscription_url, None);
|
||||
assert_eq!(config.selected_server_tag, None);
|
||||
assert_eq!(cache, None);
|
||||
|
||||
cleanup(&root);
|
||||
}
|
||||
|
||||
struct MockFetcher(SubscriptionCache);
|
||||
|
||||
impl SubscriptionFetcher for MockFetcher {
|
||||
fn fetch_subscription(
|
||||
&self,
|
||||
url: &str,
|
||||
) -> Result<SubscriptionCache, subscription::SubscriptionError> {
|
||||
assert_eq!(url, "https://sub.example.test/path?token=secret");
|
||||
Ok(self.0.clone())
|
||||
}
|
||||
}
|
||||
|
||||
struct MockChecker;
|
||||
|
||||
impl SingBoxConfigChecker for MockChecker {
|
||||
fn check_config(
|
||||
&self,
|
||||
binary_path: &Path,
|
||||
config_json: &str,
|
||||
) -> Result<SingBoxCheckResult, SingBoxConfigError> {
|
||||
assert_eq!(binary_path, Path::new("sing-box.exe"));
|
||||
assert!(config_json.contains("\"final\": \"vpn\""));
|
||||
Ok(SingBoxCheckResult {
|
||||
checked: true,
|
||||
success: true,
|
||||
message: "mock check passed".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct FixedClock;
|
||||
|
||||
impl Clock for FixedClock {
|
||||
fn now(&self) -> String {
|
||||
"2026-07-07T00:00:00Z".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_cache() -> SubscriptionCache {
|
||||
SubscriptionCache {
|
||||
config: json!({
|
||||
"outbounds": [
|
||||
{
|
||||
"type": "vless",
|
||||
"tag": "nl-1",
|
||||
"server": "nl.example.test",
|
||||
"server_port": 443,
|
||||
"uuid": "11111111-1111-1111-1111-111111111111"
|
||||
},
|
||||
{
|
||||
"type": "trojan",
|
||||
"tag": "de-1",
|
||||
"server": "de.example.test",
|
||||
"server_port": 443,
|
||||
"password": "secret"
|
||||
}
|
||||
]
|
||||
}),
|
||||
servers: vec![
|
||||
SubscriptionServer {
|
||||
tag: "nl-1".to_string(),
|
||||
server_type: "vless".to_string(),
|
||||
server: "nl.example.test".to_string(),
|
||||
server_port: 443,
|
||||
},
|
||||
SubscriptionServer {
|
||||
tag: "de-1".to_string(),
|
||||
server_type: "trojan".to_string(),
|
||||
server: "de.example.test".to_string(),
|
||||
server_port: 443,
|
||||
},
|
||||
],
|
||||
user_info: Map::new(),
|
||||
fetched_at: "2026-07-07T00:00:00Z".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_cache_with_flag_tag() -> SubscriptionCache {
|
||||
SubscriptionCache {
|
||||
config: json!({
|
||||
"outbounds": [
|
||||
{
|
||||
"type": "vless",
|
||||
"tag": "Умный 🇳🇱->🇷🇺",
|
||||
"server": "media.example.test",
|
||||
"server_port": 443,
|
||||
"uuid": "11111111-1111-1111-1111-111111111111"
|
||||
}
|
||||
]
|
||||
}),
|
||||
servers: vec![SubscriptionServer {
|
||||
tag: "Умный 🇳🇱->🇷🇺".to_string(),
|
||||
server_type: "vless".to_string(),
|
||||
server: "media.example.test".to_string(),
|
||||
server_port: 443,
|
||||
}],
|
||||
user_info: Map::new(),
|
||||
fetched_at: "2026-07-07T00:00:00Z".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
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-singbox-commands-{name}-{timestamp}"))
|
||||
}
|
||||
|
||||
fn cleanup(root: &Path) {
|
||||
let _ = fs::remove_dir_all(root);
|
||||
}
|
||||
128
src-tauri/tests/singbox_service_tests.rs
Normal file
128
src-tauri/tests/singbox_service_tests.rs
Normal file
@@ -0,0 +1,128 @@
|
||||
#[path = "../src/component_detection.rs"]
|
||||
mod component_detection;
|
||||
#[path = "../src/models.rs"]
|
||||
mod models;
|
||||
#[path = "../src/singbox_service.rs"]
|
||||
mod singbox_service;
|
||||
|
||||
use component_detection::DetectedSingBox;
|
||||
use singbox_service::{
|
||||
build_singbox_setup_status, ensure_safe_singbox_install_dir, parse_service_command_output,
|
||||
service_control_script, SingBoxServiceAction,
|
||||
};
|
||||
use std::path::{Path, PathBuf};
|
||||
#[cfg(windows)]
|
||||
use std::process::Command as ProcessCommand;
|
||||
|
||||
#[test]
|
||||
fn setup_status_reports_missing_items_when_singbox_is_absent() {
|
||||
let status = build_singbox_setup_status(None);
|
||||
|
||||
assert!(!status.ready);
|
||||
assert_eq!(status.missing_count, 3);
|
||||
assert_eq!(status.items[0].id, "sing-box-binary");
|
||||
assert!(status.items[0].details.contains("SagerNet/sing-box"));
|
||||
assert_eq!(status.items[1].id, "winsw-wrapper");
|
||||
assert!(status.items[1].details.contains("winsw/winsw"));
|
||||
assert_eq!(status.items[2].id, "windows-service");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setup_status_reports_ready_when_binary_wrapper_and_service_exist() {
|
||||
let detected = detected_singbox(true, true, true);
|
||||
let status = build_singbox_setup_status(Some(&detected));
|
||||
|
||||
assert!(status.ready);
|
||||
assert_eq!(status.missing_count, 0);
|
||||
assert!(status.items.iter().all(|item| item.installed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_last_json_service_command_output_line() {
|
||||
let output = br#"
|
||||
noise
|
||||
{"success":true,"code":"started","serviceName":"ProxyWardenSingBox","status":"Running","processId":42}
|
||||
"#;
|
||||
let parsed = parse_service_command_output(output).expect("service json should parse");
|
||||
|
||||
assert!(parsed.success);
|
||||
assert_eq!(parsed.code, "started");
|
||||
assert_eq!(parsed.service_name, Some("ProxyWardenSingBox".to_string()));
|
||||
assert_eq!(parsed.status, Some("Running".to_string()));
|
||||
assert_eq!(parsed.process_id, Some(42));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn safe_install_dir_allows_only_proxywarden_singbox_folder() {
|
||||
assert!(
|
||||
ensure_safe_singbox_install_dir(Path::new(r"C:\Program Files\ProxyWarden\sing-box")).is_ok()
|
||||
);
|
||||
assert!(ensure_safe_singbox_install_dir(Path::new(r"C:\Windows")).is_err());
|
||||
assert!(ensure_safe_singbox_install_dir(Path::new(r"C:\Program Files\sing-box")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_control_script_targets_named_service_and_action() {
|
||||
let script = service_control_script(SingBoxServiceAction::Start, "ProxyWardenSingBox", None, None);
|
||||
|
||||
assert!(script.contains("$serviceName = 'ProxyWardenSingBox'"));
|
||||
assert!(script.contains("$action = 'start'"));
|
||||
assert!(script.contains("ConvertTo-Json -Compress"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_control_script_syncs_generated_config_before_start() {
|
||||
let source = Path::new(r"C:\ProgramData\ProxyWarden\generated\sing-box-config.json");
|
||||
let target = Path::new(r"C:\Program Files\ProxyWarden\sing-box\config.json");
|
||||
let script = service_control_script(
|
||||
SingBoxServiceAction::Start,
|
||||
"ProxyWardenSingBox",
|
||||
Some(source),
|
||||
Some(target),
|
||||
);
|
||||
|
||||
assert!(script.contains(
|
||||
"$configSource = 'C:\\ProgramData\\ProxyWarden\\generated\\sing-box-config.json'"
|
||||
));
|
||||
assert!(script.contains(
|
||||
"$configTarget = 'C:\\Program Files\\ProxyWarden\\sing-box\\config.json'"
|
||||
));
|
||||
assert!(script.contains("Copy-Item -LiteralPath $configSource"));
|
||||
assert!(script.contains("'config_sync_failed'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(windows)]
|
||||
fn install_singbox_script_parses_as_powershell() {
|
||||
let script_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("..")
|
||||
.join("scripts")
|
||||
.join("install-singbox.ps1");
|
||||
let escaped_path = script_path.display().to_string().replace('\'', "''");
|
||||
let parser = format!(
|
||||
"$tokens = $null; $errors = $null; [System.Management.Automation.Language.Parser]::ParseFile('{escaped_path}', [ref]$tokens, [ref]$errors) | Out-Null; if ($errors.Count -gt 0) {{ $errors | ForEach-Object {{ $_.Message }}; exit 1 }}"
|
||||
);
|
||||
let output = ProcessCommand::new("powershell")
|
||||
.args(["-NoProfile", "-NonInteractive", "-Command", &parser])
|
||||
.output()
|
||||
.expect("powershell parser should run");
|
||||
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"install-singbox.ps1 should parse\nstdout:\n{}\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
);
|
||||
}
|
||||
|
||||
fn detected_singbox(binary_exists: bool, wrapper_exists: bool, running: bool) -> DetectedSingBox {
|
||||
DetectedSingBox {
|
||||
install_dir: PathBuf::from(r"C:\Program Files\ProxyWarden\sing-box"),
|
||||
executable_path: PathBuf::from(r"C:\Program Files\ProxyWarden\sing-box\sing-box.exe"),
|
||||
wrapper_path: PathBuf::from(r"C:\Program Files\ProxyWarden\sing-box\ProxyWardenSingBox.exe"),
|
||||
binary_exists,
|
||||
wrapper_exists,
|
||||
running,
|
||||
service_name: "ProxyWardenSingBox".to_string(),
|
||||
}
|
||||
}
|
||||
302
src-tauri/tests/storage_tests.rs
Normal file
302
src-tauri/tests/storage_tests.rs
Normal file
@@ -0,0 +1,302 @@
|
||||
#[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, LocalSingBoxConfig,
|
||||
Profile, ProfileItem, ProfileItemType, Protocol, ProxyProtocol, SubscriptionCache,
|
||||
SubscriptionServer, 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\ProxyWarden");
|
||||
|
||||
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 roundtrips_local_singbox_config_and_subscription_cache() {
|
||||
let root = test_root("local-singbox");
|
||||
let storage = JsonStorage::new(root.clone());
|
||||
let config = LocalSingBoxConfig {
|
||||
subscription_url: Some("https://sub.example.test/path?token=secret".to_string()),
|
||||
selected_server_tag: Some("nl-1".to_string()),
|
||||
listen_host: "127.0.0.1".to_string(),
|
||||
listen_port: 1080,
|
||||
service_name: "ProxyWardenSingBox".to_string(),
|
||||
install_root: r"C:\Program Files\ProxyWarden\sing-box".to_string(),
|
||||
updated_at: Some("2026-07-07T10:00:00Z".to_string()),
|
||||
};
|
||||
let cache = sample_subscription_cache();
|
||||
|
||||
storage
|
||||
.write_local_singbox_config(&config)
|
||||
.expect("write local sing-box config");
|
||||
storage
|
||||
.write_singbox_subscription_cache(&cache)
|
||||
.expect("write subscription cache");
|
||||
|
||||
assert_eq!(
|
||||
storage
|
||||
.read_local_singbox_config()
|
||||
.expect("read local sing-box config"),
|
||||
config
|
||||
);
|
||||
assert_eq!(
|
||||
storage
|
||||
.read_singbox_subscription_cache()
|
||||
.expect("read subscription cache"),
|
||||
Some(cache)
|
||||
);
|
||||
assert_eq!(
|
||||
config.subscription_display_url(),
|
||||
Some("https://sub.example.test/...".to_string())
|
||||
);
|
||||
|
||||
cleanup(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_local_singbox_config_defaults_to_optional_empty_state() {
|
||||
let root = test_root("local-singbox-default");
|
||||
let storage = JsonStorage::new(root.clone());
|
||||
let config = storage
|
||||
.read_local_singbox_config()
|
||||
.expect("read default local sing-box config");
|
||||
|
||||
assert_eq!(config.subscription_url, None);
|
||||
assert_eq!(config.selected_server_tag, None);
|
||||
assert_eq!(config.listen_host, "127.0.0.1");
|
||||
assert_eq!(config.listen_port, 1080);
|
||||
assert_eq!(config.service_name, "ProxyWardenSingBox");
|
||||
|
||||
cleanup(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_subscription_cache_falls_back_to_none() {
|
||||
let root = test_root("invalid-subscription-cache");
|
||||
let storage = JsonStorage::new(root.clone());
|
||||
storage.ensure_dirs().expect("create storage dirs");
|
||||
fs::write(
|
||||
&storage.paths().singbox_subscription_cache_file,
|
||||
"{not valid json",
|
||||
)
|
||||
.expect("write invalid cache");
|
||||
|
||||
assert_eq!(
|
||||
storage
|
||||
.read_singbox_subscription_cache()
|
||||
.expect("invalid cache fallback"),
|
||||
None
|
||||
);
|
||||
|
||||
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!("proxywarden-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_subscription_cache() -> SubscriptionCache {
|
||||
SubscriptionCache {
|
||||
config: serde_json::json!({
|
||||
"outbounds": [
|
||||
{
|
||||
"type": "vless",
|
||||
"tag": "nl-1",
|
||||
"server": "nl.example.test",
|
||||
"server_port": 443
|
||||
}
|
||||
]
|
||||
}),
|
||||
servers: vec![SubscriptionServer {
|
||||
tag: "nl-1".to_string(),
|
||||
server_type: "vless".to_string(),
|
||||
server: "nl.example.test".to_string(),
|
||||
server_port: 443,
|
||||
}],
|
||||
user_info: serde_json::Map::new(),
|
||||
fetched_at: "2026-07-07T10:00:00Z".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(),
|
||||
}
|
||||
}
|
||||
99
src-tauri/tests/subscription_tests.rs
Normal file
99
src-tauri/tests/subscription_tests.rs
Normal file
@@ -0,0 +1,99 @@
|
||||
#[path = "../src/models.rs"]
|
||||
mod models;
|
||||
#[path = "../src/subscription.rs"]
|
||||
mod subscription;
|
||||
|
||||
use base64::{engine::general_purpose, Engine};
|
||||
use models::redact_subscription_url;
|
||||
use subscription::{parse_subscription_body, parse_user_info};
|
||||
|
||||
#[test]
|
||||
fn parses_singbox_json_config_servers() {
|
||||
let parsed = parse_subscription_body(
|
||||
r#"{
|
||||
"outbounds": [
|
||||
{ "type": "direct", "tag": "direct" },
|
||||
{ "type": "vless", "tag": "nl-1", "server": "nl.example.test", "server_port": 443 },
|
||||
{ "type": "trojan", "tag": "de-1", "server": "de.example.test", "server_port": 8443 }
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.expect("json subscription should parse");
|
||||
|
||||
assert_eq!(parsed.servers.len(), 2);
|
||||
assert_eq!(parsed.servers[0].tag, "nl-1");
|
||||
assert_eq!(parsed.servers[0].server_type, "vless");
|
||||
assert_eq!(parsed.servers[1].server_port, 8443);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_base64_vless_link_list() {
|
||||
let link = sample_vless_link("nl-1");
|
||||
let encoded = general_purpose::STANDARD.encode(format!("{link}\n"));
|
||||
|
||||
let parsed = parse_subscription_body(&encoded).expect("base64 vless list should parse");
|
||||
let outbound = &parsed.config["outbounds"][0];
|
||||
|
||||
assert_eq!(parsed.servers.len(), 1);
|
||||
assert_eq!(parsed.servers[0].tag, "nl-1");
|
||||
assert_eq!(outbound["type"], "vless");
|
||||
assert_eq!(outbound["server"], "nl.example.test");
|
||||
assert_eq!(outbound["packet_encoding"], "xudp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_body_without_supported_outbounds() {
|
||||
let error = parse_subscription_body(r#"{"outbounds":[{"type":"direct","tag":"direct"}]}"#)
|
||||
.expect_err("unsupported subscription should fail");
|
||||
|
||||
assert!(error.message.contains("No supported proxy outbounds found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_vless_without_reality_parameters() {
|
||||
let error = parse_subscription_body("vless://uuid@nl.example.test:443#nl-1")
|
||||
.expect_err("missing reality params should fail");
|
||||
|
||||
assert!(error.message.contains("pbk and sid"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_subscription_user_info_header() {
|
||||
let user_info = parse_user_info(Some("upload=10; download=20; total=30; expire=bad"));
|
||||
|
||||
assert_eq!(user_info["upload"], 10);
|
||||
assert_eq!(user_info["download"], 20);
|
||||
assert_eq!(user_info["total"], 30);
|
||||
assert!(!user_info.contains_key("expire"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_or_non_http_subscription_url_before_network() {
|
||||
let invalid = subscription::fetch_subscription("not a url")
|
||||
.expect_err("invalid url should fail before request");
|
||||
let unsupported = subscription::fetch_subscription("file:///C:/subscription.txt")
|
||||
.expect_err("non-http url should fail before request");
|
||||
|
||||
assert!(invalid.message.contains("Invalid subscription URL"));
|
||||
assert!(unsupported.message.contains("http or https"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redacts_subscription_url_for_display() {
|
||||
assert_eq!(
|
||||
redact_subscription_url("https://sub.example.test/path?token=secret"),
|
||||
"https://sub.example.test/..."
|
||||
);
|
||||
assert_eq!(
|
||||
redact_subscription_url("vless://uuid@example.test"),
|
||||
"vless://uuid@example.test/..."
|
||||
);
|
||||
}
|
||||
|
||||
fn sample_vless_link(tag: &str) -> String {
|
||||
format!(
|
||||
"vless://{}@nl.example.test:443?security=reality&sni=example.test&fp=chrome&pbk=public-key&sid=short-id&flow=xtls-rprx-vision#{}",
|
||||
"11111111-1111-1111-1111-111111111111",
|
||||
tag
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user