tests: cover phase 1 commands end to end

Pytest suite with a tiny_client fixture, an ephemeral Ed25519 keypair
fixture, and a threaded HTTPServer helper. Exercises cli dispatch,
inspect (including excluded-path handling), build-manifest and sign
against the real m2dev-client scripts, diff-remote via a local server,
and the full release publish composite against a local rsync target.
This commit is contained in:
Jan Nedbal
2026-04-14 18:59:50 +02:00
parent e70fc300e2
commit d2dd2c88b6
13 changed files with 498 additions and 0 deletions

66
tests/conftest.py Normal file
View File

@@ -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

29
tests/fixtures/http_server.py vendored Normal file
View File

@@ -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:<free port>, 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)

1
tests/fixtures/tiny_client/Metin2.exe vendored Normal file
View File

@@ -0,0 +1 @@
fake main executable content

View File

@@ -0,0 +1 @@
fake launcher content

1
tests/fixtures/tiny_client/readme.txt vendored Normal file
View File

@@ -0,0 +1 @@
tiny client fixture for tests

View File

@@ -0,0 +1 @@
asset payload bytes

View File

@@ -0,0 +1 @@
second asset payload

View File

@@ -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)

View File

@@ -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

71
tests/test_diff_remote.py Normal file
View File

@@ -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

42
tests/test_inspect.py Normal file
View File

@@ -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

View File

@@ -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"] == []

73
tests/test_sign.py Normal file
View File

@@ -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)