use proxywarden_lib::adapters::proxifyre::ProxiFyreAdapter; use proxywarden_lib::adapters::singbox::{ SingBoxAdapter, SingBoxCheckResult, SingBoxConfigChecker, SingBoxConfigError, }; use proxywarden_lib::apply_flow::{ apply_configuration, ApplyConfigurationInput, ApplyPhaseStatus, ApplyRouteMode, ApplyServices, }; use proxywarden_lib::commands::{ Clock, CommandError, HelperApplyRequest, HelperApplyResult, ProxyApplyHelper, }; use proxywarden_lib::component_detection::{DetectedProxyfier, ProxyfierEngine}; use proxywarden_lib::models::{ LocalSingBoxConfig, Profile, ProfileInput, ProfileItem, ProfileItemInput, ProfileItemType, Protocol, ProxyProtocol, SubscriptionCache, SubscriptionServer, Target, TargetInput, TargetKind, }; #[cfg(windows)] use proxywarden_lib::safe_fs; use proxywarden_lib::storage::JsonStorage; use std::{cell::Cell, fs, path::Path}; #[test] fn external_apply_commits_one_source_state_without_service_control() { let fixture = ApplyFixture::new("external-success"); fixture.seed_old_state(); let helper = RecordingHelper::success(); let result = run_apply(&fixture.storage, external_input(), &helper).expect("preflight should succeed"); assert!(result.success); assert!(!result.partial_state); assert_eq!(helper.calls.get(), 1); assert!(result.phases.iter().any(|phase| { phase.id == "service-control" && phase.status == ApplyPhaseStatus::Skipped })); let profiles = fixture.storage.read_profiles().expect("read profiles"); let targets = fixture.storage.read_targets().expect("read targets"); assert!(profiles .iter() .any(|profile| profile.id == "main-profile" && profile.enabled)); assert!(profiles .iter() .any(|profile| profile.id == "legacy" && !profile.enabled)); assert!(targets.iter().any(|target| { target.id == "main-proxy" && target.host == "proxy.example.test" && target.port == 1080 })); assert!(Path::new(&result.generated_config_path).exists()); #[cfg(windows)] safe_fs::verify_path_protected_for_owner_admin_system(Path::new(&result.generated_config_path)) .expect("generated config keeps restricted ACL"); } #[test] fn preflight_failure_does_not_write_source_or_call_helper() { let fixture = ApplyFixture::new("preflight-failure"); fixture.seed_old_state(); let before_profiles = fixture.storage.read_profiles().expect("profiles before"); let before_targets = fixture.storage.read_targets().expect("targets before"); let helper = RecordingHelper::success(); let mut input = external_input(); input.external_target.as_mut().expect("target").host = "socks5://unsafe.example.test".to_string(); let error = run_apply(&fixture.storage, input, &helper) .expect_err("invalid target should fail before writes"); assert_eq!(error.code(), "validation_failed"); assert_eq!(helper.calls.get(), 0); assert_eq!( fixture.storage.read_profiles().expect("profiles after"), before_profiles ); assert_eq!( fixture.storage.read_targets().expect("targets after"), before_targets ); } #[test] fn backend_blocks_apply_when_proxifyre_is_not_detected() { let fixture = ApplyFixture::new("missing-proxifyre"); fixture.seed_old_state(); let helper = RecordingHelper::success(); let proxy_adapter = ProxiFyreAdapter::default(); let singbox_adapter = SingBoxAdapter::default(); let error = apply_configuration( &fixture.storage, external_input(), ApplyServices { proxy_adapter: &proxy_adapter, singbox_adapter: &singbox_adapter, checker: &NoopChecker, helper: &helper, clock: &FixedClock, detected_proxyfier: None, detected_singbox: None, }, ) .expect_err("backend must not trust frontend readiness"); assert_eq!(error.code(), "proxifyre_not_found"); assert_eq!(helper.calls.get(), 0); } #[test] fn apply_command_contract_uses_camel_case_nested_dtos() { let input: ApplyConfigurationInput = serde_json::from_value(serde_json::json!({ "routeMode": "external", "profile": { "id": "main-profile", "name": "Main", "enabled": true, "targetId": "main-proxy", "protocols": ["TCP"], "items": [{ "type": "process", "value": "Discord.exe" }] }, "externalTarget": { "id": "main-proxy", "name": "Proxy", "kind": "external", "protocol": "socks5", "host": "proxy.example.test", "port": 1080 }, "disableOtherProfiles": true })) .expect("typed Tauri input should deserialize"); assert_eq!(input.profile.target_id, "main-proxy"); assert_eq!(input.profile.items[0].item_type, "process"); assert_eq!( input.external_target.expect("target").host, "proxy.example.test" ); } #[test] fn helper_failure_rolls_back_source_and_generated_artifact() { let fixture = ApplyFixture::new("helper-rollback"); fixture.seed_old_state(); let before_profiles = fixture.storage.read_profiles().expect("profiles before"); let before_targets = fixture.storage.read_targets().expect("targets before"); let generated_path = fixture .storage .paths() .generated_dir .join("proxifyre-app-config.json"); fs::create_dir_all(generated_path.parent().expect("generated parent")) .expect("create generated dir"); fs::write(&generated_path, b"old-generated").expect("seed generated config"); let helper = RecordingHelper::failure(); let result = run_apply(&fixture.storage, external_input(), &helper) .expect("runtime failure should return phase result"); assert!(!result.success); assert!(!result.partial_state); assert_eq!(result.error_code.as_deref(), Some("fixture_apply_failed")); assert!(result .phases .iter() .any(|phase| phase.status == ApplyPhaseStatus::RolledBack)); assert_eq!( fixture.storage.read_profiles().expect("profiles after"), before_profiles ); assert_eq!( fixture.storage.read_targets().expect("targets after"), before_targets ); assert_eq!( fs::read(&generated_path).expect("generated after"), b"old-generated" ); #[cfg(windows)] { safe_fs::verify_path_protected_for_owner_admin_system(&generated_path) .expect("rollback keeps generated config restricted"); assert!( !safe_fs::backup_path(&generated_path).exists(), "rollback restores prior absence of backup" ); } } #[test] fn local_apply_with_missing_running_service_stops_at_preflight() { let fixture = ApplyFixture::new("local-service-preflight"); fixture.seed_old_state(); fixture .storage .write_local_singbox_config(&LocalSingBoxConfig { subscription_url: Some("https://sub.example.test/list".to_string()), selected_server_id: Some("fixture-server".to_string()), selected_server_tag: Some("fixture".to_string()), ..LocalSingBoxConfig::default() }) .expect("write local config"); fixture .storage .write_singbox_subscription_cache(&SubscriptionCache { config: serde_json::json!({ "outbounds": [{ "type": "vless", "tag": "fixture", "server": "edge.example.test", "server_port": 443, "uuid": "11111111-1111-1111-1111-111111111111" }] }), servers: vec![SubscriptionServer { id: "fixture-server".to_string(), tag: "fixture".to_string(), server_type: "vless".to_string(), server: "edge.example.test".to_string(), server_port: 443, }], user_info: serde_json::Map::new(), fetched_at: "fixture".to_string(), }) .expect("write cache"); let helper = RecordingHelper::success(); let before_profiles = fixture.storage.read_profiles().expect("profiles before"); let error = run_apply( &fixture.storage, ApplyConfigurationInput { expected_revision: None, route_mode: ApplyRouteMode::LocalSingbox, profile: profile_input(), external_target: None, disable_other_profiles: true, }, &helper, ) .expect_err("stopped/missing Local sing-box must block preflight"); assert_eq!(error.code(), "proxifyre_preflight_failed"); assert_eq!(helper.calls.get(), 0); assert_eq!( fixture.storage.read_profiles().expect("profiles after"), before_profiles ); } #[test] fn editing_shared_target_preserves_the_other_profile_and_target() { let fixture = ApplyFixture::new("shared-target"); fixture.seed_old_state(); let before_profiles = fixture.storage.read_profiles().unwrap(); let before_targets = fixture.storage.read_targets().unwrap(); let mut input = external_input(); input.disable_other_profiles = false; input.external_target.as_mut().unwrap().id = Some("legacy-target".into()); input.profile.protocols = vec!["TCP".into()]; input.profile.items = vec![ProfileItemInput { item_type: "folder".into(), value: r"C:\Games".into(), recursive: Some(false), }]; run_apply(&fixture.storage, input, &RecordingHelper::success()).unwrap(); let profiles = fixture.storage.read_profiles().unwrap(); let targets = fixture.storage.read_targets().unwrap(); assert_eq!(profiles[0], before_profiles[0]); assert_eq!(targets[0], before_targets[0]); let edited = profiles.iter().find(|p| p.id == "main-profile").unwrap(); assert_ne!(edited.target_id, "legacy-target"); assert_eq!(edited.protocols, vec![Protocol::Tcp]); assert!(!edited.items[0].recursive); assert_eq!( targets .iter() .find(|t| t.id == edited.target_id) .unwrap() .host, "proxy.example.test" ); } #[test] fn clearing_last_profile_requires_explicit_stop_and_does_not_call_helper() { let fixture = ApplyFixture::new("clear-running"); fixture.seed_old_state(); let before = fixture.storage.read_profiles().unwrap(); let mut input = external_input(); input.profile.id = Some("legacy".into()); input.profile.target_id = "legacy-target".into(); input.profile.enabled = false; input.profile.items.clear(); input.disable_other_profiles = false; let helper = RecordingHelper::success(); let error = run_apply(&fixture.storage, input, &helper).unwrap_err(); assert_eq!(error.code(), "stop_before_clearing_route"); assert_eq!(helper.calls.get(), 0); assert_eq!(fixture.storage.read_profiles().unwrap(), before); } fn external_input() -> ApplyConfigurationInput { ApplyConfigurationInput { expected_revision: None, route_mode: ApplyRouteMode::External, profile: profile_input(), external_target: Some(TargetInput { id: Some("main-proxy".to_string()), name: "Основной прокси".to_string(), kind: "external".to_string(), protocol: "socks5".to_string(), host: "proxy.example.test".to_string(), port: 1080, requires_component: None, }), disable_other_profiles: true, } } fn profile_input() -> ProfileInput { ProfileInput { id: Some("main-profile".to_string()), name: "Приложения через прокси".to_string(), enabled: true, target_id: String::new(), protocols: vec!["TCP".to_string(), "UDP".to_string()], items: vec![ProfileItemInput { item_type: "process".to_string(), value: "Discord.exe".to_string(), recursive: None, }], } } fn run_apply( storage: &JsonStorage, input: ApplyConfigurationInput, helper: &dyn ProxyApplyHelper, ) -> Result< proxywarden_lib::apply_flow::ApplyConfigurationResult, proxywarden_lib::apply_flow::ApplyFlowError, > { let proxy_adapter = ProxiFyreAdapter::default(); let singbox_adapter = SingBoxAdapter::default(); apply_configuration( storage, input, ApplyServices { proxy_adapter: &proxy_adapter, singbox_adapter: &singbox_adapter, checker: &NoopChecker, helper, clock: &FixedClock, detected_proxyfier: Some(test_proxyfier()), detected_singbox: None, }, ) } fn test_proxyfier() -> DetectedProxyfier { DetectedProxyfier { engine: ProxyfierEngine::ProxiFyre, name: "ProxiFyre".to_string(), install_dir: r"C:\Program Files\ProxyWarden\components\ProxiFyre".into(), executable_path: r"C:\Program Files\ProxyWarden\components\ProxiFyre\ProxiFyre.exe".into(), config_path: Some( r"C:\Program Files\ProxyWarden\components\ProxiFyre\app-config.json".into(), ), running: true, service_name: Some("ProxiFyreService".to_string()), service_status: Some("running".to_string()), version: Some("2.2.1.0".to_string()), } } struct RecordingHelper { calls: Cell, succeed: bool, } impl RecordingHelper { fn success() -> Self { Self { calls: Cell::new(0), succeed: true, } } fn failure() -> Self { Self { calls: Cell::new(0), succeed: false, } } } impl ProxyApplyHelper for RecordingHelper { fn apply_proxy_config( &self, _request: HelperApplyRequest<'_>, ) -> Result { self.calls.set(self.calls.get() + 1); if self.succeed { Ok(HelperApplyResult { success: true, changed: true, action: "apply".to_string(), message: "fixture applied".to_string(), }) } else { Err(CommandError { code: "fixture_apply_failed".to_string(), message: "fixture helper failed".to_string(), details: Vec::new(), }) } } } struct NoopChecker; impl SingBoxConfigChecker for NoopChecker { fn check_config( &self, _binary_path: &Path, _config_json: &str, ) -> Result { Ok(SingBoxCheckResult { checked: true, success: true, message: "fixture valid".to_string(), }) } } struct FixedClock; impl Clock for FixedClock { fn now(&self) -> String { "2026-07-11T00:00:00Z".to_string() } } struct ApplyFixture { root: std::path::PathBuf, storage: JsonStorage, } impl ApplyFixture { fn new(label: &str) -> Self { let root = std::env::temp_dir().join(format!( "proxywarden-apply-flow-{label}-{}", uuid::Uuid::new_v4().hyphenated() )); Self { storage: JsonStorage::new(root.clone()), root, } } fn seed_old_state(&self) { self.storage .write_profiles(&[Profile { id: "legacy".to_string(), name: "Legacy".to_string(), enabled: true, target_id: "legacy-target".to_string(), protocols: vec![Protocol::Tcp], items: vec![ProfileItem { item_type: ProfileItemType::Process, value: "legacy".to_string(), recursive: false, }], }]) .expect("seed profiles"); self.storage .write_targets(&[Target { id: "legacy-target".to_string(), name: "Legacy".to_string(), kind: TargetKind::External, protocol: ProxyProtocol::Socks5, host: "legacy.example.test".to_string(), port: 1080, requires_component: None, }]) .expect("seed targets"); } } impl Drop for ApplyFixture { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.root); } }