Some checks failed
Windows project source guards / test (push) Failing after 26s
Оставляется то, на что ссылаются этот и три прежних выпуска; ядро — копия из Zapret 2 GUI (без лишних заплаток zstd, publish.prune). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
584 lines
27 KiB
Python
584 lines
27 KiB
Python
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import io
|
||
import json
|
||
import stat
|
||
import subprocess
|
||
import tempfile
|
||
import unittest
|
||
from pathlib import Path
|
||
from unittest.mock import patch
|
||
import zipfile
|
||
|
||
import build
|
||
from scripts import release_windows
|
||
|
||
|
||
class ReleaseVersionTests(unittest.TestCase):
|
||
def test_next_patch_comes_from_stable_tag(self) -> None:
|
||
self.assertEqual(release_windows.next_patch("v0.4.83"), "0.4.84")
|
||
self.assertEqual(release_windows.next_patch("v1.9.99"), "1.9.100")
|
||
|
||
def test_explicit_immediate_minor_zero_is_allowed(self) -> None:
|
||
self.assertEqual(
|
||
release_windows.validate_next_stable_version("v0.4.101", "0.5.0"),
|
||
"0.5.0",
|
||
)
|
||
self.assertEqual(
|
||
release_windows.validate_next_stable_version("v0.4.101", None),
|
||
"0.4.102",
|
||
)
|
||
|
||
def test_release_archive_keeps_newest_versions_numerically(self) -> None:
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
root = Path(tmp)
|
||
for name in ("v0.5.0", "v0.6.9", "v0.6.10", "v0.6.11", "v0.6.12", "notes"):
|
||
(root / name).mkdir()
|
||
(root / name / "asset.zip").write_bytes(b"x")
|
||
removed = release_windows.prune_release_archive("0.6.12", root, keep=3)
|
||
self.assertEqual(sorted(removed), ["v0.5.0", "v0.6.9"])
|
||
self.assertEqual(
|
||
sorted(entry.name for entry in root.iterdir()),
|
||
["notes", "v0.6.10", "v0.6.11", "v0.6.12"],
|
||
)
|
||
|
||
def test_remote_dist_pattern_matches_only_release_assets(self) -> None:
|
||
# Python has no duplicate named groups; the shape check is the same.
|
||
pattern = release_windows.remote_dist_asset_pattern().replace("(?<v>", "(")
|
||
for name in release_windows.EXPECTED_ASSET_NAMES:
|
||
with self.subTest(name=name):
|
||
self.assertRegex(name.format(version="0.6.12"), pattern)
|
||
for name in ("ZapretKVN", "ZapretKVN-portable.zip", "ZapretKVN-v0.6.12-windows-x64.exe.bak"):
|
||
with self.subTest(name=name):
|
||
self.assertNotRegex(name, pattern)
|
||
|
||
def test_skipped_or_major_versions_are_rejected(self) -> None:
|
||
for value in ("0.4.103", "0.5.1", "0.6.0", "1.0.0"):
|
||
with self.subTest(value=value), self.assertRaises(release_windows.ReleaseError):
|
||
release_windows.validate_next_stable_version("v0.4.101", value)
|
||
|
||
def test_prerelease_or_malformed_version_is_rejected(self) -> None:
|
||
for value in ("0.4", "0.4.84-rc1", "latest", ""):
|
||
with self.subTest(value=value), self.assertRaises(release_windows.ReleaseError):
|
||
release_windows.parse_version(value)
|
||
|
||
def test_latest_stable_tag_ignores_prerelease_suffixes(self) -> None:
|
||
with patch.object(
|
||
release_windows,
|
||
"output",
|
||
return_value="v0.4.83\nv0.4.84-rc1\nv0.4.82\n",
|
||
):
|
||
self.assertEqual(release_windows.latest_stable_tag(), "v0.4.83")
|
||
|
||
|
||
class ReleaseChangelogTests(unittest.TestCase):
|
||
def test_repeated_and_semicolon_changes_are_normalized(self) -> None:
|
||
self.assertEqual(
|
||
release_windows.normalize_changes(
|
||
[" First change ; Second change ", "• Third change"]
|
||
),
|
||
["First change", "Second change", "Third change"],
|
||
)
|
||
|
||
def test_empty_resume_changes_are_allowed_only_explicitly(self) -> None:
|
||
self.assertEqual(release_windows.normalize_changes([], allow_empty=True), [])
|
||
with self.assertRaises(release_windows.ReleaseError):
|
||
release_windows.normalize_changes([])
|
||
|
||
def test_publisher_limits_are_enforced_before_external_writes(self) -> None:
|
||
with self.assertRaises(release_windows.ReleaseError):
|
||
release_windows.normalize_changes(["x"] * 7)
|
||
with self.assertRaises(release_windows.ReleaseError):
|
||
release_windows.normalize_changes(["x" * 181])
|
||
|
||
|
||
class ReleaseStateTests(unittest.TestCase):
|
||
def test_phase_order_supports_resume_without_repeating_completed_work(self) -> None:
|
||
state = {"phase": "stable_verified"}
|
||
self.assertTrue(release_windows.phase_done(state, "dev_verified"))
|
||
self.assertTrue(release_windows.phase_done(state, "stable_verified"))
|
||
self.assertFalse(release_windows.phase_done(state, "assets_verified"))
|
||
|
||
def test_atomic_json_round_trip(self) -> None:
|
||
with tempfile.TemporaryDirectory() as directory:
|
||
path = Path(directory) / "state.json"
|
||
release_windows.atomic_json(path, {"phase": "dev_verified", "version": "0.4.84"})
|
||
self.assertEqual(
|
||
release_windows.read_json(path),
|
||
{"phase": "dev_verified", "version": "0.4.84"},
|
||
)
|
||
|
||
def test_execute_checks_clean_tree_before_creating_resume_state(self) -> None:
|
||
args = release_windows.build_parser().parse_args(["--change", "Ready"])
|
||
with (
|
||
patch.object(release_windows, "require_clean_main", side_effect=release_windows.ReleaseError("dirty")),
|
||
patch.object(release_windows, "load_or_create_state") as create_state,
|
||
):
|
||
with self.assertRaisesRegex(release_windows.ReleaseError, "dirty"):
|
||
release_windows.execute(args)
|
||
create_state.assert_not_called()
|
||
|
||
def test_core_refresh_uses_verified_write_or_nonmutating_current_check(self) -> None:
|
||
with patch.object(release_windows, "run") as run:
|
||
release_windows.refresh_stable_core_lock(write=True)
|
||
release_windows.refresh_stable_core_lock(write=False)
|
||
|
||
resolver = str(release_windows.CORE_RESOLVER_PATH.relative_to(release_windows.ROOT))
|
||
self.assertEqual(
|
||
run.call_args_list[0].args[0],
|
||
[release_windows.sys.executable, resolver, "--write", "--update-runtime"],
|
||
)
|
||
self.assertEqual(
|
||
run.call_args_list[1].args[0],
|
||
[release_windows.sys.executable, resolver, "--check", "--require-current"],
|
||
)
|
||
|
||
@staticmethod
|
||
def _gate_manifest(*, prerelease: bool = False) -> dict:
|
||
return {
|
||
"schema": 1,
|
||
"mode": "stable",
|
||
"version": "0.4.95",
|
||
"commit": "a" * 40,
|
||
"templates_verified": 4,
|
||
"geoip": json.loads(release_windows.GEOIP_LOCK_PATH.read_text()),
|
||
"executable": {"size": 1, "sha256": "b" * 64},
|
||
"core": {
|
||
"lock_sha256": "c" * 64,
|
||
"manifest_sha256": "d" * 64,
|
||
"sources": [
|
||
{
|
||
"id": "amnezia", "repository": "amnezia-vpn/amneziawg-go",
|
||
"channel": "official-tags", "release_prerelease": True,
|
||
"archive_sha256": "9" * 64, "asset_size": 5,
|
||
},
|
||
{
|
||
"id": "xray-core",
|
||
"channel": "stable",
|
||
"release_prerelease": prerelease,
|
||
"archive_sha256": "e" * 64,
|
||
"asset_size": 1,
|
||
},
|
||
{
|
||
"id": "sing-box-extended",
|
||
"channel": "stable",
|
||
"release_prerelease": False,
|
||
"archive_sha256": "f" * 64,
|
||
"asset_size": 1,
|
||
},
|
||
{
|
||
"id": "hysteria",
|
||
"channel": "stable",
|
||
"release_prerelease": False,
|
||
"archive_sha256": "1" * 64,
|
||
"asset_size": 1,
|
||
},
|
||
],
|
||
},
|
||
}
|
||
|
||
def test_gate_manifest_requires_exact_stable_core_proof(self) -> None:
|
||
release_windows.verify_gate_manifest(
|
||
self._gate_manifest(),
|
||
"stable",
|
||
"0.4.95",
|
||
"a" * 40,
|
||
)
|
||
with self.assertRaisesRegex(release_windows.ReleaseError, "non-stable core"):
|
||
release_windows.verify_gate_manifest(
|
||
self._gate_manifest(prerelease=True),
|
||
"stable",
|
||
"0.4.95",
|
||
"a" * 40,
|
||
)
|
||
|
||
|
||
class ReleaseAbandonTests(unittest.TestCase):
|
||
"""A cancelled unpublished release is skipped only through --abandon."""
|
||
|
||
def setUp(self) -> None:
|
||
directory = tempfile.TemporaryDirectory()
|
||
self.addCleanup(directory.cleanup)
|
||
root = Path(directory.name)
|
||
self.state_path = root / "state.json"
|
||
self.abandoned_path = root / "abandoned.json"
|
||
for name, value in (("STATE_PATH", self.state_path), ("ABANDONED_PATH", self.abandoned_path)):
|
||
patcher = patch.object(release_windows, name, value)
|
||
patcher.start()
|
||
self.addCleanup(patcher.stop)
|
||
|
||
class _Forgejo:
|
||
def __init__(self, release=None) -> None:
|
||
self.release = release
|
||
|
||
def release_by_tag(self, _version):
|
||
return self.release
|
||
|
||
def _state(self, phase: str, version: str = "0.7.6") -> None:
|
||
release_windows.atomic_json(
|
||
self.state_path,
|
||
{"version": version, "phase": phase, "commit": "abc", "previous_tag": "v0.7.5", "changes": ["x"]},
|
||
)
|
||
|
||
def _abandon(self, version: str = "0.7.6", *, tag: bool = False, remote: str = "", release=None):
|
||
def fake_run(args, **_kwargs):
|
||
return subprocess.CompletedProcess(args, 0 if tag else 1)
|
||
|
||
with (
|
||
patch.object(release_windows.subprocess, "run", side_effect=fake_run),
|
||
patch.object(release_windows, "output", return_value=remote),
|
||
):
|
||
return release_windows.abandon(version, self._Forgejo(release))
|
||
|
||
def test_abandon_archives_state_and_records_version(self) -> None:
|
||
self._state("dev_verified")
|
||
result = self._abandon()
|
||
self.assertEqual(result["status"], "abandoned")
|
||
self.assertFalse(self.state_path.exists())
|
||
self.assertTrue(self.state_path.with_name("zapret-kvn-release-abandoned-v0.7.6.json").is_file())
|
||
self.assertEqual(release_windows.abandoned_versions()["0.7.6"]["phase"], "dev_verified")
|
||
|
||
def test_abandon_refuses_tagged_or_published_or_other_versions(self) -> None:
|
||
self._state("tag_pushed")
|
||
with self.assertRaisesRegex(release_windows.ReleaseError, "correction"):
|
||
self._abandon()
|
||
self._state("assets_verified")
|
||
with self.assertRaisesRegex(release_windows.ReleaseError, "locally"):
|
||
self._abandon(tag=True)
|
||
with self.assertRaisesRegex(release_windows.ReleaseError, "origin"):
|
||
self._abandon(remote="abc refs/tags/v0.7.6")
|
||
with self.assertRaisesRegex(release_windows.ReleaseError, "Forgejo"):
|
||
self._abandon(release={"id": 1})
|
||
with self.assertRaisesRegex(release_windows.ReleaseError, "not v0.7.7"):
|
||
self._abandon("0.7.7")
|
||
self.assertTrue(self.state_path.exists())
|
||
self.assertEqual(release_windows.abandoned_versions(), {})
|
||
|
||
def test_next_release_follows_only_abandoned_versions(self) -> None:
|
||
with patch.object(release_windows, "current_app_version", return_value="0.7.5"):
|
||
self.assertEqual(release_windows.release_base("v0.7.5"), "v0.7.5")
|
||
with patch.object(release_windows, "current_app_version", return_value="0.7.6"):
|
||
with self.assertRaisesRegex(release_windows.ReleaseError, "abandon"):
|
||
release_windows.release_base("v0.7.5")
|
||
release_windows.atomic_json(self.abandoned_path, {"versions": {"0.7.6": {}, "0.7.7": {}}})
|
||
with patch.object(release_windows, "current_app_version", return_value="0.7.7"):
|
||
base = release_windows.release_base("v0.7.5")
|
||
self.assertEqual(base, "v0.7.7")
|
||
self.assertEqual(release_windows.validate_next_stable_version(base, "0.7.8"), "0.7.8")
|
||
with self.assertRaises(release_windows.ReleaseError):
|
||
release_windows.validate_next_stable_version(base, "0.7.9")
|
||
|
||
|
||
class AppVersionTests(unittest.TestCase):
|
||
def test_version_update_changes_exactly_one_assignment(self) -> None:
|
||
with tempfile.TemporaryDirectory() as directory:
|
||
constants = Path(directory) / "constants.py"
|
||
constants.write_text('APP_NAME = "x"\nAPP_VERSION = "0.4.83"\n', encoding="utf-8")
|
||
with patch.object(release_windows, "CONSTANTS_PATH", constants):
|
||
self.assertEqual(release_windows.current_app_version(), "0.4.83")
|
||
release_windows.set_app_version("0.4.84")
|
||
self.assertEqual(release_windows.current_app_version(), "0.4.84")
|
||
|
||
|
||
class BuildPayloadTests(unittest.TestCase):
|
||
def _make_clean_payload(self, root: Path) -> Path:
|
||
app_dir = root / "dist" / "ZapretKVN"
|
||
(app_dir / "data" / "templates" / "xray").mkdir(parents=True)
|
||
(app_dir / "data" / "templates" / "xray" / "default.json").write_text(
|
||
"{}\n", encoding="utf-8"
|
||
)
|
||
for relative in build.SINGBOX_RULE_SET_RELATIVE_PATHS:
|
||
path = app_dir / "core" / relative
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
path.write_bytes(b"srs")
|
||
return app_dir
|
||
|
||
def test_clean_payload_allows_only_source_owned_templates_under_data(self) -> None:
|
||
with tempfile.TemporaryDirectory() as directory:
|
||
app_dir = self._make_clean_payload(Path(directory))
|
||
build.assert_clean_payload(app_dir)
|
||
|
||
def test_clean_payload_rejects_runtime_data_and_orphan_files(self) -> None:
|
||
forbidden = ("state.enc", "configs", "runtime", "logs")
|
||
for name in forbidden:
|
||
with self.subTest(name=name), tempfile.TemporaryDirectory() as directory:
|
||
app_dir = self._make_clean_payload(Path(directory))
|
||
path = app_dir / "data" / name
|
||
if "." in name:
|
||
path.write_bytes(b"runtime")
|
||
else:
|
||
path.mkdir()
|
||
with self.assertRaisesRegex(RuntimeError, "runtime data"):
|
||
build.assert_clean_payload(app_dir)
|
||
|
||
with tempfile.TemporaryDirectory() as directory:
|
||
app_dir = self._make_clean_payload(Path(directory))
|
||
(app_dir / "old-orphan.bin").write_bytes(b"stale")
|
||
with self.assertRaisesRegex(RuntimeError, "unexpected top-level"):
|
||
build.assert_clean_payload(app_dir)
|
||
|
||
def test_clean_payload_requires_all_local_singbox_rule_sets(self) -> None:
|
||
with tempfile.TemporaryDirectory() as directory:
|
||
app_dir = self._make_clean_payload(Path(directory))
|
||
(app_dir / "core" / build.SINGBOX_RULE_SET_RELATIVE_PATHS[0]).unlink()
|
||
with self.assertRaisesRegex(RuntimeError, "missing bundled sing-box rule-set"):
|
||
build.assert_clean_payload(app_dir)
|
||
|
||
@staticmethod
|
||
def _routing_archive() -> bytes:
|
||
output = io.BytesIO()
|
||
with zipfile.ZipFile(output, "w") as archive:
|
||
archive.writestr(
|
||
"snapshot/sing-box/rule-set-geosite/geosite-ru-blocked.srs",
|
||
b"blocked-domain",
|
||
)
|
||
archive.writestr(
|
||
"snapshot/sing-box/rule-set-geoip/geoip-ru-blocked.srs",
|
||
b"blocked-ip",
|
||
)
|
||
archive.writestr(
|
||
"snapshot/sing-box/rule-set-geosite/geosite-category-ru.srs",
|
||
b"direct-domain",
|
||
)
|
||
archive.writestr(
|
||
"snapshot/sing-box/rule-set-geoip/geoip-ru.srs",
|
||
b"direct-ip",
|
||
)
|
||
return output.getvalue()
|
||
|
||
def test_build_downloads_locked_rule_sets_into_core_and_replaces_stale_data(self) -> None:
|
||
archive_bytes = self._routing_archive()
|
||
archive_sha256 = hashlib.sha256(archive_bytes).hexdigest()
|
||
|
||
class Response:
|
||
def __enter__(self):
|
||
return self
|
||
|
||
def __exit__(self, *_args):
|
||
return None
|
||
|
||
def read(self, size=-1):
|
||
nonlocal archive_bytes
|
||
if not archive_bytes:
|
||
return b""
|
||
chunk, archive_bytes = archive_bytes[:size], archive_bytes[size:]
|
||
return chunk
|
||
|
||
with tempfile.TemporaryDirectory() as directory:
|
||
root = Path(directory)
|
||
lock = json.loads(build.CORE_LOCK_PATH.read_text(encoding="utf-8"))
|
||
source = next(item for item in lock["sources"] if item["id"] == build.ROUTING_SOURCE_ID)
|
||
source["sha256"] = archive_sha256
|
||
lock_path = root / "core-lock.json"
|
||
lock_path.write_text(json.dumps(lock), encoding="utf-8")
|
||
core_dir = root / "core"
|
||
stale = core_dir / "rule-set" / "stale.srs"
|
||
stale.parent.mkdir(parents=True)
|
||
stale.write_bytes(b"stale")
|
||
|
||
with (
|
||
patch.object(build, "urlopen", return_value=Response()) as request,
|
||
patch.object(build, "DOWNLOAD_CACHE", root / "cache"),
|
||
):
|
||
destination = build.stage_singbox_rule_sets(lock_path, core_dir)
|
||
build.stage_singbox_rule_sets(lock_path, core_dir)
|
||
request.assert_called_once()
|
||
self.assertTrue((root / "cache" / source["archive"]).is_file())
|
||
|
||
self.assertEqual(destination, core_dir / "rule-set")
|
||
self.assertFalse(stale.exists())
|
||
self.assertEqual(
|
||
{path.name for path in destination.iterdir()},
|
||
{relative.name for relative in build.SINGBOX_RULE_SET_RELATIVE_PATHS},
|
||
)
|
||
self.assertTrue(all(path.stat().st_size > 0 for path in destination.iterdir()))
|
||
|
||
def test_clean_removes_the_entire_previous_payload(self) -> None:
|
||
with tempfile.TemporaryDirectory() as directory:
|
||
root = Path(directory)
|
||
dist_dir = root / "dist"
|
||
app_dir = dist_dir / "ZapretKVN"
|
||
(app_dir / "data" / "runtime").mkdir(parents=True)
|
||
(app_dir / "data" / "runtime" / "old.json").write_text("{}")
|
||
(app_dir / "legacy-orphan.bin").write_bytes(b"stale")
|
||
(root / "build" / "old").mkdir(parents=True)
|
||
(dist_dir / "_build_tmp" / "old").mkdir(parents=True)
|
||
|
||
with (
|
||
patch.object(build, "DIST_DIR", dist_dir),
|
||
patch.object(build, "APP_DIR", app_dir),
|
||
patch.object(build, "BUILD_DIR", root / "build"),
|
||
):
|
||
build.clean()
|
||
|
||
self.assertFalse(app_dir.exists())
|
||
self.assertFalse((root / "build").exists())
|
||
self.assertFalse((dist_dir / "_build_tmp").exists())
|
||
|
||
def test_copy_tree_does_not_suppress_permission_error(self) -> None:
|
||
with tempfile.TemporaryDirectory() as directory:
|
||
root = Path(directory)
|
||
source = root / "source"
|
||
source.mkdir()
|
||
destination = root / "destination"
|
||
with patch.object(build.shutil, "copytree", side_effect=PermissionError("locked")):
|
||
with self.assertRaisesRegex(RuntimeError, "Cannot stage build files"):
|
||
build._copy_tree_strict(source, destination)
|
||
|
||
def test_remove_path_does_not_suppress_permission_error(self) -> None:
|
||
with tempfile.TemporaryDirectory() as directory:
|
||
path = Path(directory) / "locked"
|
||
path.mkdir()
|
||
with patch.object(build.shutil, "rmtree", side_effect=PermissionError("locked")):
|
||
with self.assertRaisesRegex(RuntimeError, "Cannot remove build path"):
|
||
build._remove_path_strict(path)
|
||
|
||
def test_remove_previous_payload_with_readonly_module_license(self) -> None:
|
||
with tempfile.TemporaryDirectory() as directory:
|
||
payload = Path(directory) / "previous-payload"
|
||
(payload / "core").mkdir(parents=True)
|
||
license_file = payload / "core" / "LICENSE-Amnezia.txt"
|
||
license_file.write_text("test license", encoding="utf-8")
|
||
license_file.chmod(stat.S_IREAD)
|
||
try:
|
||
build._remove_path_strict(payload)
|
||
self.assertFalse(payload.exists())
|
||
finally:
|
||
if license_file.exists():
|
||
license_file.chmod(stat.S_IREAD | stat.S_IWRITE)
|
||
|
||
def test_release_gate_asserts_clean_payload(self) -> None:
|
||
source = (Path(__file__).parents[1] / "scripts" / "release_windows_gate.ps1").read_text(
|
||
encoding="utf-8"
|
||
)
|
||
self.assertIn("function Assert-CleanPayload", source)
|
||
self.assertIn("only data/templates is allowed", source)
|
||
self.assertIn("Release payload is missing bundled sing-box rule-set", source)
|
||
self.assertIn('Invoke-Native $singbox @(\"check\", \"-D\"', source)
|
||
self.assertIn("Assert-CleanPayload $RepoRoot", source)
|
||
self.assertIn("function Assert-StableCoreLock", source)
|
||
self.assertIn("release_prerelease", source)
|
||
|
||
def test_release_gate_stops_every_shipped_process(self) -> None:
|
||
source = (Path(__file__).parents[1] / "scripts" / "release_windows_gate.ps1").read_text(
|
||
encoding="utf-8"
|
||
)
|
||
names_line = next(line for line in source.splitlines() if "$names = @(" in line)
|
||
# Every executable the payload ships must be stopped before the clean
|
||
# step, otherwise an orphan locks files inside dist\ZapretKVN.
|
||
shipped = {path.stem for path in (Path(__file__).parents[1] / "zapret").rglob("*.exe")}
|
||
for name in {"ZapretKVN", "sing-box", "xray", *shipped}:
|
||
with self.subTest(name=name):
|
||
self.assertIn(f'"{name}"', names_line)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|
||
|
||
|
||
def test_release_is_signed_with_a_key_the_app_knows(tmp_path, monkeypatch) -> None:
|
||
import base64 as _b64
|
||
import os as _os
|
||
|
||
release = release_windows
|
||
from zapret_update import manifest, sign
|
||
|
||
key = tmp_path / "k.key"
|
||
public = _b64.b64decode(sign.generate_key("kvn-test", key))
|
||
_os.chmod(key, 0o600)
|
||
import zipfile as _zipfile
|
||
|
||
with _zipfile.ZipFile(tmp_path / "ZapretKVN-v9.9.9-windows-x64.zip", "w") as bundle:
|
||
bundle.writestr("ZapretKVN.exe", b"exe")
|
||
bundle.writestr("core/sing-box.exe", b"core")
|
||
bundle.writestr("empty/", b"")
|
||
monkeypatch.setattr(release, "SIGNING_KEY_PATH", key)
|
||
# Сборщик хранилища (Python 3.14, сеть) проверяется своими тестами ядра.
|
||
def fake_store(tree, directory, version, keys):
|
||
files = [
|
||
{"path": path.relative_to(tree).as_posix(), "size": path.stat().st_size,
|
||
"sha256": manifest.file_sha256(path), "zsize": 1}
|
||
for path in sorted(tree.rglob("*")) if path.is_file()
|
||
]
|
||
return {"files": files, "patches": [], "names": []}
|
||
|
||
monkeypatch.setattr(release, "build_store", fake_store)
|
||
keys = release._signed_release_module()
|
||
monkeypatch.setattr(release, "_signed_release_module", lambda: type("K", (), {
|
||
"PRODUCT": keys.PRODUCT, "CHANNEL": keys.CHANNEL, "TRUSTED_KEYS": {"kvn-test": public}})())
|
||
|
||
release.sign_release(tmp_path, "9.9.9")
|
||
|
||
assets = release.signed_assets(tmp_path)
|
||
assert [item["name"] for item in assets] == ["update-manifest.json", "update-manifest.json.sig"]
|
||
loaded = manifest.load_signed(
|
||
(tmp_path / "update-manifest.json").read_bytes(), (tmp_path / "update-manifest.json.sig").read_text(),
|
||
trusted={"kvn-test": public}, product="zapret-kvn", channel="stable",
|
||
)
|
||
assert loaded.installer.name == "ZapretKVN-v9.9.9-windows-x64.zip"
|
||
# Список файлов сборки совпадает с содержимым zip: по нему идёт частичное обновление.
|
||
assert {(item.path, item.size) for item in loaded.files} == {("ZapretKVN.exe", 3), ("core/sing-box.exe", 4)}
|
||
|
||
# Ключ, которого программа не знает, выпуск останавливает.
|
||
monkeypatch.setattr(release, "_signed_release_module", lambda: keys)
|
||
import pytest as _pytest
|
||
with _pytest.raises(release.ReleaseError, match="not accepted"):
|
||
release.sign_release(tmp_path, "9.9.9")
|
||
|
||
|
||
def test_app_keys_are_the_owner_keys_when_they_are_on_this_machine() -> None:
|
||
import base64 as _b64
|
||
from pathlib import Path as _Path
|
||
|
||
import pytest as _pytest
|
||
from xray_fluent.updates.signed_release import TRUSTED_KEYS
|
||
from zapret_update import sign
|
||
|
||
folder = _Path.home() / ".config" / "zapret-signing"
|
||
for key_id in ("kvn-2026a", "kvn-reserve-1"):
|
||
path = folder / f"{key_id}.key"
|
||
if not path.is_file():
|
||
_pytest.skip("ключей владельца на этой машине нет")
|
||
assert _b64.b64decode(sign.public_key_of(path)) == TRUSTED_KEYS[key_id]
|
||
|
||
|
||
def test_easter_egg_follows_the_shared_rule() -> None:
|
||
# Само правило — общее ядро zapret_update.release_notes (свои тесты в Zapret 2 GUI).
|
||
from zapret_update import release_notes
|
||
|
||
versions = [f"0.{b}.{c}" for b in range(9, 40) for c in range(60)]
|
||
joked = next(v for v in versions if release_notes.lucky("stable", v))
|
||
plain = next(v for v in versions if not release_notes.lucky("stable", v))
|
||
assert release_windows.published_changes(joked, ["Первое"]) == ["Первое", "Удалена буква «О»"]
|
||
assert release_windows.published_changes(plain, ["Первое"]) == ["Первое"]
|
||
# Большой список — без шутки: настоящие изменения важнее.
|
||
assert release_windows.published_changes(joked, ["Первое", "Второе"]) == ["Первое", "Второе"]
|
||
# Длинный единственный пункт не влезает в предел публикатора — шутка уступает место.
|
||
assert release_windows.published_changes(joked, ["x" * 700]) == ["x" * 700]
|
||
|
||
|
||
|
||
def test_store_prune_keeps_this_and_recent_releases(tmp_path, monkeypatch) -> None:
|
||
import json as _json
|
||
|
||
from zapret_update import publish
|
||
|
||
payload = {"schema": 1, "product": "zapret-kvn", "channel": "stable", "version": "0.8.28", "created": 1, "key": "k",
|
||
"files": [{"path": "ZapretKVN.exe", "size": 1, "sha256": "a" * 64, "zsize": 1}]}
|
||
(tmp_path / "update-manifest.json").write_text(_json.dumps(payload), encoding="utf-8")
|
||
previous = tmp_path / "previous-0.8.27.json"
|
||
previous.write_text(_json.dumps({**payload, "version": "0.8.27", "files": [{**payload["files"][0], "sha256": "b" * 64}]}), encoding="utf-8")
|
||
token = tmp_path / "token"
|
||
token.write_text("secret", encoding="utf-8")
|
||
asked, kept = [], {}
|
||
monkeypatch.setattr(release_windows, "STORE_TOKEN_PATH", token)
|
||
monkeypatch.setattr(release_windows, "_signed_release_module", lambda: object())
|
||
monkeypatch.setattr(release_windows, "previous_manifests", lambda keys, version, directory, **kw: asked.append(kw) or [previous])
|
||
monkeypatch.setattr(publish, "prune", lambda keep, **kw: kept.update(keep=keep, **kw) or 0)
|
||
|
||
release_windows.prune_store(tmp_path, "0.8.28")
|
||
|
||
assert asked == [{"limit": release_windows.KEEP_RELEASES, "strict": True}]
|
||
assert [m.version for m in kept["keep"]] == ["0.8.28", "0.8.27"]
|
||
assert kept["owner"] == "zapretkvn" and kept["product"] == "zapret-kvn" and kept["token"] == "secret"
|