Files
ProxyWarden/src-tauri/tests/storage_tests.rs

410 lines
13 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_without_backup_returns_error_and_moves_corrupt_file() {
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");
let error = storage
.read_singbox_subscription_cache()
.expect_err("invalid cache should not silently fallback");
assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
assert!(!storage.paths().singbox_subscription_cache_file.exists());
assert!(has_corrupt_sibling(
&storage.paths().singbox_subscription_cache_file
));
cleanup(&root);
}
#[test]
fn invalid_json_without_backup_returns_error_and_moves_corrupt_file() {
let root = test_root("invalid-json-no-backup");
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");
let error = storage
.read_profiles()
.expect_err("invalid profiles should not silently fallback");
assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
assert!(!storage.paths().profiles_file.exists());
assert!(has_corrupt_sibling(&storage.paths().profiles_file));
cleanup(&root);
}
#[test]
fn invalid_json_recovers_from_valid_backup() {
let root = test_root("invalid-json-valid-backup");
let storage = JsonStorage::new(root.clone());
let backup_profiles = vec![sample_profile("backup")];
let current_profiles = vec![sample_profile("current")];
storage
.write_profiles(&backup_profiles)
.expect("write first profiles");
storage
.write_profiles(&current_profiles)
.expect("write second profiles");
fs::write(&storage.paths().profiles_file, "{not valid json").expect("corrupt live json");
let recovered = storage
.read_profiles()
.expect("invalid profiles should recover from valid backup");
assert_eq!(recovered, backup_profiles);
assert_eq!(
storage.read_profiles().expect("restored live profiles"),
backup_profiles
);
assert!(has_corrupt_sibling(&storage.paths().profiles_file));
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 has_corrupt_sibling(path: &Path) -> bool {
let Some(parent) = path.parent() else {
return false;
};
let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
return false;
};
let prefix = format!("{file_name}.corrupt.");
fs::read_dir(parent)
.expect("read sibling dir")
.filter_map(Result::ok)
.any(|entry| {
entry
.file_name()
.to_str()
.is_some_and(|name| name.starts_with(&prefix))
})
}
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(),
}
}