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