Refactor proxy routing and session management

This commit is contained in:
2026-07-08 00:09:38 +03:00
parent c5bdb10445
commit b45dd2ae05
26 changed files with 5193 additions and 307 deletions

View File

@@ -10,8 +10,14 @@ mod models;
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;
@@ -155,6 +161,25 @@ fn proxifyre_install_script_parses_as_powershell() {
cleanup(&root);
}
#[test]
fn singbox_runner_preserves_installer_args_with_spaces() {
let script = commands::singbox_installer_runner_script(
Path::new(r"C:\ProgramData\VpnProxy\state\install-singbox.ps1"),
Path::new(r"C:\ProgramData\VpnProxy\state\install.log"),
&[
"-InstallRoot".to_string(),
r"C:\Program Files\VpnProxy\sing-box".to_string(),
"-ServiceName".to_string(),
"VpnProxySingBox".to_string(),
"-Uninstall".to_string(),
],
);
assert!(script.contains("$installerArgs = @('-InstallRoot', 'C:\\Program Files\\VpnProxy\\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");
@@ -241,6 +266,7 @@ fn component_status_merges_detected_existing_proxifyre() {
running: true,
service_name: Some("ProxiFyreService".to_string()),
}),
None,
);
let proxyfier = components
.iter()

View File

@@ -4,7 +4,8 @@ mod component_detection;
mod models;
use component_detection::{
detect_proxyfier_install_with_host, proxyfier_component_from_detection, ProxyfierDetectionHost,
detect_proxyfier_install_with_host, detect_singbox_install_with_host,
proxyfier_component_from_detection, singbox_component_from_detection, ProxyfierDetectionHost,
ProxyfierEngine, RegistryInstallEntry,
};
use models::ComponentState;
@@ -74,6 +75,66 @@ fn missing_proxyfier_returns_install_action_status() {
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\VpnProxy\sing-box\sing-box.exe")
.with_service("VpnProxySingBox");
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\VpnProxy\sing-box\sing-box.exe")
);
assert_eq!(detected.service_name, "VpnProxySingBox");
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\VpnProxy\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("VPN_PROXY_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>,

View File

@@ -8,14 +8,16 @@ mod proxy_router;
mod singbox;
use models::{
ComponentId, ComponentState, ComponentStatus, Profile, ProfileItem, ProfileItemType, Protocol,
ProxyProtocol, Target, TargetKind,
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, SingBoxConfig, SingBoxConfigChecker, SingBoxConfigError,
SingBoxConfigErrorKind, SingBoxGenerationRequest, SINGBOX_OUTPUT_FILE,
SingBoxAdapter, SingBoxCheckResult, SingBoxConfigChecker, SingBoxConfigError,
SingBoxConfigErrorKind, SingBoxGenerationRequest, DEFAULT_VPN_OUTBOUND_TAG,
SINGBOX_OUTPUT_FILE,
};
use std::{
cell::RefCell,
@@ -23,25 +25,25 @@ use std::{
};
#[test]
fn generates_local_singbox_config_and_runs_check_when_binary_path_is_supplied() {
fn generates_selected_outbound_config_and_runs_check_when_binary_path_is_supplied() {
let adapter = SingBoxAdapter::default();
let targets = vec![local_singbox_target()];
let components = vec![running_singbox_component()];
let config = local_singbox_config("nl-1");
let cache = subscription_cache();
let checker = RecordingChecker::ok("configuration OK");
let binary_path = Path::new(r"C:\Tools\VpnProxy\sing-box\sing-box.exe");
let generated = adapter
.generate_config(
SingBoxGenerationRequest::new(&targets, &components, Some(binary_path)),
SingBoxGenerationRequest::new(&config, &cache, Some(binary_path)),
&checker,
)
.expect("running local sing-box should generate config");
let config: SingBoxConfig =
.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.local_target_id, "local-singbox");
assert_eq!(generated.selected_server_tag, "nl-1");
assert_eq!(generated.listen, "127.0.0.1");
assert_eq!(generated.listen_port, 1080);
assert_eq!(
@@ -52,30 +54,39 @@ fn generates_local_singbox_config_and_runs_check_when_binary_path_is_supplied()
message: "configuration OK".to_string(),
})
);
assert_eq!(config.log.level, "info");
assert_eq!(config.inbounds.len(), 1);
assert_eq!(config.inbounds[0].inbound_type, "mixed");
assert_eq!(config.inbounds[0].listen, "127.0.0.1");
assert_eq!(config.inbounds[0].listen_port, 1080);
assert!(!config.inbounds[0].set_system_proxy);
assert_eq!(config.outbounds[0].outbound_type, "direct");
assert_eq!(config.route.final_outbound, "direct");
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 targets = vec![local_singbox_target()];
let components = vec![running_singbox_component()];
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(&targets, &components, None),
SingBoxGenerationRequest::new(&config, &cache, None),
&checker,
)
.expect("binary path is optional");
@@ -85,54 +96,53 @@ fn skips_singbox_check_when_binary_path_is_not_supplied() {
}
#[test]
fn blocks_local_singbox_config_when_required_component_is_missing() {
fn blocks_config_when_server_is_not_selected() {
let adapter = SingBoxAdapter::default();
let targets = vec![local_singbox_target()];
let components = Vec::new();
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(&targets, &components, None),
SingBoxGenerationRequest::new(&config, &cache, None),
&checker,
)
.expect_err("local sing-box target requires component state");
.expect_err("missing selected server should block config");
assert_eq!(error.kind, SingBoxConfigErrorKind::MissingRequiredComponent);
assert_eq!(error.kind, SingBoxConfigErrorKind::MissingSelectedServer);
assert!(checker.calls.borrow().is_empty());
}
#[test]
fn blocks_local_singbox_config_when_component_is_not_running() {
fn blocks_config_when_selected_outbound_is_missing() {
let adapter = SingBoxAdapter::default();
let targets = vec![local_singbox_target()];
let components = vec![stopped_singbox_component()];
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(&targets, &components, None),
SingBoxGenerationRequest::new(&config, &cache, None),
&checker,
)
.expect_err("local sing-box target requires running component");
.expect_err("missing outbound should block config");
assert_eq!(
error.kind,
SingBoxConfigErrorKind::RequiredComponentNotRunning
);
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 targets = vec![local_singbox_target()];
let components = vec![running_singbox_component()];
let config = local_singbox_config("nl-1");
let cache = subscription_cache();
let checker = RecordingChecker::err("invalid config");
let error = adapter
.generate_config(
SingBoxGenerationRequest::new(&targets, &components, Some(Path::new("sing-box.exe"))),
SingBoxGenerationRequest::new(&config, &cache, Some(Path::new("sing-box.exe"))),
&checker,
)
.expect_err("failed sing-box check should block generated config");
@@ -202,6 +212,46 @@ impl SingBoxConfigChecker for RecordingChecker {
}
}
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: "VpnProxySingBox".to_string(),
install_root: r"C:\Program Files\VpnProxy\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(),
@@ -229,46 +279,6 @@ fn external_socks5_target() -> Target {
}
}
fn local_singbox_target() -> Target {
Target {
id: "local-singbox".to_string(),
name: "Local sing-box".to_string(),
kind: TargetKind::Local,
protocol: ProxyProtocol::Socks5,
host: "127.0.0.1".to_string(),
port: 1080,
requires_component: Some(ComponentId::Singbox),
}
}
fn running_singbox_component() -> ComponentStatus {
ComponentStatus {
id: ComponentId::Singbox,
name: "Local sing-box".to_string(),
state: ComponentState::Running,
installed: true,
running: true,
version: Some("1.11.0".to_string()),
path: Some(r"C:\Tools\VpnProxy\sing-box\sing-box.exe".to_string()),
problems: Vec::new(),
actions: vec!["Restart".to_string(), "Stop".to_string()],
}
}
fn stopped_singbox_component() -> ComponentStatus {
ComponentStatus {
id: ComponentId::Singbox,
name: "Local sing-box".to_string(),
state: ComponentState::Stopped,
installed: true,
running: false,
version: Some("1.11.0".to_string()),
path: Some(r"C:\Tools\VpnProxy\sing-box\sing-box.exe".to_string()),
problems: vec!["Service is stopped".to_string()],
actions: vec!["Start".to_string()],
}
}
fn missing_singbox_component() -> ComponentStatus {
ComponentStatus {
id: ComponentId::Singbox,

View 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!("vpn-proxy-singbox-commands-{name}-{timestamp}"))
}
fn cleanup(root: &Path) {
let _ = fs::remove_dir_all(root);
}

