release: prepare 0.2.0 for sing-box 1.14
This commit is contained in:
+120
-6
@@ -1,21 +1,26 @@
|
||||
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.renderer_1_13_19 import render_bytes
|
||||
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=salamander&obfs-password=OLDOBFS"
|
||||
URI_NEW = "hysteria2://NEW@example.com:443/?obfs=salamander&obfs-password=NEWOBFS"
|
||||
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):
|
||||
@@ -33,15 +38,77 @@ class TransactionTests(unittest.TestCase):
|
||||
)
|
||||
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)
|
||||
@@ -67,7 +134,7 @@ class TransactionTests(unittest.TestCase):
|
||||
|
||||
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)
|
||||
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):
|
||||
@@ -79,6 +146,7 @@ class TransactionTests(unittest.TestCase):
|
||||
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)
|
||||
@@ -91,6 +159,7 @@ class TransactionTests(unittest.TestCase):
|
||||
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)
|
||||
@@ -110,8 +179,8 @@ class TransactionTests(unittest.TestCase):
|
||||
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)
|
||||
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)
|
||||
@@ -119,6 +188,51 @@ class TransactionTests(unittest.TestCase):
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user