From 92f2aaa27f19f690eff8290abd5bb5cae928c71a Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Mon, 27 Jul 2026 18:52:47 +0200 Subject: [PATCH] test(v2): harden protocol and clean-break contracts --- tests/contract/test_no_v1.py | 38 ++++++++++- tests/contract/test_repository_format.py | 81 ++++++++++++++++++++++-- 2 files changed, 109 insertions(+), 10 deletions(-) diff --git a/tests/contract/test_no_v1.py b/tests/contract/test_no_v1.py index e907778..9d9b7a7 100644 --- a/tests/contract/test_no_v1.py +++ b/tests/contract/test_no_v1.py @@ -4,6 +4,8 @@ import subprocess import sys from pathlib import Path +import pytest + ROOT = Path(__file__).parents[2] @@ -26,12 +28,42 @@ def test_v1_reference_tag_exists() -> None: 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", "."], +def run_scanner(root: Path) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, str(ROOT / "tools/forbidden_v1_scan.py"), str(root)], cwd=ROOT, capture_output=True, check=False, text=True, ) + + +def test_forbidden_v1_scanner_accepts_runtime_tree() -> None: + result = run_scanner(ROOT) assert result.returncode == 0, result.stdout + result.stderr + + +@pytest.mark.parametrize( + ("relative_path", "canary"), + [ + ("backend/src/backup_tool/database.py", 'DB_PATH = "backup_tool.db"'), + ("backend/src/backup_tool/payload.py", "def read_legacy_payload(): ..."), + ("backend/src/backup_tool/importer.py", "class LegacyBackupImporter: ..."), + ("backend/src/backup_tool/converter.py", "def convert_v1_backup(): ..."), + ("backend/src/backup_tool/layout.py", 'FORMAT = "%Y-%m-%d_%H%M%S"'), + ("docker-compose.yml", "command: uvicorn app.main:app"), + ], +) +def test_forbidden_v1_scanner_rejects_runtime_and_config_canaries( + tmp_path: Path, + relative_path: str, + canary: str, +) -> None: + path = tmp_path / relative_path + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(canary, encoding="utf-8") + + result = run_scanner(tmp_path) + + assert result.returncode == 1, relative_path + assert str(relative_path) in result.stderr diff --git a/tests/contract/test_repository_format.py b/tests/contract/test_repository_format.py index 6aed68d..ca09a62 100644 --- a/tests/contract/test_repository_format.py +++ b/tests/contract/test_repository_format.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +from copy import deepcopy from pathlib import Path from typing import Any @@ -19,20 +20,24 @@ def load(path: Path) -> Any: raise AssertionError(f"invalid contract JSON: {path}") from error +def validator(name: str) -> jsonschema.Draft202012Validator: + schema = load(CONTRACT / f"{name}.schema.json") + jsonschema.Draft202012Validator.check_schema(schema) + return jsonschema.Draft202012Validator( + schema, + format_checker=jsonschema.FormatChecker(), + ) + + 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) + validator(name).validate(load(FIXTURES / f"valid-{name}.json")) 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) + validator(name).validate(load(FIXTURES / f"invalid-{name}.json")) def test_valid_golden_files_are_canonical_json() -> None: @@ -52,6 +57,68 @@ def test_protocol_contracts_are_versioned_and_closed() -> None: assert not manifest_schema["additionalProperties"] +def test_uuidv7_and_rfc3339_formats_are_enforced() -> None: + repository = load(FIXTURES / "valid-repository.json") + manifest = load(FIXTURES / "valid-manifest.json") + + repository["repository_id"] = "123e4567-e89b-42d3-a456-426614174000" + with pytest.raises(jsonschema.ValidationError): + validator("repository").validate(repository) + + manifest["created_at"] = "2026-07-27 12:00:00" + with pytest.raises(jsonschema.ValidationError): + validator("manifest").validate(manifest) + + +def test_manifest_signature_and_source_consistency_are_required() -> None: + manifest = load(FIXTURES / "valid-manifest.json") + assert manifest["manifest_signature"]["algorithm"] == "ed25519" + + missing_signature = deepcopy(manifest) + del missing_signature["manifest_signature"] + with pytest.raises(jsonschema.ValidationError): + validator("manifest").validate(missing_signature) + + incomplete_consistency = deepcopy(manifest) + incomplete_consistency["source_consistency"] = {"adapter": "local"} + with pytest.raises(jsonschema.ValidationError): + validator("manifest").validate(incomplete_consistency) + + +def test_entry_types_bind_blobs_and_safe_link_targets() -> None: + manifest = load(FIXTURES / "valid-manifest.json") + entries = manifest["entries"] + + file_without_blob = deepcopy(manifest) + file_without_blob["entries"][1]["blob_digest"] = None + with pytest.raises(jsonschema.ValidationError): + validator("manifest").validate(file_without_blob) + + directory_with_blob = deepcopy(manifest) + directory_with_blob["entries"][0]["blob_digest"] = "c" * 64 + with pytest.raises(jsonschema.ValidationError): + validator("manifest").validate(directory_with_blob) + + for unsafe_target in ("/etc/passwd", "../escape", "dir\\escape"): + unsafe_symlink = deepcopy(manifest) + unsafe_symlink["entries"] = [ + { + "blob_digest": None, + "link_target": unsafe_target, + "metadata_support": [], + "mode": None, + "mtime_ns": None, + "path": "link", + "size": 0, + "type": "symlink", + } + ] + with pytest.raises(jsonschema.ValidationError): + validator("manifest").validate(unsafe_symlink) + + assert entries[1]["type"] == "file" + + 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"]]