355 lines
11 KiB
Rust
355 lines
11 KiB
Rust
use proxywarden_lib::models::{
|
|
ActivityEntry, ActivityLevel, ComponentId, ComponentState, ComponentStatus, LocalSingBoxConfig,
|
|
Profile, ProfileItem, ProfileItemType, Protocol, ProxyProtocol, SubscriptionCache,
|
|
SubscriptionServer, Target, TargetKind,
|
|
};
|
|
use proxywarden_lib::storage::{backup_path, default_config_root, JsonStorage, StoragePaths};
|
|
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
#[test]
|
|
fn storage_defaults_to_programdata_root() {
|
|
let expected = PathBuf::from(r"C:\ProgramData\ProxyWarden");
|
|
|
|
assert_eq!(default_config_root(), expected);
|
|
assert_eq!(StoragePaths::default().root, expected);
|
|
}
|
|
|
|
#[test]
|
|
fn roundtrips_profiles_targets_components_and_activity() {
|
|
let root = test_root("roundtrip");
|
|
let storage = JsonStorage::new(root.clone());
|
|
|
|
let profiles = vec![sample_profile("discord")];
|
|
let targets = vec![sample_target("home-gateway")];
|
|
let components = vec![sample_component()];
|
|
let activity = vec![sample_activity(
|
|
"created",
|
|
"2026-01-01T10:00:00Z",
|
|
ActivityLevel::Success,
|
|
)];
|
|
|
|
storage.write_profiles(&profiles).expect("write profiles");
|
|
storage.write_targets(&targets).expect("write targets");
|
|
write_json(&storage.paths().components_file, &components);
|
|
write_json(&storage.paths().activity_file, &activity);
|
|
|
|
assert_eq!(storage.read_profiles().expect("read profiles"), profiles);
|
|
assert_eq!(storage.read_targets().expect("read targets"), targets);
|
|
assert_eq!(
|
|
storage.read_components().expect("read components"),
|
|
components
|
|
);
|
|
assert_eq!(storage.read_activity().expect("read activity"), 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()),
|
|
device_hwid: Some("hwid-abcdef1234".to_string()),
|
|
selected_server_tag: Some("nl-1".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()),
|
|
};
|
|
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.device_hwid, 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, "ProxyWardenSingBox");
|
|
|
|
cleanup(&root);
|
|
}
|
|
|
|
#[test]
|
|
fn reads_percent_encoded_singbox_tags_as_utf8() {
|
|
let root = test_root("local-singbox-percent-tags");
|
|
let storage = JsonStorage::new(root.clone());
|
|
let encoded_tag =
|
|
"%D0%A3%D0%BC%D0%BD%D1%8B%D0%B9%20%F0%9F%87%B3%F0%9F%87%B1-%3E%F0%9F%87%B7%F0%9F%87%BA";
|
|
let decoded_tag = "Умный 🇳🇱->🇷🇺";
|
|
|
|
storage
|
|
.write_local_singbox_config(&LocalSingBoxConfig {
|
|
selected_server_tag: Some(encoded_tag.to_string()),
|
|
..LocalSingBoxConfig::default()
|
|
})
|
|
.expect("write local sing-box config");
|
|
storage
|
|
.write_singbox_subscription_cache(&SubscriptionCache {
|
|
config: serde_json::json!({
|
|
"outbounds": [
|
|
{
|
|
"type": "vless",
|
|
"tag": encoded_tag,
|
|
"server": "nl.example.test",
|
|
"server_port": 443
|
|
}
|
|
]
|
|
}),
|
|
servers: vec![SubscriptionServer {
|
|
tag: encoded_tag.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(),
|
|
})
|
|
.expect("write subscription cache");
|
|
|
|
let config = storage
|
|
.read_local_singbox_config()
|
|
.expect("read local sing-box config");
|
|
let cache = storage
|
|
.read_singbox_subscription_cache()
|
|
.expect("read subscription cache")
|
|
.expect("subscription cache");
|
|
|
|
assert_eq!(config.selected_server_tag, Some(decoded_tag.to_string()));
|
|
assert_eq!(cache.servers[0].tag, decoded_tag);
|
|
assert_eq!(cache.config["outbounds"][0]["tag"], decoded_tag);
|
|
|
|
cleanup(&root);
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_subscription_cache_falls_back_to_none() {
|
|
let root = test_root("invalid-subscription-cache");
|
|
let storage = JsonStorage::new(root.clone());
|
|
fs::create_dir_all(&storage.paths().state_dir).expect("create state dir");
|
|
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");
|
|
let storage = JsonStorage::new(root.clone());
|
|
fs::create_dir_all(&storage.paths().config_dir).expect("create config dir");
|
|
fs::write(&storage.paths().profiles_file, "{not valid json").expect("write invalid json");
|
|
|
|
assert_eq!(
|
|
storage.read_profiles().expect("invalid profiles fallback"),
|
|
Vec::<Profile>::new()
|
|
);
|
|
|
|
cleanup(&root);
|
|
}
|
|
|
|
#[test]
|
|
fn write_creates_backup_before_overwriting_source_file() {
|
|
let root = test_root("backup");
|
|
let storage = JsonStorage::new(root.clone());
|
|
let first = vec![sample_profile("first")];
|
|
let second = vec![sample_profile("second")];
|
|
|
|
storage.write_profiles(&first).expect("first write");
|
|
storage.write_profiles(&second).expect("second write");
|
|
|
|
let backup = backup_path(&storage.paths().profiles_file);
|
|
assert!(backup.exists(), "backup file should exist");
|
|
|
|
let backup_contents = fs::read_to_string(backup).expect("read backup");
|
|
let backup_profiles: Vec<Profile> =
|
|
serde_json::from_str(&backup_contents).expect("backup json");
|
|
|
|
assert_eq!(backup_profiles, first);
|
|
assert_eq!(storage.read_profiles().expect("current profiles"), second);
|
|
|
|
cleanup(&root);
|
|
}
|
|
|
|
#[test]
|
|
fn activity_entries_are_sorted_and_capped() {
|
|
let root = test_root("activity");
|
|
let storage = JsonStorage::with_activity_limit(root.clone(), 2);
|
|
|
|
storage
|
|
.append_activity(sample_activity(
|
|
"old",
|
|
"2026-01-01T10:00:00Z",
|
|
ActivityLevel::Info,
|
|
))
|
|
.expect("append old");
|
|
storage
|
|
.append_activity(sample_activity(
|
|
"new",
|
|
"2026-01-03T10:00:00Z",
|
|
ActivityLevel::Success,
|
|
))
|
|
.expect("append new");
|
|
storage
|
|
.append_activity(sample_activity(
|
|
"middle",
|
|
"2026-01-02T10:00:00Z",
|
|
ActivityLevel::Warning,
|
|
))
|
|
.expect("append middle");
|
|
|
|
let entries = storage.read_activity().expect("read capped activity");
|
|
|
|
assert_eq!(entries.len(), 2);
|
|
assert_eq!(
|
|
entries
|
|
.iter()
|
|
.map(|entry| entry.id.as_str())
|
|
.collect::<Vec<_>>(),
|
|
vec!["new", "middle"]
|
|
);
|
|
|
|
cleanup(&root);
|
|
}
|
|
|
|
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!("proxywarden-storage-{name}-{timestamp}"))
|
|
}
|
|
|
|
fn cleanup(root: &Path) {
|
|
let _ = fs::remove_dir_all(root);
|
|
}
|
|
|
|
fn write_json<T: serde::Serialize + ?Sized>(path: &Path, value: &T) {
|
|
if let Some(parent) = path.parent() {
|
|
fs::create_dir_all(parent).expect("create json parent dir");
|
|
}
|
|
let contents = serde_json::to_vec_pretty(value).expect("serialize json");
|
|
fs::write(path, contents).expect("write json");
|
|
}
|
|
|
|
fn sample_profile(id: &str) -> Profile {
|
|
Profile {
|
|
id: id.to_string(),
|
|
name: format!("Profile {id}"),
|
|
enabled: true,
|
|
target_id: "home-gateway".to_string(),
|
|
protocols: vec![Protocol::Tcp, Protocol::Udp],
|
|
items: vec![ProfileItem {
|
|
item_type: ProfileItemType::Process,
|
|
value: "Discord".to_string(),
|
|
recursive: false,
|
|
}],
|
|
}
|
|
}
|
|
|
|
fn sample_target(id: &str) -> Target {
|
|
Target {
|
|
id: id.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 sample_component() -> ComponentStatus {
|
|
ComponentStatus {
|
|
id: ComponentId::Proxyfier,
|
|
name: "ProxiFyre".to_string(),
|
|
state: ComponentState::Missing,
|
|
installed: false,
|
|
running: false,
|
|
version: None,
|
|
path: None,
|
|
problems: vec!["ProxiFyre не установлен".to_string()],
|
|
actions: vec!["Установить ProxiFyre".to_string()],
|
|
}
|
|
}
|
|
|
|
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(),
|
|
at: at.to_string(),
|
|
level,
|
|
title: format!("Activity {id}"),
|
|
message: "Storage test activity".to_string(),
|
|
}
|
|
}
|