from __future__ import annotations import json import hashlib import tempfile import unittest from pathlib import Path from unittest import mock from vpn_egressctl.errors import ApplyError, UnsupportedVersionError, ValidationError from vpn_egressctl.fsutil import atomic_write_json from vpn_egressctl.metadata import build_config_metadata from vpn_egressctl.renderer_1_14_0 import render_bytes from vpn_egressctl.transaction import Controller from vpn_egressctl.uri import parse_hysteria2_uri from vpn_egressctl.version import parse_version_output from tests.helpers import FakeResponse, FakeRunner, VERSION_OUTPUT, make_policy OLD_CONFIG = b'{"log":{"level":"error"}}\n' URI_OLD = "hysteria2://OLD@example.com:443/?obfs=gecko&obfs-password=OLDOBFS" URI_NEW = "hysteria2://NEW@example.com:443/?obfs=gecko&obfs-password=NEWOBFS" class TransactionTests(unittest.TestCase): def make(self, directory: str, *, runner: FakeRunner | None = None) -> tuple[Controller, FakeRunner, object]: root = Path(directory) policy = make_policy(root) Path(policy.sing_box.config_path).parent.mkdir(parents=True) Path(policy.runtime.uri_path).write_text(URI_NEW + "\n", encoding="utf-8") selected = runner or FakeRunner() controller = Controller( policy, runner=selected, urlopen=lambda *args, **kwargs: FakeResponse(), sleeper=lambda _: None, ) return controller, selected, policy def mark_managed(self, controller: Controller, data: bytes, source: str = URI_OLD) -> None: controller._write_state( status="ok", version=parse_version_output(VERSION_OUTPUT), config_data=data, source_data=source, endpoint=parse_hysteria2_uri(source), changed=False, ) def write_last_good(self, controller: Controller, data: bytes) -> None: controller.state_dir.mkdir(parents=True, exist_ok=True) controller.last_good_path.write_bytes(data) atomic_write_json( controller.last_good_meta_path, build_config_metadata(hashlib.sha256(data).hexdigest(), "1.14.0"), ) def test_first_clean_sync_has_no_cross_release_backup(self) -> None: with tempfile.TemporaryDirectory() as directory: controller, _, policy = self.make(directory) self.assertTrue(controller.sync()) self.assertTrue(Path(policy.sing_box.config_path).exists()) self.assertFalse(controller.last_good_path.exists()) def test_first_clean_sync_failure_removes_candidate_and_stops_service(self) -> None: with tempfile.TemporaryDirectory() as directory: runner = FakeRunner(restart_results=[1]) controller, _, policy = self.make(directory, runner=runner) with self.assertRaises(ApplyError) as caught: controller.sync() self.assertTrue(caught.exception.rollback_ok) self.assertFalse(Path(policy.sing_box.config_path).exists()) self.assertTrue(any("stop" in call for call in runner.calls)) state = json.loads(controller.state_path.read_text(encoding="utf-8")) self.assertEqual(state["status"], "initial_apply_failed_service_stopped") def test_state_write_failure_rolls_back_first_install(self) -> None: with tempfile.TemporaryDirectory() as directory: controller, runner, policy = self.make(directory) with ( mock.patch.object(controller, "_write_state", side_effect=OSError("disk")), self.assertRaises(ApplyError) as caught, ): controller.sync() self.assertTrue(caught.exception.rollback_ok) self.assertFalse(Path(policy.sing_box.config_path).exists()) self.assertTrue(any("stop" in call for call in runner.calls)) def test_unmanaged_existing_config_requires_clean_install(self) -> None: with tempfile.TemporaryDirectory() as directory: controller, _, policy = self.make(directory) Path(policy.sing_box.config_path).write_bytes(OLD_CONFIG) with self.assertRaisesRegex(ValidationError, "clean installation"): controller.sync() self.assertEqual(Path(policy.sing_box.config_path).read_bytes(), OLD_CONFIG) def test_sync_applies_and_records_last_good(self) -> None: with tempfile.TemporaryDirectory() as directory: controller, runner, policy = self.make(directory) Path(policy.sing_box.config_path).write_bytes(OLD_CONFIG) self.mark_managed(controller, OLD_CONFIG) changed = controller.sync() self.assertTrue(changed) installed = Path(policy.sing_box.config_path).read_bytes() self.assertIn(b'"password": "NEW"', installed) self.assertEqual(controller.last_good_path.read_bytes(), OLD_CONFIG) self.assertTrue(controller.last_good_meta_path.exists()) backups = list(controller.backup_dir.glob("config.*.json")) self.assertEqual(len(backups), 1) self.assertTrue(Path(str(backups[0]) + ".meta").exists()) state_text = controller.state_path.read_text(encoding="utf-8") self.assertNotIn("NEWOBFS", state_text) self.assertNotIn('"NEW"', state_text) self.assertEqual(json.loads(state_text)["status"], "ok") self.assertTrue(any("restart" in call for call in runner.calls)) def test_no_change_does_not_restart(self) -> None: with tempfile.TemporaryDirectory() as directory: controller, runner, policy = self.make(directory) endpoint = parse_hysteria2_uri(URI_NEW) Path(policy.sing_box.config_path).write_bytes(render_bytes(policy, endpoint)) self.assertFalse(controller.sync()) self.assertFalse(any("restart" in call for call in runner.calls)) self.assertFalse(json.loads(controller.state_path.read_text())["changed"]) def test_generated_config_rejection_is_non_mutating(self) -> None: with tempfile.TemporaryDirectory() as directory: controller, _, policy = self.make(directory, runner=FakeRunner(check_result=1)) Path(policy.sing_box.config_path).write_bytes(OLD_CONFIG) with self.assertRaises(ValidationError): controller.sync() self.assertEqual(Path(policy.sing_box.config_path).read_bytes(), OLD_CONFIG) def test_version_gate_is_non_mutating(self) -> None: with tempfile.TemporaryDirectory() as directory: output = VERSION_OUTPUT.replace("1.14.0", "1.13.19", 1) controller, _, policy = self.make(directory, runner=FakeRunner(version=output)) Path(policy.sing_box.config_path).write_bytes(OLD_CONFIG) with self.assertRaises(UnsupportedVersionError): controller.sync() self.assertEqual(Path(policy.sing_box.config_path).read_bytes(), OLD_CONFIG) def test_restart_failure_restores_config(self) -> None: with tempfile.TemporaryDirectory() as directory: runner = FakeRunner(restart_results=[1, 0]) controller, _, policy = self.make(directory, runner=runner) Path(policy.sing_box.config_path).write_bytes(OLD_CONFIG) self.mark_managed(controller, OLD_CONFIG) with self.assertRaises(ApplyError) as caught: controller.sync() self.assertTrue(caught.exception.rollback_ok) self.assertEqual(Path(policy.sing_box.config_path).read_bytes(), OLD_CONFIG) self.assertEqual(json.loads(controller.state_path.read_text())["status"], "apply_failed_rolled_back") def test_import_failure_restores_uri_and_config(self) -> None: with tempfile.TemporaryDirectory() as directory: runner = FakeRunner(restart_results=[1, 0]) controller, _, policy = self.make(directory, runner=runner) Path(policy.runtime.uri_path).write_text(URI_OLD + "\n", encoding="utf-8") Path(policy.sing_box.config_path).write_bytes(OLD_CONFIG) self.mark_managed(controller, OLD_CONFIG) with self.assertRaises(ApplyError): controller.import_uri(URI_NEW) self.assertEqual(Path(policy.runtime.uri_path).read_text().strip(), URI_OLD) self.assertEqual(Path(policy.sing_box.config_path).read_bytes(), OLD_CONFIG) def test_import_validation_does_not_store_uri(self) -> None: with tempfile.TemporaryDirectory() as directory: controller, _, policy = self.make(directory) Path(policy.runtime.uri_path).write_text(URI_OLD + "\n", encoding="utf-8") with self.assertRaises(ValidationError): controller.import_uri("hysteria2://secret@example.com?unknown=x") self.assertEqual(Path(policy.runtime.uri_path).read_text().strip(), URI_OLD) def test_manual_rollback_swaps_current_and_last_good(self) -> None: with tempfile.TemporaryDirectory() as directory: controller, _, policy = self.make(directory) current = render_bytes(policy, parse_hysteria2_uri(URI_NEW)) previous = render_bytes(policy, parse_hysteria2_uri(URI_OLD)) Path(policy.sing_box.config_path).write_bytes(current) self.mark_managed(controller, current, URI_NEW) self.write_last_good(controller, previous) controller.rollback() self.assertEqual(Path(policy.sing_box.config_path).read_bytes(), previous) self.assertEqual(controller.last_good_path.read_bytes(), current) self.assertEqual(json.loads(controller.state_path.read_text())["status"], "rolled_back") status = controller.status() self.assertEqual(status["status"], "rolled_back") self.assertFalse(status["source_drift"]) self.assertTrue(status["last_good_valid"]) def test_legacy_last_good_without_metadata_is_rejected(self) -> None: with tempfile.TemporaryDirectory() as directory: controller, _, policy = self.make(directory) current = render_bytes(policy, parse_hysteria2_uri(URI_NEW)) Path(policy.sing_box.config_path).write_bytes(current) self.mark_managed(controller, current, URI_NEW) controller.state_dir.mkdir(parents=True, exist_ok=True) controller.last_good_path.write_bytes(OLD_CONFIG) with self.assertRaisesRegex(ValidationError, "metadata"): controller.rollback() def test_manual_rollback_metadata_failure_restores_both_sides(self) -> None: with tempfile.TemporaryDirectory() as directory: controller, _, policy = self.make(directory) current = render_bytes(policy, parse_hysteria2_uri(URI_NEW)) previous = render_bytes(policy, parse_hysteria2_uri(URI_OLD)) Path(policy.sing_box.config_path).write_bytes(current) self.mark_managed(controller, current, URI_NEW) self.write_last_good(controller, previous) original_meta = controller.last_good_meta_path.read_bytes() with ( mock.patch( "vpn_egressctl.transaction.atomic_write_json", side_effect=OSError("disk"), ), self.assertRaises(ApplyError) as caught, ): controller.rollback() self.assertTrue(caught.exception.rollback_ok) self.assertEqual(Path(policy.sing_box.config_path).read_bytes(), current) self.assertEqual(controller.last_good_path.read_bytes(), previous) self.assertEqual(controller.last_good_meta_path.read_bytes(), original_meta) def test_last_good_checksum_mismatch_is_rejected(self) -> None: with tempfile.TemporaryDirectory() as directory: controller, _, policy = self.make(directory) current = render_bytes(policy, parse_hysteria2_uri(URI_NEW)) Path(policy.sing_box.config_path).write_bytes(current) self.mark_managed(controller, current, URI_NEW) self.write_last_good(controller, OLD_CONFIG) controller.last_good_path.write_bytes(b"{}\n") with self.assertRaisesRegex(ValidationError, "checksum"): controller.rollback() def test_diff_never_contains_secrets(self) -> None: with tempfile.TemporaryDirectory() as directory: controller, _, policy = self.make(directory) Path(policy.sing_box.config_path).write_bytes(OLD_CONFIG) output = "\n".join(controller.diff()) self.assertNotIn("NEWOBFS", output) self.assertNotIn('"NEW"', output) self.assertIn("REDACTED", output) def test_status_detects_source_drift(self) -> None: with tempfile.TemporaryDirectory() as directory: controller, _, policy = self.make(directory) endpoint = parse_hysteria2_uri(URI_NEW) Path(policy.sing_box.config_path).write_bytes(render_bytes(policy, endpoint)) controller.sync() Path(policy.runtime.uri_path).write_text(URI_OLD + "\n", encoding="utf-8") status = controller.status() self.assertTrue(status["source_drift"]) if __name__ == "__main__": unittest.main()