Refactor application structure and simplify implementation
This commit is contained in:
@@ -0,0 +1,423 @@
|
||||
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,
|
||||
};
|
||||
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());
|
||||
}
|
||||
|
||||
#[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"
|
||||
);
|
||||
}
|
||||
|
||||
#[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 {
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
fn external_input() -> ApplyConfigurationInput {
|
||||
ApplyConfigurationInput {
|
||||
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()),
|
||||
}
|
||||
}
|
||||
|
||||
struct RecordingHelper {
|
||||
calls: Cell<usize>,
|
||||
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<HelperApplyResult, CommandError> {
|
||||
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<SingBoxCheckResult, SingBoxConfigError> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user