use proxywarden_lib::{ configuration_transaction::{read_guard, revision_locked, ConfigurationTransaction}, storage::JsonStorage, }; use std::{fs, path::PathBuf}; struct Fixture { root: PathBuf, storage: JsonStorage, } impl Fixture { fn new() -> Self { let root = std::env::temp_dir().join(format!("pw-transaction-{}", uuid::Uuid::new_v4())); let storage = JsonStorage::new(&root); storage.write_profiles(&[]).unwrap(); storage.write_targets(&[]).unwrap(); Self { root, storage } } } impl Drop for Fixture { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.root); } } #[test] fn interrupted_commit_restores_primary_and_backup_before_next_read() { let fixture = Fixture::new(); let path = &fixture.storage.paths().profiles_file; let before = fs::read(path).unwrap(); let transaction = ConfigurationTransaction::begin(&fixture.storage, None).unwrap(); fixture.storage.write_profiles(&[]).unwrap(); fs::write(path, b"half-written").unwrap(); transaction.abort().unwrap(); assert_eq!(fs::read(path).unwrap(), before); assert!(!proxywarden_lib::safe_fs::backup_path(path).exists()); let _guard = read_guard(&fixture.storage).unwrap(); assert!(fixture.storage.read_profiles().unwrap().is_empty()); } #[test] fn rollback_leaves_unchanged_files_in_place() { let fixture = Fixture::new(); let path = &fixture.storage.paths().profiles_file; let transaction = ConfigurationTransaction::begin(&fixture.storage, None).unwrap(); // Deny replacement on Windows, while allowing the recovery code to read. let mut options = fs::OpenOptions::new(); options.read(true); #[cfg(windows)] { use std::os::windows::fs::OpenOptionsExt; options.share_mode(1); } let held = options.open(path).unwrap(); let modified = held.metadata().unwrap().modified().unwrap(); transaction.abort().unwrap(); assert_eq!(fs::metadata(path).unwrap().modified().unwrap(), modified); assert!(read_guard(&fixture.storage).is_ok()); } #[test] fn shared_lock_rejects_second_writer_and_reader() { let fixture = Fixture::new(); let transaction = ConfigurationTransaction::begin(&fixture.storage, None).unwrap(); assert!(ConfigurationTransaction::begin(&fixture.storage, None).is_err()); assert!(read_guard(&fixture.storage).is_err()); transaction.commit().unwrap(); assert!(read_guard(&fixture.storage).is_ok()); } #[test] fn committed_revision_rejects_delayed_result_even_when_values_are_identical() { let fixture = Fixture::new(); let revision = { let _guard = read_guard(&fixture.storage).unwrap(); revision_locked(&fixture.storage).unwrap() }; ConfigurationTransaction::begin(&fixture.storage, Some(&revision)) .unwrap() .commit() .unwrap(); assert!(ConfigurationTransaction::begin(&fixture.storage, Some(&revision)).is_err()); } #[test] fn damaged_snapshot_blocks_all_restoration_and_next_writer() { let fixture = Fixture::new(); let transaction = ConfigurationTransaction::begin(&fixture.storage, None).unwrap(); let path = &fixture.storage.paths().profiles_file; fs::write(path, b"new-state").unwrap(); fs::write( fixture .storage .paths() .migrations_dir .join("configuration-before-2.json"), b"damaged", ) .unwrap(); assert!(transaction.abort().is_err()); assert_eq!( fs::read(path).unwrap(), b"new-state", "validate all snapshots before restoring any" ); assert!(read_guard(&fixture.storage).is_err()); assert!(ConfigurationTransaction::begin(&fixture.storage, None).is_err()); } #[test] fn successful_commit_removes_sensitive_fixed_snapshots() { let fixture = Fixture::new(); ConfigurationTransaction::begin(&fixture.storage, None) .unwrap() .commit() .unwrap(); for item in fs::read_dir(&fixture.storage.paths().migrations_dir).unwrap() { let name = item.unwrap().file_name().to_string_lossy().to_string(); assert!( !name.starts_with("configuration-before-") && !name.starts_with("configuration-commit") ); } } #[test] fn transaction_child() { let Some(root) = std::env::var_os("PW_TEST_TRANSACTION_ROOT") else { return; }; let storage = JsonStorage::new(PathBuf::from(root)); let Ok(_transaction) = ConfigurationTransaction::begin(&storage, None) else { std::process::exit(2); }; fs::write(&storage.paths().profiles_file, b"interrupted-child-write").unwrap(); // Deliberately bypass Drop, as a terminated application does. std::process::exit(0); } #[test] fn committed_marker_survives_partial_snapshot_cleanup_without_rollback() { let fixture = Fixture::new(); let transaction = ConfigurationTransaction::begin(&fixture.storage, None).unwrap(); fs::write(&fixture.storage.paths().profiles_file, b"committed-state").unwrap(); // Model death after publishing the terminal marker and removing one snapshot. let journal = fixture .storage .paths() .migrations_dir .join("configuration-commit.json"); let mut intent: serde_json::Value = serde_json::from_slice(&fs::read(&journal).unwrap()).unwrap(); intent["committed"] = serde_json::Value::Bool(true); fs::write(&journal, serde_json::to_vec(&intent).unwrap()).unwrap(); fs::remove_file( fixture .storage .paths() .migrations_dir .join("configuration-before-0.json"), ) .unwrap(); drop(transaction); let _guard = read_guard(&fixture.storage).unwrap(); assert_eq!( fs::read(&fixture.storage.paths().profiles_file).unwrap(), b"committed-state" ); assert!(!journal.exists()); } #[test] fn process_death_is_recovered_before_normal_read_and_lock_excludes_other_processes() { let fixture = Fixture::new(); let before = fs::read(&fixture.storage.paths().profiles_file).unwrap(); let launch = || { std::process::Command::new(std::env::current_exe().unwrap()) .args(["--exact", "transaction_child"]) .env("PW_TEST_TRANSACTION_ROOT", &fixture.root) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .status() .unwrap() }; let transaction = ConfigurationTransaction::begin(&fixture.storage, None).unwrap(); assert_eq!(launch().code(), Some(2)); transaction.abort().unwrap(); assert!(launch().success()); assert_eq!( fs::read(&fixture.storage.paths().profiles_file).unwrap(), b"interrupted-child-write" ); let _guard = read_guard(&fixture.storage).unwrap(); assert_eq!( fs::read(&fixture.storage.paths().profiles_file).unwrap(), before ); }