Files
ersatztv/scripts/tests/test_merge_consent_exemption.py
T
timothy 07e1e8cfbc
review-verdict/h10 Awaiting review verdict for 07e1e8c
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 12s
Review verdict / Set review-verdict status (pull_request) Successful in 10s
PR Gates / decisions lifecycle (pull_request) Successful in 21s
PR Gates / Docs update reminder (pull_request) Successful in 19s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m19s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m28s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m5s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 15m31s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 15m44s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
fix(622): validate rename rows too; name the real blocker when only the verdict is pending
Third BLOCKED verdict, third legitimate finding. The per-row guard validated the
DESTINATION only, so `{"filename":"docs/x.md","status":"renamed"}` with no
`previous_filename` passed while its source silently vanished — precisely the
path-hiding that collecting both sides exists to prevent. A rename row must now
carry a non-empty `previous_filename`.

Required for rename rows ONLY. Demanding it globally would reject every ordinary
modified/added row and make the gate refuse all exemptions — which every
"withholds the exemption" test would happily pass through, so that direction gets
its own positive control. Predicate checked against ten shapes before adopting;
mutation-verified in both directions (dropping the clause fails 2 tests, applying
it globally fails 3 including the controls).

Also fixes a wart this PR introduced. `review-verdict/h10` is itself folded into
the COMBINED status, so a PR awaiting its verdict reports combined 'pending' and
the hook's condition (a) reported it as a CI problem — sending a reader to build
logs when the missing thing is the review, and exiting before the H10 branch that
would have said so. The message now names the outstanding contexts, and says
plainly when the verdict is the only one left.

Scope boundary, stated in the record: real Gitea populates `previous_filename` on
renames and returns well-formed pages. Everything past this point defends against
shapes with no evidence of existing, so the guard's claim stays "any page we
cannot fully classify withholds the exemption" rather than growing to cover
unobserved responses.

Decisions-Edit: yes
2026-07-25 22:43:30 +02:00

233 lines
9.9 KiB
Python

