diff --git a/tests/mcp/test_tool_dispatch.py b/tests/mcp/test_tool_dispatch.py index b2a3d2d..6bd9dd7 100644 --- a/tests/mcp/test_tool_dispatch.py +++ b/tests/mcp/test_tool_dispatch.py @@ -199,6 +199,80 @@ def test_dispatch_invalid_input_returns_wrapper_error(): assert envelope["error"]["code"] == "invalid_tool_input" +def test_m2pack_build_translates_all_flags(): + spec = TOOLS_BY_NAME["m2pack_build"] + argv = build_cli_args( + spec, + { + "input": "/src", + "output": "/out/a.m2p", + "key": "/ck", + "sign_secret_key": "/sk", + "key_id": 2, + }, + ) + assert argv[0:3] == ["m2pack", "build", "--json"] + assert "--input" in argv and argv[argv.index("--input") + 1] == "/src" + assert "--output" in argv and argv[argv.index("--output") + 1] == "/out/a.m2p" + assert "--sign-secret-key" in argv + assert argv[argv.index("--sign-secret-key") + 1] == "/sk" + assert "--key-id" in argv and argv[argv.index("--key-id") + 1] == "2" + + +def test_m2pack_verify_omits_optional_keys(): + spec = TOOLS_BY_NAME["m2pack_verify"] + argv = build_cli_args(spec, {"archive": "/a.m2p"}) + assert argv == ["m2pack", "verify", "--json", "--archive", "/a.m2p"] + + +def test_m2pack_diff_requires_both_sides(): + from metin_release_mcp.errors import InvalidToolInputError + + spec = TOOLS_BY_NAME["m2pack_diff"] + with pytest.raises(InvalidToolInputError): + build_cli_args(spec, {"left": "/L"}) + argv = build_cli_args(spec, {"left": "/L", "right": "/R"}) + assert argv == ["m2pack", "diff", "--json", "--left", "/L", "--right", "/R"] + + +def test_m2pack_export_runtime_key_with_format(): + spec = TOOLS_BY_NAME["m2pack_export_runtime_key"] + argv = build_cli_args( + spec, + { + "key": "/ck", + "public_key": "/pk", + "output": "/out", + "format": "blob", + "key_id": 4, + }, + ) + assert argv[0:3] == ["m2pack", "export-runtime-key", "--json"] + assert "--format" in argv and argv[argv.index("--format") + 1] == "blob" + assert "--key-id" in argv and argv[argv.index("--key-id") + 1] == "4" + + +def test_dispatch_m2pack_build_through_fake_runner(stub_runner): + runner = stub_runner( + {"ok": True, "command": "m2pack build", "status": "built", "artifacts": {"archive_path": "/x.m2p"}} + ) + envelope, _ = server.dispatch( + "m2pack_build", + { + "input": "/src", + "output": "/x.m2p", + "key": "/ck", + "sign_secret_key": "/sk", + }, + ) + assert envelope["ok"] is True + call = runner.calls[0] + assert "m2pack" in call and "build" in call + assert "--json" in call + assert "--input" in call and "/src" in call + assert "--sign-secret-key" in call and "/sk" in call + + def test_dispatch_unparseable_output_returns_wrapper_error(monkeypatch): def boom(argv_tail): from metin_release_mcp.errors import CliUnparseableOutputError diff --git a/tests/mcp/test_tool_schema.py b/tests/mcp/test_tool_schema.py index aa9ad8e..a5af77f 100644 --- a/tests/mcp/test_tool_schema.py +++ b/tests/mcp/test_tool_schema.py @@ -10,6 +10,10 @@ from metin_release.commands import ( build_manifest, diff_remote, inspect, + m2pack_build, + m2pack_diff, + m2pack_export_runtime_key, + m2pack_verify, promote, publish, sign, @@ -35,17 +39,21 @@ EXPECTED_TOOL_NAMES = { "release_promote", "release_verify_public", "release_publish", + "m2pack_build", + "m2pack_verify", + "m2pack_diff", + "m2pack_export_runtime_key", } -def test_tool_catalogue_is_exactly_the_phase_one_set(): +def test_tool_catalogue_is_exactly_the_phase_one_plus_four_set(): assert {t.name for t in TOOL_SPECS} == EXPECTED_TOOL_NAMES - assert len(TOOL_SPECS) == 8 + assert len(TOOL_SPECS) == 12 -def test_every_tool_has_release_subcommand_and_description(): +def test_every_tool_has_known_group_and_description(): for spec in TOOL_SPECS: - assert spec.subcommand[0] == "release" + assert spec.subcommand[0] in {"release", "m2pack"} assert spec.description.strip() schema = json_schema(spec) assert schema["type"] == "object" @@ -82,6 +90,10 @@ _COMMAND_MODULES = { "release_promote": promote, "release_verify_public": verify_public, "release_publish": publish, + "m2pack_build": m2pack_build, + "m2pack_verify": m2pack_verify, + "m2pack_diff": m2pack_diff, + "m2pack_export_runtime_key": m2pack_export_runtime_key, } @@ -121,7 +133,7 @@ def test_unknown_tool_rejected_by_dispatch(): def test_unknown_tool_name_not_in_catalogue(): assert "release_rollback" not in TOOLS_BY_NAME assert "erp_reserve" not in TOOLS_BY_NAME - assert "m2pack_build" not in TOOLS_BY_NAME + assert "launcher_publish" not in TOOLS_BY_NAME def test_build_cli_args_rejects_missing_required(): diff --git a/tests/test_m2pack_binary.py b/tests/test_m2pack_binary.py new file mode 100644 index 0000000..0964662 --- /dev/null +++ b/tests/test_m2pack_binary.py @@ -0,0 +1,68 @@ +"""Tests for the m2pack binary discovery helper.""" + +from __future__ import annotations + +import os +import stat +from pathlib import Path + +import pytest + +from metin_release.errors import ValidationError +from metin_release.m2pack_binary import ENV_VAR, resolve_m2pack_binary + + +def _make_stub(tmp_path: Path, name: str = "m2pack") -> Path: + stub = tmp_path / name + stub.write_text("#!/bin/sh\necho '{}'\n") + stub.chmod(stub.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) + return stub + + +def test_env_var_override_wins(tmp_path: Path, monkeypatch): + stub = _make_stub(tmp_path) + # Scrub PATH so shutil.which can't resolve m2pack + monkeypatch.setenv("PATH", "/nonexistent") + monkeypatch.setenv(ENV_VAR, str(stub)) + resolved = resolve_m2pack_binary() + assert resolved == stub.resolve() + + +def test_env_var_empty_falls_through_to_path(tmp_path: Path, monkeypatch): + stub = _make_stub(tmp_path) + monkeypatch.setenv(ENV_VAR, " ") # blank-ish + monkeypatch.setenv("PATH", str(tmp_path)) + resolved = resolve_m2pack_binary() + assert resolved == stub.resolve() + + +def test_env_var_pointing_nowhere_raises(tmp_path: Path, monkeypatch): + monkeypatch.setenv(ENV_VAR, str(tmp_path / "does-not-exist")) + monkeypatch.setenv("PATH", "/nonexistent") + with pytest.raises(ValidationError) as exc: + resolve_m2pack_binary() + assert exc.value.error_code == "m2pack_not_found" + + +def test_path_fallback_used_when_env_unset(tmp_path: Path, monkeypatch): + stub = _make_stub(tmp_path) + monkeypatch.delenv(ENV_VAR, raising=False) + monkeypatch.setenv("PATH", str(tmp_path)) + resolved = resolve_m2pack_binary() + assert resolved == stub.resolve() + + +def test_missing_binary_raises_validation_error(monkeypatch): + monkeypatch.delenv(ENV_VAR, raising=False) + monkeypatch.setenv("PATH", "/nonexistent") + with pytest.raises(ValidationError) as exc: + resolve_m2pack_binary() + assert exc.value.error_code == "m2pack_not_found" + assert "M2PACK_BINARY" in str(exc.value) + + +def test_custom_env_mapping_parameter(tmp_path: Path): + stub = _make_stub(tmp_path) + fake_env = {ENV_VAR: str(stub)} + resolved = resolve_m2pack_binary(env=fake_env) + assert resolved == stub.resolve() diff --git a/tests/test_m2pack_commands.py b/tests/test_m2pack_commands.py new file mode 100644 index 0000000..da6eb9d --- /dev/null +++ b/tests/test_m2pack_commands.py @@ -0,0 +1,307 @@ +"""Tests for the m2pack wrapper subcommands. + +All tests use a stub binary — a small Python script that echoes a canned +JSON envelope based on the subcommand name — pointed at via the +``M2PACK_BINARY`` env var. The real m2pack-secure binary is never invoked. +""" + +from __future__ import annotations + +import json +import stat +import sys +from pathlib import Path + +import pytest + +from metin_release.cli import main as cli_main + + +STUB_TEMPLATE = r"""#!{python} +import json +import sys + +argv = sys.argv[1:] +sub = argv[0] if argv else "unknown" +MODE = {mode!r} + +if MODE == "fail": + sys.stderr.write("boom\n") + sys.exit(2) +if MODE == "nonjson": + sys.stdout.write("not json at all\n") + sys.exit(0) +if MODE == "notobject": + sys.stdout.write(json.dumps([1, 2, 3]) + "\n") + sys.exit(0) + +# mode == "ok": echo a canned envelope per subcommand +if sub == "build": + env = {{"ok": True, "command": "build", "stats": {{"files": 3, "bytes": 12345}}}} +elif sub == "verify": + env = {{"ok": True, "command": "verify", "signature": "valid"}} +elif sub == "diff": + env = {{"ok": True, "command": "diff", "added": ["a"], "removed": [], "changed": ["b", "c"], "unchanged": 7}} +elif sub == "export-runtime-key": + env = {{"ok": True, "command": "export-runtime-key", "key_id": 1, "format": "json"}} +else: + env = {{"ok": True, "command": sub}} + +# Record argv so the test can assert translation +import os +log = os.environ.get("M2PACK_STUB_LOG") +if log: + with open(log, "a") as fh: + fh.write(json.dumps(argv) + "\n") + +sys.stdout.write(json.dumps(env) + "\n") +sys.exit(0) +""" + + +def _install_stub(tmp_path: Path, monkeypatch, mode: str = "ok") -> tuple[Path, Path]: + stub = tmp_path / "m2pack_stub.py" + stub.write_text(STUB_TEMPLATE.format(python=sys.executable, mode=mode)) + stub.chmod(stub.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) + log = tmp_path / "stub.log" + monkeypatch.setenv("M2PACK_BINARY", str(stub)) + monkeypatch.setenv("M2PACK_STUB_LOG", str(log)) + return stub, log + + +def _run_cli(argv: list[str], capsys) -> dict: + rc = cli_main(argv) + out = capsys.readouterr().out + envelope = json.loads(out) + return {"rc": rc, "env": envelope} + + +def _read_stub_call(log: Path) -> list[str]: + lines = [ln for ln in log.read_text().splitlines() if ln.strip()] + assert lines, "stub was never invoked" + return json.loads(lines[-1]) + + +# --------------------------------------------------------------------------- +# Success paths +# --------------------------------------------------------------------------- + + +def test_build_success(tmp_path, monkeypatch, capsys): + _, log = _install_stub(tmp_path, monkeypatch) + (tmp_path / "in").mkdir() + key = tmp_path / "k.hex" + key.write_text("ff") + sk = tmp_path / "sk.hex" + sk.write_text("aa") + out = tmp_path / "out.m2p" + + r = _run_cli( + [ + "--json", + "m2pack", + "build", + "--input", str(tmp_path / "in"), + "--output", str(out), + "--key", str(key), + "--sign-secret-key", str(sk), + "--key-id", "7", + ], + capsys, + ) + assert r["rc"] == 0 + assert r["env"]["ok"] is True + assert r["env"]["command"] == "m2pack build" + assert r["env"]["status"] == "built" + assert r["env"]["artifacts"]["archive_path"] == str(out) + assert r["env"]["m2pack"]["stats"]["files"] == 3 + + call = _read_stub_call(log) + assert call[0] == "build" + assert "--input" in call and str(tmp_path / "in") in call + assert "--output" in call and str(out) in call + assert "--key" in call and str(key) in call + assert "--sign-secret-key" in call and str(sk) in call + assert "--key-id" in call and "7" in call + assert "--json" in call + + +def test_build_omits_optional_key_id(tmp_path, monkeypatch, capsys): + _, log = _install_stub(tmp_path, monkeypatch) + (tmp_path / "in").mkdir() + (tmp_path / "k").write_text("a") + (tmp_path / "sk").write_text("b") + _run_cli( + [ + "--json", "m2pack", "build", + "--input", str(tmp_path / "in"), + "--output", str(tmp_path / "o.m2p"), + "--key", str(tmp_path / "k"), + "--sign-secret-key", str(tmp_path / "sk"), + ], + capsys, + ) + call = _read_stub_call(log) + assert "--key-id" not in call + + +def test_verify_success_without_keys(tmp_path, monkeypatch, capsys): + _, log = _install_stub(tmp_path, monkeypatch) + archive = tmp_path / "a.m2p" + archive.write_bytes(b"x") + r = _run_cli( + ["--json", "m2pack", "verify", "--archive", str(archive)], capsys + ) + assert r["rc"] == 0 + assert r["env"]["status"] == "verified" + assert r["env"]["m2pack"]["signature"] == "valid" + call = _read_stub_call(log) + assert call[0] == "verify" + assert "--public-key" not in call + assert "--key" not in call + + +def test_verify_with_public_and_content_keys(tmp_path, monkeypatch, capsys): + _, log = _install_stub(tmp_path, monkeypatch) + archive = tmp_path / "a.m2p" + archive.write_bytes(b"x") + pk = tmp_path / "pub" + pk.write_text("ff") + ck = tmp_path / "ck" + ck.write_text("aa") + _run_cli( + [ + "--json", "m2pack", "verify", + "--archive", str(archive), + "--public-key", str(pk), + "--key", str(ck), + ], + capsys, + ) + call = _read_stub_call(log) + assert "--public-key" in call and str(pk) in call + assert "--key" in call and str(ck) in call + + +def test_diff_success_promotes_counts(tmp_path, monkeypatch, capsys): + _, log = _install_stub(tmp_path, monkeypatch) + left = tmp_path / "L" + left.mkdir() + right = tmp_path / "R.m2p" + right.write_bytes(b"x") + r = _run_cli( + [ + "--json", "m2pack", "diff", + "--left", str(left), + "--right", str(right), + ], + capsys, + ) + assert r["rc"] == 0 + assert r["env"]["status"] == "diffed" + stats = r["env"]["stats"] + assert stats["added_count"] == 1 + assert stats["removed_count"] == 0 + assert stats["changed_count"] == 2 + assert stats["unchanged_count"] == 7 + call = _read_stub_call(log) + assert call[0] == "diff" + + +def test_export_runtime_key_success(tmp_path, monkeypatch, capsys): + _, log = _install_stub(tmp_path, monkeypatch) + key = tmp_path / "ck" + key.write_text("aa") + pk = tmp_path / "pk" + pk.write_text("ff") + out = tmp_path / "runtime.json" + r = _run_cli( + [ + "--json", "m2pack", "export-runtime-key", + "--key", str(key), + "--public-key", str(pk), + "--output", str(out), + "--key-id", "3", + "--format", "blob", + ], + capsys, + ) + assert r["rc"] == 0 + assert r["env"]["status"] == "exported" + assert r["env"]["artifacts"]["runtime_key_path"] == str(out) + call = _read_stub_call(log) + assert call[0] == "export-runtime-key" + assert "--format" in call and "blob" in call + assert "--key-id" in call and "3" in call + + +def test_export_runtime_key_rejects_bad_format(tmp_path, monkeypatch, capsys): + _install_stub(tmp_path, monkeypatch) + with pytest.raises(SystemExit): + cli_main( + [ + "--json", "m2pack", "export-runtime-key", + "--key", str(tmp_path / "k"), + "--public-key", str(tmp_path / "p"), + "--output", str(tmp_path / "o"), + "--format", "yaml", + ] + ) + + +# --------------------------------------------------------------------------- +# Error paths +# --------------------------------------------------------------------------- + + +def test_nonzero_exit_maps_to_subprocess_failed(tmp_path, monkeypatch, capsys): + _install_stub(tmp_path, monkeypatch, mode="fail") + (tmp_path / "in").mkdir() + (tmp_path / "k").write_text("a") + (tmp_path / "sk").write_text("b") + rc = cli_main( + [ + "--json", "m2pack", "build", + "--input", str(tmp_path / "in"), + "--output", str(tmp_path / "o.m2p"), + "--key", str(tmp_path / "k"), + "--sign-secret-key", str(tmp_path / "sk"), + ], + ) + assert rc == 1 + env = json.loads(capsys.readouterr().out) + assert env["ok"] is False + assert env["error"]["code"] == "m2pack_failed" + + +def test_nonjson_output_maps_to_invalid_json(tmp_path, monkeypatch, capsys): + _install_stub(tmp_path, monkeypatch, mode="nonjson") + archive = tmp_path / "a.m2p" + archive.write_bytes(b"x") + rc = cli_main(["--json", "m2pack", "verify", "--archive", str(archive)]) + assert rc == 1 + env = json.loads(capsys.readouterr().out) + assert env["ok"] is False + assert env["error"]["code"] == "m2pack_invalid_json" + + +def test_json_array_output_maps_to_invalid_json(tmp_path, monkeypatch, capsys): + _install_stub(tmp_path, monkeypatch, mode="notobject") + archive = tmp_path / "a.m2p" + archive.write_bytes(b"x") + rc = cli_main(["--json", "m2pack", "verify", "--archive", str(archive)]) + assert rc == 1 + env = json.loads(capsys.readouterr().out) + assert env["error"]["code"] == "m2pack_invalid_json" + + +def test_missing_binary_raises_validation_error(tmp_path, monkeypatch, capsys): + monkeypatch.delenv("M2PACK_BINARY", raising=False) + monkeypatch.setenv("PATH", "/nonexistent") + archive = tmp_path / "a.m2p" + archive.write_bytes(b"x") + rc = cli_main(["--json", "m2pack", "verify", "--archive", str(archive)]) + assert rc == 1 + env = json.loads(capsys.readouterr().out) + assert env["ok"] is False + assert env["error"]["code"] == "m2pack_not_found"