"""Tests for the base-change detection in `.claude/hooks/pretooluse-merge-consent.sh` (#632). `review-verdict/h10` is a per-sha commit status, which makes "a new commit inherits an old verdict" impossible by construction (#622). Retargeting a PR's base reaches the same end by the opposite route: the head sha does not move, so the status stays green, while the merge-base — and therefore the effective diff the verdict was formed against — changes underneath it. What is asserted here is DETECTION on the hook path only, and the tests are written to keep that claim narrow: * a status carries no base field of its own, so the server-side required check cannot see this at all; a merge driven through the Gitea UI or API is unaffected. No test here implies otherwise. * a verdict posted before #632 has no `(base: …)` in its description and must get NO opinion, rather than denying every in-flight PR the day this lands. Observable contract: the hook exits 0 with EMPTY stdout when it has no opinion (passthrough to normal permissioning), and emits a JSON `permissionDecision` otherwise. """ 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" # The PR is deliberately NOT docs-only: the docs-only exemption short-circuits the whole gate, so a # docs PR would never reach the base check and the tests would pass without exercising it. 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]) if page == 1: print(json.dumps([{"filename": "ErsatzTV/Program.cs", "status": "modified"}])) else: print("[]") sys.exit(0) if "/status" in url: desc = (state / "verdict_desc").read_text() if desc == "TRANSPORT-ERROR": sys.exit(22) if desc == "GARBAGE": print('{"message":"internal error"}'); sys.exit(0) if desc == "SCALAR-ROW": print('{"state":"success","statuses":[1]}'); sys.exit(0) if desc == "NONSTRING-DESC": print(json.dumps({"state": "success", "statuses": [ {"context": "review-verdict/h10", "status": "success", "description": {"x": 1}}]})) sys.exit(0) rows = [] if desc == "NONE" else [ {"context": "review-verdict/h10", "status": "success", "description": desc}] print(json.dumps({"state": "success", "statuses": rows})) sys.exit(0) if "/pulls/" in url: body = {"head": {"sha": os.environ["STUB_SHA"]}, "body": "fixes #1"} live = (state / "live_base").read_text().strip() if live != "MISSING": body["base"] = {"ref": live} print(json.dumps(body)) sys.exit(0) print("{}") """ @pytest.fixture def hook(tmp_path): bindir = tmp_path / "bin" bindir.mkdir() curl = bindir / "curl" curl.write_text(CURL_SHIM) curl.chmod(0o755) state = tmp_path / "state" state.mkdir() (state / "live_base").write_text("main") (state / "verdict_desc").write_text("Review-verdict: MERGEABLE @ a9e3e23 (base: main)") 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" # noqa: S105 - deliberately fake; the real credential comes from the environment env["ETV_GITEA_URL"] = "http://gitea.example" env.pop("ETV_GITEA_BASICAUTH", None) class Handle: def set_live_base(self, ref): (state / "live_base").write_text(ref) def set_verdict_description(self, desc): """'NONE' serves a head with no review-verdict/h10 status at all.""" (state / "verdict_desc").write_text(desc) def decision(self): payload = {"tool_input": {"method": "merge", "owner": "timothy", "repo": "ersatztv", "pull_number": 42}} r = subprocess.run(["bash", str(HOOK)], input=json.dumps(payload), env=env, capture_output=True, text=True) assert r.returncode == 0, r.stderr if not r.stdout.strip(): return None return json.loads(r.stdout) def reason(self): d = self.decision() return "" if d is None else json.dumps(d) return Handle() def test_a_retargeted_base_denies_a_verdict_formed_against_the_old_one(hook): hook.set_live_base("release/26.4") reason = hook.reason() assert "deny" in reason, "a verdict formed against a different base was allowed to stand" assert "release/26.4" in reason and "main" in reason, ( "the deny must name both bases; a reader cannot act on 'the base changed'" ) def test_positive_control_an_unchanged_base_does_not_trigger_the_base_deny(hook): """Without this, the test above could pass because the hook denies on every path — which it very nearly does, since this PR is non-docs and the rest of the gate is unstubbed.""" reason = hook.reason() assert "ersatztv#632" not in reason, "the base check fired on a PR whose base never moved" @pytest.mark.parametrize( "desc", [ "Review-verdict: MERGEABLE @ a9e3e23", # posted before #632 "NONE", # no verdict status on this head at all ], ) def test_a_verdict_with_no_recorded_base_gets_no_opinion(hook, desc): """Graceful adoption. Denying here would block every in-flight PR the day this lands, and the window closes on its own: verdicts are per-head and short-lived, so every verdict posted after #632 carries the field. Asserting on the word "base" rather than on the issue tag: the tag-only check would have passed for a base-specific ask or deny whose wording happened to omit it, which is the failure mode most likely to appear when someone edits these messages. """ hook.set_live_base("release/26.4") hook.set_verdict_description(desc) assert "base" not in hook.reason(), ( "a pre-#632 verdict drew a base-related decision for a field it could not have carried" ) @pytest.mark.parametrize("failure", ["SCALAR-ROW", "NONSTRING-DESC"]) def test_a_malformed_status_MEMBER_asks_too(hook, failure): """One level below the previous fix, and it survived it. Validating only that `.statuses` is an array left `{"statuses":[1]}` passing the guard, after which `.context` on a number errors and a `|| true` on the extraction turned that error into an empty description — straight back onto the graceful-adoption path, which is precisely the outcome the guard exists to distinguish from. Same swallow-the-error shape as the bug one level up, which is why the validation domain must match the CONSUMPTION domain rather than stopping at the top-level type. """ hook.set_live_base("release/26.4") hook.set_verdict_description(failure) reason = hook.reason() assert "ask" in reason and "base" in reason @pytest.mark.parametrize("failure", ["TRANSPORT-ERROR", "GARBAGE"]) def test_an_UNREADABLE_status_response_asks_rather_than_skipping_the_check(hook, failure): """ "Could not check" is a third outcome, not a quiet synonym for "no base recorded". Collapsing the two makes an unreadable status response produce an empty `recorded_base`, take the graceful-adoption path, and skip validation in silence — after which a later successful status read could still auto-grant, emitting "merge gate: satisfied" for a comparison that never happened. A transient Gitea hiccup is not evidence that the base is unchanged. """ hook.set_live_base("release/26.4") hook.set_verdict_description(failure) reason = hook.reason() assert "ask" in reason, "an unreadable status response silently skipped the base check" assert "base" in reason, "the ask must name what could not be checked" def test_a_pr_with_no_resolvable_base_asks(hook): """A null/absent `.base.ref` is also 'could not check', not 'nothing to check'.""" hook.set_live_base("MISSING") reason = hook.reason() assert "ask" in reason and "base" in reason def test_the_comparator_is_the_base_REF_not_its_tip_sha(): """The design decision this test exists to freeze. `base.sha` tracks the base branch's TIP, which moves every time anything merges to `main` — comparing that would invalidate every open verdict on every unrelated merge, turning a rare-event guard into a permanent merge deadlock. A base branch that merely ADVANCES must be silent here; rebasing onto it moves the head sha, which the per-sha binding already covers.""" assert ".base.ref" in HOOK.read_text(), "the hook must compare the base BRANCH, not its tip sha" assert ".base.sha" not in HOOK.read_text(), "comparing base.sha deadlocks every open PR whenever main advances"