287 lines
9.5 KiB
Rust
287 lines
9.5 KiB
Rust
use proxywarden_lib::adapters::proxifyre::{ProxiFyreAdapter, ProxiFyreConfig};
|
|
use proxywarden_lib::adapters::proxy_router::{ProxyRouterAdapter, ProxyRouterRequest};
|
|
use proxywarden_lib::adapters::singbox::{
|
|
SingBoxAdapter, SingBoxCheckResult, SingBoxConfigChecker, SingBoxConfigError,
|
|
SingBoxConfigErrorKind, SingBoxGenerationRequest, DEFAULT_VPN_OUTBOUND_TAG,
|
|
SINGBOX_OUTPUT_FILE,
|
|
};
|
|
use proxywarden_lib::models::{
|
|
ComponentId, ComponentState, ComponentStatus, LocalSingBoxConfig, Profile, ProfileItem,
|
|
ProfileItemType, Protocol, ProxyProtocol, SubscriptionCache, SubscriptionServer, Target,
|
|
TargetKind,
|
|
};
|
|
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()),
|
|
device_hwid: None,
|
|
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()],
|
|
}
|
|
}
|