Files
pulse/scripts/release_control/browser_verification_guard_test.py
T
rcourtman 2fd184315d Simplify and harden verified Patrol receipts
Contract-Neutral: Patrol compact receipt payload does not change agent lifecycle or storage recovery contracts
2026-08-15 10:25:04 +01:00

197 lines
7.4 KiB
Python

#!/usr/bin/env python3
from __future__ import annotations
from io import StringIO
import os
from pathlib import Path
import subprocess
import tempfile
import unittest
from unittest.mock import patch
from browser_verification_guard import (
RECEIPT_PATH,
formatting_only_paths,
frontend_runtime_paths,
main,
validate_receipt,
)
from format_staged_frontend_test import REAL_PRETTIER
from repo_file_io import strip_local_git_env
BASE_SHA = "a" * 40
CHANGED_PATH = "frontend-modern/src/components/Example.tsx"
CONTENT_SHA = "c" * 64
REPO_ROOT = Path(__file__).resolve().parents[2]
def valid_receipt() -> dict:
return {
"version": 1,
"base_sha": BASE_SHA,
"verified_at": "2026-08-02T20:15:00Z",
"result": "passed",
"changed_paths": [CHANGED_PATH],
"content_sha256": {CHANGED_PATH: CONTENT_SHA},
"routes": ["/proxmox/overview"],
"viewports": [
{"width": 1280, "height": 800},
{"width": 390, "height": 844},
],
"states": ["toolbar closed", "View menu open", "Columns disclosure open"],
"interactions": ["Open View, open Columns, dismiss with Escape"],
}
class BrowserVerificationGuardTest(unittest.TestCase):
def test_pre_commit_formats_frontend_before_validating_receipt_hashes(self) -> None:
hook = (REPO_ROOT / ".husky" / "pre-commit").read_text(encoding="utf-8")
formatter = hook.index("python3 scripts/release_control/format_staged_frontend.py")
guard = hook.index("python3 scripts/release_control/browser_verification_guard.py")
self.assertLess(formatter, guard)
def test_blocks_frontend_change_when_receipt_is_not_in_commit(self) -> None:
with (
patch("sys.stdin", StringIO(CHANGED_PATH + "\n")),
patch("sys.stderr", new=StringIO()),
):
self.assertEqual(main(["--files-from-stdin"]), 1)
def test_frontend_runtime_paths_exclude_tests_and_receipt(self) -> None:
self.assertEqual(
frontend_runtime_paths(
[
CHANGED_PATH,
"frontend-modern/src/components/Example.test.tsx",
"frontend-modern/src/components/__tests__/Example.tsx",
RECEIPT_PATH,
"frontend-modern/index.html",
"internal/api/server.go",
]
),
["frontend-modern/index.html", CHANGED_PATH],
)
def test_accepts_receipt_bound_to_changed_paths_and_two_viewports(self) -> None:
self.assertEqual(
validate_receipt(
valid_receipt(),
changed_paths=[CHANGED_PATH],
expected_base=BASE_SHA,
expected_content_sha256={CHANGED_PATH: CONTENT_SHA},
),
[],
)
def test_rejects_stale_base_and_incomplete_changed_path_coverage(self) -> None:
receipt = valid_receipt()
receipt["base_sha"] = "b" * 40
receipt["changed_paths"] = ["frontend-modern/src/components/Other.tsx"]
errors = validate_receipt(
receipt,
changed_paths=[CHANGED_PATH],
expected_base=BASE_SHA,
expected_content_sha256={CHANGED_PATH: CONTENT_SHA},
)
self.assertTrue(any("base_sha" in error for error in errors))
self.assertTrue(any("changed_paths" in error for error in errors))
def test_rejects_dom_only_proof_without_states_interactions_or_narrow_viewport(self) -> None:
receipt = valid_receipt()
receipt["viewports"] = [{"width": 1280, "height": 800}]
receipt["states"] = []
receipt["interactions"] = []
errors = validate_receipt(
receipt,
changed_paths=[CHANGED_PATH],
expected_base=BASE_SHA,
expected_content_sha256={CHANGED_PATH: CONTENT_SHA},
)
self.assertTrue(any("narrow width" in error for error in errors))
self.assertTrue(any("states" in error for error in errors))
self.assertTrue(any("interactions" in error for error in errors))
def test_rejects_source_content_changed_after_browser_verification(self) -> None:
receipt = valid_receipt()
errors = validate_receipt(
receipt,
changed_paths=[CHANGED_PATH],
expected_base=BASE_SHA,
expected_content_sha256={CHANGED_PATH: "d" * 64},
)
self.assertTrue(any("content_sha256" in error for error in errors))
class FormattingOnlyExemptionTest(unittest.TestCase):
"""A prettier sweep has no visual delta, but a real edit must still block."""
def build_repo(self, tmpdir: str, old: str, new: str) -> Path:
repo_root = Path(tmpdir)
source = repo_root / CHANGED_PATH
source.parent.mkdir(parents=True)
source.write_text(old, encoding="utf-8")
env = strip_local_git_env(os.environ.copy())
def git(*args: str) -> None:
subprocess.run(
["git", *args], cwd=repo_root, check=True, capture_output=True, env=env
)
git("init")
git("add", CHANGED_PATH)
git("-c", "user.email=t@example.com", "-c", "user.name=t", "commit", "-m", "seed")
source.write_text(new, encoding="utf-8")
git("add", CHANGED_PATH)
return repo_root
def resolve(self, repo_root: Path) -> set[str]:
# Under the pre-commit hook, GIT_DIR and GIT_INDEX_FILE are exported
# and would point this temp repo's plumbing at the real repository.
with patch.dict("os.environ", strip_local_git_env(os.environ.copy()), clear=True):
with patch("browser_verification_guard.REPO_ROOT", repo_root):
return formatting_only_paths([CHANGED_PATH], commit=None, repo_root=repo_root)
@unittest.skipUnless(REAL_PRETTIER.exists(), "prettier not installed under frontend-modern")
def test_exempts_a_pure_reformat(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
repo_root = self.build_repo(tmpdir, "const x = {a:1}\n", "const x = { a: 1 };\n")
with patch.dict(
"os.environ", {"PULSE_PRETTIER_BIN": str(REAL_PRETTIER)}, clear=False
):
self.assertEqual(self.resolve(repo_root), {CHANGED_PATH})
@unittest.skipUnless(REAL_PRETTIER.exists(), "prettier not installed under frontend-modern")
def test_still_requires_proof_when_a_reformat_also_changes_a_value(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
# Formatted exactly as prettier would, but 1 became 2. The guard
# must not treat a semantic edit as a cosmetic one.
repo_root = self.build_repo(tmpdir, "const x = {a:1}\n", "const x = { a: 2 };\n")
with patch.dict(
"os.environ", {"PULSE_PRETTIER_BIN": str(REAL_PRETTIER)}, clear=False
):
self.assertEqual(self.resolve(repo_root), set())
def test_fails_closed_when_prettier_is_unavailable(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
repo_root = self.build_repo(tmpdir, "const x = {a:1}\n", "const x = { a: 1 };\n")
with patch.dict(
"os.environ",
{"PULSE_PRETTIER_BIN": str(repo_root / "missing-prettier")},
clear=False,
):
self.assertEqual(self.resolve(repo_root), set())
if __name__ == "__main__":
unittest.main()