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:
66
tests/conftest.py
Normal file
66
tests/conftest.py
Normal 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
29
tests/fixtures/http_server.py
vendored
Normal 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
1
tests/fixtures/tiny_client/Metin2.exe
vendored
Normal file
@@ -0,0 +1 @@
|
||||
fake main executable content
|
||||
1
tests/fixtures/tiny_client/Metin2Launcher.exe
vendored
Normal file
1
tests/fixtures/tiny_client/Metin2Launcher.exe
vendored
Normal file
@@ -0,0 +1 @@
|
||||
fake launcher content
|
||||
1
tests/fixtures/tiny_client/readme.txt
vendored
Normal file
1
tests/fixtures/tiny_client/readme.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
tiny client fixture for tests
|
||||
1
tests/fixtures/tiny_client/subdir/asset.bin
vendored
Normal file
1
tests/fixtures/tiny_client/subdir/asset.bin
vendored
Normal file
@@ -0,0 +1 @@
|
||||
asset payload bytes
|
||||
1
tests/fixtures/tiny_client/subdir/other.dat
vendored
Normal file
1
tests/fixtures/tiny_client/subdir/other.dat
vendored
Normal file
@@ -0,0 +1 @@
|
||||
second asset payload
|
||||
73
tests/test_build_manifest.py
Normal file
73
tests/test_build_manifest.py
Normal 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)
|
||||
56
tests/test_cli_dispatch.py
Normal file
56
tests/test_cli_dispatch.py
Normal 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
71
tests/test_diff_remote.py
Normal 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
42
tests/test_inspect.py
Normal 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
|
||||
83
tests/test_publish_composite.py
Normal file
83
tests/test_publish_composite.py
Normal 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
73
tests/test_sign.py
Normal 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)
|
||||
Reference in New Issue
Block a user