From 33b0c212925d1a2edcea704d72fc34364613166c Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Mon, 27 Jul 2026 18:29:43 +0200 Subject: [PATCH] test(v2): define protocol and clean-break contracts --- tests/contract/test_no_v1.py | 38 ++++++++++ tests/contract/test_repository_format.py | 92 ++++++++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 tests/contract/test_no_v1.py create mode 100644 tests/contract/test_repository_format.py diff --git a/tests/contract/test_no_v1.py b/tests/contract/test_no_v1.py new file mode 100644 index 0000000..7691090 --- /dev/null +++ b/tests/contract/test_no_v1.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from pathlib import Path +import subprocess +import sys + + +ROOT = Path(__file__).parents[2] + + +def test_v1_executable_packages_are_absent() -> None: + assert not (ROOT / "backend" / "app").exists() + assert not (ROOT / "backend" / "backup").exists() + assert not (ROOT / "backend" / "tests").exists() + assert not (ROOT / "frontend" / "src" / "api" / "client.ts").exists() + + +def test_v1_reference_tag_exists() -> None: + result = subprocess.run( + ["git", "tag", "--list", "v1-reference-2026-07-27"], + cwd=ROOT, + capture_output=True, + check=False, + text=True, + ) + assert result.returncode == 0 + assert result.stdout.strip() == "v1-reference-2026-07-27" + + +def test_forbidden_v1_scanner_accepts_runtime_tree() -> None: + result = subprocess.run( + [sys.executable, "tools/forbidden_v1_scan.py", "."], + cwd=ROOT, + capture_output=True, + check=False, + text=True, + ) + assert result.returncode == 0, result.stdout + result.stderr diff --git a/tests/contract/test_repository_format.py b/tests/contract/test_repository_format.py new file mode 100644 index 0000000..1fddcff --- /dev/null +++ b/tests/contract/test_repository_format.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import jsonschema +import pytest + + +ROOT = Path(__file__).parents[2] +CONTRACT = ROOT / "contracts" / "repository" / "v1" +FIXTURES = CONTRACT / "fixtures" + + +def load(path: Path) -> Any: + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise AssertionError(f"invalid contract JSON: {path}") from error + + +def test_repository_and_manifest_golden_files_validate() -> None: + for name in ("repository", "manifest"): + schema = load(CONTRACT / f"{name}.schema.json") + fixture = load(FIXTURES / f"valid-{name}.json") + jsonschema.Draft202012Validator.check_schema(schema) + jsonschema.validate(fixture, schema) + + +def test_invalid_golden_files_are_rejected() -> None: + for name in ("repository", "manifest"): + schema = load(CONTRACT / f"{name}.schema.json") + fixture = load(FIXTURES / f"invalid-{name}.json") + with pytest.raises(jsonschema.ValidationError): + jsonschema.validate(fixture, schema) + + +def test_valid_golden_files_are_canonical_json() -> None: + for path in sorted(FIXTURES.glob("valid-*.json")): + data = load(path) + canonical = json.dumps(data, sort_keys=True, separators=(",", ":")) + "\n" + assert path.read_text(encoding="utf-8") == canonical + + +def test_protocol_contracts_are_versioned_and_closed() -> None: + repository_schema = load(CONTRACT / "repository.schema.json") + manifest_schema = load(CONTRACT / "manifest.schema.json") + + assert repository_schema["properties"]["format_version"]["const"] == 1 + assert manifest_schema["properties"]["format_version"]["const"] == 1 + assert not repository_schema["additionalProperties"] + assert not manifest_schema["additionalProperties"] + + +def test_normalized_path_vectors_are_relative_posix_paths() -> None: + vectors = load(CONTRACT / "normalized-paths.json") + valid = [case for case in vectors if case["valid"]] + invalid = [case for case in vectors if not case["valid"]] + + assert {case["raw"] for case in invalid} >= {"/absolute", "../escape", "a\\b"} + for case in valid: + normalized = case["normalized"] + assert normalized and not normalized.startswith("/") + assert "\\" not in normalized + assert ".." not in normalized.split("/") + + +def test_state_errors_capabilities_and_fault_points_are_frozen() -> None: + transitions = load(CONTRACT / "execution-transitions.json") + assert transitions["queued"] == ["cancelled", "preparing"] + assert transitions["verifying"] == ["committed", "failed"] + assert transitions["committed"] == [] + + errors = load(CONTRACT / "error-codes.json") + assert len(errors) == len(set(errors)) + assert all(code == code.lower() and " " not in code for code in errors) + + capabilities = load(CONTRACT / "capabilities-v2.0.json") + assert capabilities["sources"] == ["local", "ssh"] + assert not capabilities["features"]["tar_download"] + assert not capabilities["features"]["postgresql"] + assert not capabilities["features"]["mysql"] + + fault_points = load(CONTRACT / "fault-points.json") + assert { + "blob.before_write", + "blob.after_fsync", + "manifest.before_publish", + "metadata.before_commit", + "restore.before_replace", + }.issubset(fault_points)