145 lines
7.3 KiB
Python
145 lines
7.3 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
from vpn_egressctl.errors import ApplyError, UnsupportedVersionError, ValidationError
|
|
from vpn_egressctl.renderer_1_13_19 import render_bytes
|
|
from vpn_egressctl.transaction import Controller
|
|
from vpn_egressctl.uri import parse_hysteria2_uri
|
|
|
|
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=salamander&obfs-password=OLDOBFS"
|
|
URI_NEW = "hysteria2://NEW@example.com:443/?obfs=salamander&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 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)
|
|
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)
|
|
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.13.19", "1.13.12", 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)
|
|
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)
|
|
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)
|
|
controller.state_dir.mkdir(parents=True)
|
|
controller.last_good_path.write_bytes(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"])
|
|
|
|
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()
|