"""Tests for the docs-only exemption path in `.claude/hooks/pretooluse-merge-consent.sh` (#622).
This covers the *file-enumeration* half of the gate, which decides whether a PR skips the
Done-when/verdict checks entirely. Three defects lived here, all silent false negatives that
widened the exemption rather than narrowing it:
1. the list was read from a single `?limit=100` page, which Gitea caps at 50 — on PR #619 that
showed zero protected paths where the full enumeration finds ten;
2. only `.filename` was read, so a `git mv` of a protected file INTO `docs/` looked docs-only;
3. a mid-pagination transport failure produced an empty page, which counted as zero rows and
read as "end of list" — completing the enumeration from a PARTIAL list.
(3) is the one worth restating: it is a defect on the FAILURE path, so every happy-path run looked
correct. These tests therefore assert what happens when a page *errors*, not only when it returns.
Observable contract: the hook exits 0 with EMPTY stdout when the PR is exempt (it passes through to
normal permissioning), and emits a JSON `permissionDecision` when it is not. So "did the exemption
apply" is directly observable without stubbing the rest of the gate.
"""
from __future__ import annotations
import json
import os
import subprocess
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
HOOK = REPO_ROOT / ".claude" / "hooks" / "pretooluse-merge-consent.sh"
SHA = "a9e3e23abf337980ca4c05854f5b1e210099d08b"
# Serves paged `pulls/N/files`, plus the minimal PR object the hook reads first.
CURL_SHIM = r'''#!/usr/bin/env python3
import json, os, sys, pathlib, urllib.parse
state = pathlib.Path(os.environ["STUB_DIR"])
args = sys.argv[1:]
url = [a for a in args if a.startswith("http")][-1]
if "/pulls/" in url and "/files" in url:
q = urllib.parse.parse_qs(urllib.parse.urlparse(url).query)
page = int(q.get("page", ["1"])[0])
pages = json.loads((state / "pages.json").read_text())
if page > len(pages):
print("[]"); sys.exit(0)
entry = pages[page - 1]
if entry == "ERROR": # transport failure on this page
sys.exit(22)
if entry == "GARBAGE": # 200 with a non-array body (proxy/error page)
print('{"message":"internal error"}'); sys.exit(0)
# anything else is served verbatim as the page body (rows, [], malformed rows, scalars)
print(json.dumps(entry)); sys.exit(0)
if "/pulls/" in url:
print(json.dumps({"head": {"sha": os.environ["STUB_SHA"]}, "body": "no linked issue here"}))
sys.exit(0)
print("{}")
'''
def _rows(paths):
return [{"filename": p, "status": "modified"} for p in paths]
@pytest.fixture
def hook(tmp_path):
bindir = tmp_path / "bin"; bindir.mkdir()
shim = bindir / "curl"; shim.write_text(CURL_SHIM); shim.chmod(0o755)
state = tmp_path / "state"; state.mkdir()
env = dict(os.environ)
env["PATH"] = f"{bindir}{os.pathsep}{env['PATH']}"
env["STUB_DIR"] = str(state)
env["STUB_SHA"] = SHA
env["ETV_GITEA_TOKEN"] = "stub"
env["ETV_GITEA_URL"] = "http://gitea.example"
env.pop("ETV_GITEA_BASICAUTH", None)
class Handle:
def set_pages(self, *pages):
(state / "pages.json").write_text(json.dumps(list(pages)))
def run(self):
payload = {"tool_input": {"method": "merge", "owner": "timothy",
"repo": "ersatztv", "pull_number": 42}}
return subprocess.run(["bash", str(HOOK)], input=json.dumps(payload),
env=env, capture_output=True, text=True)
def exempted(self):
"""Exempt == passthrough == exit 0 with no decision JSON."""
r = self.run()
assert r.returncode == 0, r.stderr
return r.stdout.strip() == ""
return Handle()
def test_docs_only_pr_is_exempt(hook):
hook.set_pages(_rows(["docs/a.md", "docs/b.md", "README.md"]))
assert hook.exempted() is True
def test_code_pr_is_not_exempt(hook):
hook.set_pages(_rows(["docs/a.md", "ErsatzTV/Program.cs"]))
assert hook.exempted() is False
def test_protected_path_on_a_LATER_page_is_still_seen(hook):
"""The #619 shape: 50 docs files on page 1, code hiding on page 2."""
hook.set_pages(_rows([f"docs/f{i}.md" for i in range(50)]),
_rows(["scripts/decisions_lib.py"]))
assert hook.exempted() is False
def test_full_first_page_alone_does_not_end_enumeration(hook):
"""A full 50-row page must trigger a second fetch, not terminate the loop."""
hook.set_pages(_rows([f"docs/f{i}.md" for i in range(50)]),
_rows(["docs/tail.md"]))
assert hook.exempted() is True # genuinely all docs, but only provable by reading page 2
def test_rename_of_code_into_docs_is_not_exempt(hook):
"""`git mv ErsatzTV/Program.cs docs/note.md` — the code path lives only in previous_filename.
NOTE the source must be a path the hook does not already exempt. Its docs-only pattern
deliberately also covers `.claude/`/`.gitea/`/`.husky/` (process files exempt to a HUMAN
PROMPT, never to an auto-grant), so a rename between two exempt paths is correctly still
exempt. That is the hook's contract and differs from `review-verdict.yml`'s stricter
PROTECTED list, which must never auto-post a green status for those paths.
"""
hook.set_pages([{"filename": "docs/innocuous-note.md", "status": "renamed",
"previous_filename": "ErsatzTV/Program.cs"}])
assert hook.exempted() is False
def test_rename_between_two_exempt_paths_stays_exempt(hook):
"""Guards the above: reading previous_filename must not over-trigger on legitimate moves."""
hook.set_pages([{"filename": "docs/b.md", "status": "renamed",
"previous_filename": "docs/a.md"}])
assert hook.exempted() is True
def test_transport_failure_mid_pagination_withholds_the_exemption(hook):
"""The failure-path defect: an errored page must not read as 'end of list'."""
hook.set_pages(_rows([f"docs/f{i}.md" for i in range(50)]), "ERROR")
assert hook.exempted() is False
def test_non_array_body_mid_pagination_withholds_the_exemption(hook):
"""A 200 carrying an error object must not be treated as an empty final page."""
hook.set_pages(_rows([f"docs/f{i}.md" for i in range(50)]), "GARBAGE")
assert hook.exempted() is False
def test_first_page_failure_withholds_the_exemption(hook):
"""NOTE this one is deliberately weak on its own — see the test below.
With page 1 failing, the path list is empty, and the hook independently withholds the
exemption on an empty list. So this passes even with the validation guard removed. It is
kept as a smoke case, but it does NOT pin the guard; `test_malformed_rows_*` does.
"""
hook.set_pages("ERROR")
assert hook.exempted() is False
def test_malformed_rows_mid_pagination_withhold_the_exemption(hook):
"""Isolates the row-schema hole: `[{}]` is a valid ARRAY whose rows carry no filename.
Validating only the top-level type lets this through — it contributes no paths, so it looks
like a short final page and completes the enumeration from a PARTIAL list. The 50 docs rows on
page 1 are what make this non-vacuous: the path list is non-empty, so the exemption would
genuinely fire if `complete` were wrongly set.
"""
hook.set_pages(_rows([f"docs/f{i}.md" for i in range(50)]), [{}])
assert hook.exempted() is False
def test_scalar_rows_mid_pagination_withhold_the_exemption(hook):
"""An array of scalars must be rejected, not crash the extraction under `set -e`."""
hook.set_pages(_rows([f"docs/f{i}.md" for i in range(50)]), ["unexpected"])
assert hook.exempted() is False
def test_empty_final_page_is_a_legitimate_end_of_pagination(hook):
"""Positive control for the guard above: `[]` must still count as a clean end, not a failure.
Without this, a stricter guard could withhold every exemption and the tests above would still
pass — the suite would be asserting 'never exempt', which is not the contract.
"""
hook.set_pages(_rows([f"docs/f{i}.md" for i in range(50)]), [])
assert hook.exempted() is True
def test_malformed_rename_row_withholds_the_exemption(hook):
"""A row marked `renamed` with NO `previous_filename` hides its source path.
The destination alone is `docs/...`, so validating only `filename` would accept the row and
silently drop whatever it was renamed FROM — defeating the reason both sides are collected.
Real Gitea always populates it (verified by constructing a rename), so this is the
malformed-2xx class the guard claims to fail closed on; the claim should match the behaviour.
"""
hook.set_pages(_rows([f"docs/f{i}.md" for i in range(50)]),
[{"filename": "docs/moved.md", "status": "renamed"}])
assert hook.exempted() is False
def test_rename_row_with_empty_previous_filename_withholds_the_exemption(hook):
hook.set_pages(_rows([f"docs/f{i}.md" for i in range(50)]),
[{"filename": "docs/moved.md", "status": "renamed", "previous_filename": ""}])
assert hook.exempted() is False
def test_ordinary_row_without_previous_filename_is_still_valid(hook):
"""Positive control: `previous_filename` must be required ONLY for rename rows.
Requiring it globally would reject every normal modified/added row and make the gate refuse
all exemptions — which the 'withholds' tests above could not distinguish from working.
"""
hook.set_pages([{"filename": "docs/a.md", "status": "modified"},
{"filename": "docs/b.md", "status": "added"},
{"filename": "docs/c.md"}])
assert hook.exempted() is True
def test_exceeding_max_pages_withholds_the_exemption(hook):
"""41 full pages: enumeration cannot be proven exhaustive, so no exemption."""
hook.set_pages(*[_rows([f"docs/p{p}f{i}.md" for i in range(50)]) for p in range(41)])
assert hook.exempted() is False