diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..1483a6e --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import os +import shutil +import sys +from pathlib import Path + +import pytest + +# Ensure editable install's src path is on sys.path when running from checkout. +ROOT = Path(__file__).resolve().parents[1] +SRC = ROOT / "src" +if str(SRC) not in sys.path: + sys.path.insert(0, str(SRC)) + + +@pytest.fixture +def tiny_client(tmp_path: Path) -> Path: + """Copy the checked-in tiny_client fixture into a tmp dir and return it.""" + src = Path(__file__).parent / "fixtures" / "tiny_client" + dst = tmp_path / "client" + shutil.copytree(src, dst) + return dst + + +@pytest.fixture +def make_manifest_script() -> Path: + return Path("/home/jann/metin/repos/m2dev-client/scripts/make-manifest.py") + + +@pytest.fixture +def sign_manifest_script() -> Path: + return Path("/home/jann/metin/repos/m2dev-client/scripts/sign-manifest.py") + + +@pytest.fixture(autouse=True) +def reset_env(monkeypatch): + # Don't let ambient env vars bleed into tests. + for key in ("METIN_RELEASE_MAKE_MANIFEST", "METIN_RELEASE_SIGN_MANIFEST"): + monkeypatch.delenv(key, raising=False) + yield + + +@pytest.fixture +def test_keypair(tmp_path: Path) -> tuple[Path, str]: + """Generate an ephemeral Ed25519 keypair, write the private key mode 600. + + Returns (private_key_path, public_key_hex). + """ + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + + priv = Ed25519PrivateKey.generate() + raw = priv.private_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PrivateFormat.Raw, + encryption_algorithm=serialization.NoEncryption(), + ) + key_path = tmp_path / "key" + key_path.write_bytes(raw) + os.chmod(key_path, 0o600) + pub_hex = priv.public_key().public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ).hex() + return key_path, pub_hex diff --git a/tests/fixtures/http_server.py b/tests/fixtures/http_server.py new file mode 100644 index 0000000..c9aed8a --- /dev/null +++ b/tests/fixtures/http_server.py @@ -0,0 +1,29 @@ +"""Minimal threaded HTTP server for diff-remote / verify-public tests.""" + +from __future__ import annotations + +import contextlib +import threading +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Iterator + + +class _Handler(SimpleHTTPRequestHandler): + def log_message(self, format, *args): # noqa: A002 + pass # silence + + +@contextlib.contextmanager +def serve_dir(root: Path) -> Iterator[str]: + """Serve `root` on 127.0.0.1:, yield the base URL.""" + handler = lambda *a, **kw: _Handler(*a, directory=str(root), **kw) # noqa: E731 + server = ThreadingHTTPServer(("127.0.0.1", 0), handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield f"http://127.0.0.1:{server.server_port}" + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) diff --git a/tests/fixtures/tiny_client/Metin2.exe b/tests/fixtures/tiny_client/Metin2.exe new file mode 100644 index 0000000..35ac4f3 --- /dev/null +++ b/tests/fixtures/tiny_client/Metin2.exe @@ -0,0 +1 @@ +fake main executable content diff --git a/tests/fixtures/tiny_client/Metin2Launcher.exe b/tests/fixtures/tiny_client/Metin2Launcher.exe new file mode 100644 index 0000000..a2e2838 --- /dev/null +++ b/tests/fixtures/tiny_client/Metin2Launcher.exe @@ -0,0 +1 @@ +fake launcher content diff --git a/tests/fixtures/tiny_client/readme.txt b/tests/fixtures/tiny_client/readme.txt new file mode 100644 index 0000000..09690f0 --- /dev/null +++ b/tests/fixtures/tiny_client/readme.txt @@ -0,0 +1 @@ +tiny client fixture for tests diff --git a/tests/fixtures/tiny_client/subdir/asset.bin b/tests/fixtures/tiny_client/subdir/asset.bin new file mode 100644 index 0000000..7336b1d --- /dev/null +++ b/tests/fixtures/tiny_client/subdir/asset.bin @@ -0,0 +1 @@ +asset payload bytes diff --git a/tests/fixtures/tiny_client/subdir/other.dat b/tests/fixtures/tiny_client/subdir/other.dat new file mode 100644 index 0000000..90d72ee --- /dev/null +++ b/tests/fixtures/tiny_client/subdir/other.dat @@ -0,0 +1 @@ +second asset payload diff --git a/tests/test_build_manifest.py b/tests/test_build_manifest.py new file mode 100644 index 0000000..a8dcbb5 --- /dev/null +++ b/tests/test_build_manifest.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +import pytest + +from metin_release.commands import build_manifest +from metin_release.errors import ValidationError +from metin_release.workspace import Context + + +def _ctx(script: Path) -> Context: + return Context(make_manifest_script=script) + + +@pytest.mark.skipif( + not Path("/home/jann/metin/repos/m2dev-client/scripts/make-manifest.py").is_file(), + reason="real make-manifest.py not available", +) +def test_build_manifest_real_script(tiny_client: Path, tmp_path: Path, make_manifest_script: Path): + out = tmp_path / "manifest.json" + ctx = _ctx(make_manifest_script) + args = argparse.Namespace( + source=tiny_client, + version="2026.04.14-1", + out=out, + previous=None, + notes=None, + launcher="Metin2Launcher.exe", + created_at="2026-04-14T00:00:00Z", + ) + result = build_manifest.run(ctx, args) + assert out.is_file() + manifest = json.loads(out.read_text()) + assert manifest["version"] == "2026.04.14-1" + assert manifest["launcher"]["path"] == "Metin2Launcher.exe" + assert len(manifest["files"]) >= 3 # Metin2.exe, readme.txt, subdir/* + stats = result.data["stats"] + assert stats["file_count"] == len(manifest["files"]) + 1 + assert stats["blob_count"] >= 1 + assert stats["total_bytes"] > 0 + + +def test_build_manifest_missing_script(tiny_client: Path, tmp_path: Path): + ctx = _ctx(tmp_path / "nope.py") + args = argparse.Namespace( + source=tiny_client, + version="v1", + out=tmp_path / "manifest.json", + previous=None, + notes=None, + launcher="Metin2Launcher.exe", + created_at=None, + ) + with pytest.raises(ValidationError): + build_manifest.run(ctx, args) + + +def test_build_manifest_missing_source(tmp_path: Path, make_manifest_script: Path): + ctx = _ctx(make_manifest_script) + args = argparse.Namespace( + source=tmp_path / "nope", + version="v1", + out=tmp_path / "manifest.json", + previous=None, + notes=None, + launcher="Metin2Launcher.exe", + created_at=None, + ) + with pytest.raises(ValidationError): + build_manifest.run(ctx, args) diff --git a/tests/test_cli_dispatch.py b/tests/test_cli_dispatch.py new file mode 100644 index 0000000..5f5bfcc --- /dev/null +++ b/tests/test_cli_dispatch.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import json +import subprocess +import sys + + +def _run(*args: str) -> subprocess.CompletedProcess: + return subprocess.run( + [sys.executable, "-m", "metin_release", *args], + capture_output=True, + text=True, + ) + + +def test_version_flag(): + r = _run("--version") + assert r.returncode == 0 + assert "metin-release" in r.stdout + + +def test_help_top_level(): + r = _run("--help") + assert r.returncode == 0 + assert "release" in r.stdout + + +def test_help_release_inspect(): + r = _run("release", "inspect", "--help") + assert r.returncode == 0 + assert "--source" in r.stdout + + +def test_unknown_subcommand_exits_nonzero(): + r = _run("release", "does-not-exist") + assert r.returncode != 0 + + +def test_inspect_json_output_is_pure_json(tmp_path): + (tmp_path / "Metin2.exe").write_text("x") + (tmp_path / "Metin2Launcher.exe").write_text("y") + r = _run("--json", "release", "inspect", "--source", str(tmp_path)) + assert r.returncode == 0 + envelope = json.loads(r.stdout) # must parse cleanly + assert envelope["ok"] is True + assert envelope["command"] == "release inspect" + assert envelope["stats"]["file_count"] == 2 + + +def test_inspect_human_mode_emits_json_on_stdout_and_summary_on_stderr(tmp_path): + (tmp_path / "Metin2.exe").write_text("x") + r = _run("release", "inspect", "--source", str(tmp_path)) + assert r.returncode == 0 + envelope = json.loads(r.stdout) + assert envelope["ok"] is True + assert "release inspect" in r.stderr diff --git a/tests/test_diff_remote.py b/tests/test_diff_remote.py new file mode 100644 index 0000000..ec0ce68 --- /dev/null +++ b/tests/test_diff_remote.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +import pytest + +# Make fixtures importable. +sys.path.insert(0, str(Path(__file__).parent / "fixtures")) +from http_server import serve_dir # noqa: E402 + +from metin_release.commands import diff_remote # noqa: E402 +from metin_release.workspace import Context # noqa: E402 + + +def _write_manifest(path: Path, hashes: list[str]) -> None: + manifest = { + "version": "v1", + "created_at": "2026-04-14T00:00:00Z", + "launcher": {"path": "Metin2Launcher.exe", "sha256": hashes[0], "size": 1}, + "files": [ + {"path": f"file{i}", "sha256": h, "size": 1} + for i, h in enumerate(hashes[1:]) + ], + } + path.write_text(json.dumps(manifest, indent=2)) + + +def test_diff_remote_reports_missing(tmp_path: Path): + hashes = [ + "a" * 64, + "b" * 64, + "c" * 64, + ] + manifest = tmp_path / "manifest.json" + _write_manifest(manifest, hashes) + + # Remote serves only two of the three blobs. + remote = tmp_path / "remote" + remote.mkdir() + for h in hashes[:2]: + d = remote / "files" / h[:2] + d.mkdir(parents=True) + (d / h).write_bytes(b"x") + + with serve_dir(remote) as base_url: + args = argparse.Namespace(manifest=manifest, base_url=base_url, timeout=5.0) + result = diff_remote.run(Context(), args) + + stats = result.data["stats"] + assert stats["manifest_blob_count"] == 3 + assert stats["missing_blob_count"] == 1 + assert stats["missing_blobs"] == [hashes[2]] + + +def test_diff_remote_all_present(tmp_path: Path): + hashes = ["d" * 64] + manifest = tmp_path / "manifest.json" + _write_manifest(manifest, hashes) + + remote = tmp_path / "remote" + (remote / "files" / "dd").mkdir(parents=True) + (remote / "files" / "dd" / hashes[0]).write_bytes(b"x") + + with serve_dir(remote) as base_url: + args = argparse.Namespace(manifest=manifest, base_url=base_url, timeout=5.0) + result = diff_remote.run(Context(), args) + + assert result.data["stats"]["missing_blob_count"] == 0 diff --git a/tests/test_inspect.py b/tests/test_inspect.py new file mode 100644 index 0000000..e211fcd --- /dev/null +++ b/tests/test_inspect.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import argparse +from pathlib import Path + +import pytest + +from metin_release.commands import inspect +from metin_release.errors import SourceNotFoundError +from metin_release.workspace import Context + + +def test_inspect_tiny_client(tiny_client: Path): + ctx = Context() + args = argparse.Namespace(source=tiny_client) + result = inspect.run(ctx, args) + stats = result.data["stats"] + assert stats["file_count"] == 5 + assert stats["launcher_present"] is True + assert stats["main_exe_present"] is True + assert stats["total_bytes"] > 0 + assert stats["source_path"] == str(tiny_client) + + +def test_inspect_missing_source(tmp_path): + ctx = Context() + args = argparse.Namespace(source=tmp_path / "nope") + with pytest.raises(SourceNotFoundError): + inspect.run(ctx, args) + + +def test_inspect_skips_excluded(tmp_path): + (tmp_path / "Metin2.exe").write_text("x") + (tmp_path / "Metin2Launcher.exe").write_text("y") + log_dir = tmp_path / "log" + log_dir.mkdir() + (log_dir / "should_skip.txt").write_text("nope") + (tmp_path / "foo.pdb").write_text("nope") + ctx = Context() + args = argparse.Namespace(source=tmp_path) + result = inspect.run(ctx, args) + assert result.data["stats"]["file_count"] == 2 diff --git a/tests/test_publish_composite.py b/tests/test_publish_composite.py new file mode 100644 index 0000000..8e785ab --- /dev/null +++ b/tests/test_publish_composite.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parent / "fixtures")) +from http_server import serve_dir # noqa: E402 + +from metin_release.commands import publish # noqa: E402 +from metin_release.workspace import Context # noqa: E402 + + +MAKE_MANIFEST = Path("/home/jann/metin/repos/m2dev-client/scripts/make-manifest.py") +SIGN_MANIFEST = Path("/home/jann/metin/repos/m2dev-client/scripts/sign-manifest.py") + + +@pytest.mark.skipif( + not (MAKE_MANIFEST.is_file() and SIGN_MANIFEST.is_file()), + reason="reference scripts not available", +) +def test_publish_end_to_end_local(tiny_client: Path, tmp_path: Path, test_keypair): + key_path, pub_hex = test_keypair + out_dir = tmp_path / "release" + rsync_target_dir = tmp_path / "remote" + rsync_target_dir.mkdir() + rsync_target = str(rsync_target_dir) + "/" + + ctx = Context(make_manifest_script=MAKE_MANIFEST, sign_manifest_script=SIGN_MANIFEST) + + # We need verify-public to hit the rsync target as an HTTP server. + # Start HTTP server pointing at rsync target BEFORE publish runs verify-public. + with serve_dir(rsync_target_dir) as base_url: + args = argparse.Namespace( + source=tiny_client, + version="2026.04.14-1", + out=out_dir, + key=key_path, + rsync_target=rsync_target, + base_url=base_url, + public_key=pub_hex, + previous=None, + notes=None, + launcher="Metin2Launcher.exe", + created_at="2026-04-14T00:00:00Z", + sample_blobs=2, + yes=True, + force=False, + dry_run_upload=False, + ) + result = publish.run(ctx, args) + + assert result.ok is True + assert result.status == "published" + stages = result.data["stages"] + names = [s["name"] for s in stages] + assert names == [ + "build-manifest", + "sign", + "stage-blobs", + "upload-blobs", + "promote", + "verify-public", + ] + for s in stages: + assert "error" not in s + + # Verify that the rsync target now contains manifest + blobs. + assert (rsync_target_dir / "manifest.json").is_file() + assert (rsync_target_dir / "manifest.json.sig").is_file() + assert (rsync_target_dir / "files").is_dir() + blobs = list((rsync_target_dir / "files").rglob("*")) + blob_files = [b for b in blobs if b.is_file()] + assert len(blob_files) >= 3 + + # Verify sample-blobs count reflected + verify_stats = result.data["stats"]["verify"] + assert verify_stats["signature_valid"] is True + assert verify_stats["sampled_blob_count"] == 2 + assert verify_stats["sampled_blob_failures"] == [] diff --git a/tests/test_sign.py b/tests/test_sign.py new file mode 100644 index 0000000..0599c63 --- /dev/null +++ b/tests/test_sign.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path + +import pytest +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + +from metin_release.commands import sign +from metin_release.errors import IntegrityError, KeyPermissionError, ValidationError +from metin_release.workspace import Context + + +def _ctx(script: Path) -> Context: + return Context(sign_manifest_script=script) + + +def _write_toy_manifest(path: Path) -> None: + path.write_bytes(json.dumps({"version": "test", "files": []}, indent=2).encode() + b"\n") + + +@pytest.mark.skipif( + not Path("/home/jann/metin/repos/m2dev-client/scripts/sign-manifest.py").is_file(), + reason="real sign-manifest.py not available", +) +def test_sign_produces_valid_signature(tmp_path: Path, test_keypair, sign_manifest_script: Path): + key_path, pub_hex = test_keypair + manifest_path = tmp_path / "manifest.json" + _write_toy_manifest(manifest_path) + + ctx = _ctx(sign_manifest_script) + args = argparse.Namespace(manifest=manifest_path, key=key_path, out=None) + result = sign.run(ctx, args) + + sig_path = Path(result.data["artifacts"]["signature_path"]) + assert sig_path.is_file() + sig = sig_path.read_bytes() + assert len(sig) == 64 + + # Verify it actually checks out against the public key. + pub = Ed25519PublicKey.from_public_bytes(bytes.fromhex(pub_hex)) + pub.verify(sig, manifest_path.read_bytes()) + + # Now corrupt the manifest; signature no longer verifies. + manifest_path.write_bytes(b"tampered") + with pytest.raises(InvalidSignature): + pub.verify(sig, manifest_path.read_bytes()) + + +def test_sign_rejects_loose_key_permissions(tmp_path: Path, sign_manifest_script: Path): + key = tmp_path / "key" + key.write_bytes(b"\x00" * 32) + os.chmod(key, 0o644) + manifest = tmp_path / "manifest.json" + _write_toy_manifest(manifest) + + ctx = _ctx(sign_manifest_script) + args = argparse.Namespace(manifest=manifest, key=key, out=None) + with pytest.raises(KeyPermissionError): + sign.run(ctx, args) + + +def test_sign_relative_key_path_rejected(tmp_path: Path, sign_manifest_script: Path): + manifest = tmp_path / "manifest.json" + _write_toy_manifest(manifest) + ctx = _ctx(sign_manifest_script) + args = argparse.Namespace(manifest=manifest, key=Path("relative/key"), out=None) + with pytest.raises(ValidationError): + sign.run(ctx, args)