View 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":"VpnProxySingBox","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("VpnProxySingBox".to_string()));
assert_eq!(parsed.status, Some("Running".to_string()));
assert_eq!(parsed.process_id, Some(42));
}
#[test]
fn safe_install_dir_allows_only_vpnproxy_singbox_folder() {
assert!(
ensure_safe_singbox_install_dir(Path::new(r"C:\Program Files\VpnProxy\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, "VpnProxySingBox", None, None);
assert!(script.contains("$serviceName = 'VpnProxySingBox'"));
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\VpnProxy\generated\sing-box-config.json");
let target = Path::new(r"C:\Program Files\VpnProxy\sing-box\config.json");
let script = service_control_script(
SingBoxServiceAction::Start,
"VpnProxySingBox",
Some(source),
Some(target),
);
assert!(script.contains(
"$configSource = 'C:\\ProgramData\\VpnProxy\\generated\\sing-box-config.json'"
));
assert!(script.contains(
"$configTarget = 'C:\\Program Files\\VpnProxy\\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\VpnProxy\sing-box"),
executable_path: PathBuf::from(r"C:\Program Files\VpnProxy\sing-box\sing-box.exe"),
wrapper_path: PathBuf::from(r"C:\Program Files\VpnProxy\sing-box\VpnProxySingBox.exe"),
binary_exists,
wrapper_exists,
running,
service_name: "VpnProxySingBox".to_string(),
}
}

View File

@@ -6,8 +6,9 @@ mod models;
mod storage;
use models::{
ActivityEntry, ActivityLevel, ComponentId, ComponentState, ComponentStatus, Profile,
ProfileItem, ProfileItemType, Protocol, ProxyProtocol, Target, TargetKind,
ActivityEntry, ActivityLevel, ComponentId, ComponentState, ComponentStatus, LocalSingBoxConfig,
Profile, ProfileItem, ProfileItemType, Protocol, ProxyProtocol, SubscriptionCache,
SubscriptionServer, Target, TargetKind,
};
use std::fs;
use std::path::{Path, PathBuf};
@@ -54,6 +55,86 @@ fn roundtrips_profiles_targets_components_and_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: "VpnProxySingBox".to_string(),
install_root: r"C:\Program Files\VpnProxy\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, "VpnProxySingBox");
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");
@@ -187,6 +268,29 @@ fn sample_component() -> ComponentStatus {
}
}
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(),

View 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
)
}