Files
2026-07-13 12:40:00 +08:00

182 lines
6.4 KiB
Python

"""Regression tests for the plan-attestation flow (v2.37.0).
Verifies the attest-plan.sh helper:
- Computes a SHA-256 of task_plan.md and stores it.
- --show prints the stored hash.
- --clear removes the attestation file.
- Detects tampering (file change after attest -> stored hash != fresh hash).
- Resolves parallel plans (.planning/<slug>/task_plan.md) ahead of legacy.
Skipped on platforms without sh in PATH (the helper is POSIX shell).
"""
from __future__ import annotations
import hashlib
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
SCRIPT = REPO_ROOT / "scripts" / "attest-plan.sh"
RESOLVER = REPO_ROOT / "scripts" / "resolve-plan-dir.sh"
def have_sh() -> bool:
return shutil.which("sh") is not None
def sha256_of(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
@unittest.skipUnless(have_sh(), "sh not available on this platform")
class PlanAttestationTests(unittest.TestCase):
def setUp(self) -> None:
self.tmp = Path(tempfile.mkdtemp(prefix="pwf-attest-"))
# Copy resolver next to attest-plan so the helper can find it.
self.scripts_dir = self.tmp / "scripts"
self.scripts_dir.mkdir()
shutil.copy2(SCRIPT, self.scripts_dir / "attest-plan.sh")
shutil.copy2(RESOLVER, self.scripts_dir / "resolve-plan-dir.sh")
os.chmod(self.scripts_dir / "attest-plan.sh", 0o755)
os.chmod(self.scripts_dir / "resolve-plan-dir.sh", 0o755)
def tearDown(self) -> None:
shutil.rmtree(self.tmp, ignore_errors=True)
def _run(self, *args: str) -> subprocess.CompletedProcess:
return subprocess.run(
["sh", str(self.scripts_dir / "attest-plan.sh"), *args],
cwd=str(self.tmp),
text=True,
encoding="utf-8",
capture_output=True,
check=False,
)
def test_legacy_attest_writes_root_attestation(self) -> None:
plan = self.tmp / "task_plan.md"
plan.write_text("# Plan v1\nphase 1\n", encoding="utf-8")
result = self._run()
self.assertEqual(0, result.returncode, result.stderr)
attest = self.tmp / ".plan-attestation"
self.assertTrue(attest.exists(), "expected .plan-attestation at project root")
self.assertEqual(sha256_of(plan), attest.read_text().strip())
def test_show_prints_stored_hash(self) -> None:
plan = self.tmp / "task_plan.md"
plan.write_text("content", encoding="utf-8")
self._run()
result = self._run("--show")
self.assertEqual(0, result.returncode, result.stderr)
self.assertIn(sha256_of(plan), result.stdout)
def test_clear_removes_attestation(self) -> None:
plan = self.tmp / "task_plan.md"
plan.write_text("content", encoding="utf-8")
self._run()
self.assertTrue((self.tmp / ".plan-attestation").exists())
result = self._run("--clear")
self.assertEqual(0, result.returncode, result.stderr)
self.assertFalse((self.tmp / ".plan-attestation").exists())
def test_tamper_changes_hash(self) -> None:
plan = self.tmp / "task_plan.md"
plan.write_text("approved content\n", encoding="utf-8")
self._run()
attested = (self.tmp / ".plan-attestation").read_text().strip()
plan.write_text("approved content\nsneaky injection\n", encoding="utf-8")
fresh = sha256_of(plan)
self.assertNotEqual(
attested,
fresh,
"hash must differ after tampering for the hook gate to fire",
)
def test_parallel_plan_attest_writes_into_plan_dir(self) -> None:
plan_dir = self.tmp / ".planning" / "2026-05-05-feature-x"
plan_dir.mkdir(parents=True)
(plan_dir / "task_plan.md").write_text("phase A\n", encoding="utf-8")
(self.tmp / ".planning" / ".active_plan").write_text(
"2026-05-05-feature-x", encoding="utf-8"
)
result = self._run()
self.assertEqual(0, result.returncode, result.stderr)
attest = plan_dir / ".attestation"
self.assertTrue(attest.exists(), "expected attestation inside the plan dir")
self.assertFalse(
(self.tmp / ".plan-attestation").exists(),
"must not write the legacy file when an active plan dir exists",
)
def test_no_plan_exits_nonzero(self) -> None:
result = self._run()
self.assertNotEqual(0, result.returncode)
def test_concurrent_attest_writes_do_not_corrupt_file(self) -> None:
# v2.40 regression: parallel legacy-mode attestations used to race
# via a non-atomic `> file` redirect, occasionally yielding a
# truncated `.plan-attestation` (zero-length or partial hex) that the
# hook then read as the expected hash, producing a false TAMPERED on
# the next prompt. The fix is atomic temp+rename with an optional
# flock guard. This test spawns 8 concurrent attestations on the same
# plan file and asserts the resulting file is a complete 64-char hex
# SHA-256 every time.
import threading
plan = self.tmp / "task_plan.md"
plan.write_text("concurrent attestation target\n", encoding="utf-8")
expected = sha256_of(plan)
errors: list[str] = []
results: list[int] = []
lock = threading.Lock()
def worker() -> None:
res = self._run()
with lock:
results.append(res.returncode)
if res.returncode != 0:
errors.append(res.stderr)
threads = [threading.Thread(target=worker) for _ in range(8)]
for t in threads:
t.start()
for t in threads:
t.join(timeout=10)
self.assertFalse(errors, f"concurrent attest failures: {errors}")
self.assertEqual(8, len(results))
attest_file = self.tmp / ".plan-attestation"
self.assertTrue(attest_file.exists())
stored = attest_file.read_text().strip()
self.assertEqual(
64,
len(stored),
f"expected 64-char hex SHA, got {len(stored)} chars: {stored!r}",
)
self.assertEqual(
expected,
stored,
"stored hash must match the (unchanged) plan content even under "
"concurrent writes",
)
if __name__ == "__main__":
unittest.main()