463 lines
27 KiB
Python
463 lines
27 KiB
Python
import copy
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
import stat
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[3]
|
|
MODPATH = ROOT / "harness/agents/spec/skills/gitea-implementation-spec-review/scripts/review_support.py"
|
|
spec = importlib.util.spec_from_file_location("review_support", MODPATH)
|
|
mod = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(mod)
|
|
|
|
|
|
def result(verdict="approve"):
|
|
return {
|
|
"schema": "gitea-agent.implementation-spec-review-result/v1",
|
|
"target": {"owner": "acme", "repository": "repo", "issue": 3, "pull": 7,
|
|
"head": "a" * 40},
|
|
"contract_sha256": "b" * 64,
|
|
"verdict": verdict,
|
|
"criteria": [{"criterion": "REQ-1", "contract": "계약", "implementation_paths": ["src/a.py"],
|
|
"test_paths": ["tests/test_a.py"], "evidence": "증거", "result": "satisfied",
|
|
"reason": "충족"}],
|
|
"scope": {"missing": [], "excess": [], "spec_conflicts": []},
|
|
"conclusion": "모든 기준을 충족했다.",
|
|
}
|
|
|
|
|
|
def publication_fixture():
|
|
r = result(); paths = {"src/a.py", "tests/test_a.py"}
|
|
body = mod.render_review(r, paths)
|
|
url = "https://example.test/acme/repo/pulls/7"
|
|
user = {"id": 42, "login": "spec-agent"}
|
|
review = {"id": 11, "body": body, "commit_id": r["target"]["head"], "state": "APPROVED",
|
|
"user": user, "official": True, "stale": False, "dismissed": False,
|
|
"comments_count": 0, "submitted_at": "2026-10-03T01:24:00Z",
|
|
"updated_at": "2026-10-03T01:24:00Z", "pull_request_url": url}
|
|
before = {"issue": {"id": 3, "number": 3, "repository": {"full_name": "acme/repo"},
|
|
"updated_at": "2026-10-03T00:00:00Z", "title": "contract"},
|
|
"issue_comments": [], "pr": {"id": 7, "number": 7, "html_url": url,
|
|
"url": url, "base": {"repo": {"full_name": "acme/repo"}, "sha": "c" * 40},
|
|
"head": {"sha": "a" * 40}, "user": {"id": 99}, "state": "open", "merged": False,
|
|
"review_comments": 0, "updated_at": "2026-10-03T00:00:00Z", "requested_reviewers": []},
|
|
"pr_comments": [], "reviews": [], "timeline": [],
|
|
"files": [{"filename": "src/a.py", "status": "modified", "additions": 1}]}
|
|
after = copy.deepcopy(before); after["reviews"] = [review]
|
|
after["pr"]["updated_at"] = review["submitted_at"]; after["pr"]["review_comments"] = 1
|
|
after["timeline"] = [{"id": 101, "review_id": 11, "user": user, "body": body,
|
|
"type": "review", "created_at": review["submitted_at"],
|
|
"updated_at": review["submitted_at"], "pull_request_url": url}]
|
|
return before, after, review, r, paths
|
|
|
|
|
|
def verify_fixture(before, after, review, r, paths, contract=None):
|
|
return mod.verify_publication(before, after, review, r, paths, 42, [], contract,
|
|
expected_pr_url="https://example.test/acme/repo/pulls/7")
|
|
|
|
|
|
class SupportTests(unittest.TestCase):
|
|
def test_loads_json_rejects_duplicate_and_nonfinite(self):
|
|
with self.assertRaises(mod.ValidationError):
|
|
mod.loads_json('{"a":1,"a":2}')
|
|
with self.assertRaises(mod.ValidationError):
|
|
mod.loads_json('{"a":NaN}')
|
|
|
|
def test_validate_and_render_roundtrip(self):
|
|
r = result()
|
|
self.assertEqual(mod.validate_result(r, {"src/a.py", "tests/test_a.py"}), r)
|
|
body = mod.render_review(r, {"src/a.py", "tests/test_a.py"})
|
|
parsed = mod.parse_review(body, {"src/a.py", "tests/test_a.py"})
|
|
self.assertEqual(parsed, r)
|
|
self.assertEqual(mod.render_review(parsed, {"src/a.py", "tests/test_a.py"}), body)
|
|
|
|
def test_validation_rejects_bool_path_traversal_bad_verdict(self):
|
|
r = result()
|
|
r["target"]["issue"] = True
|
|
with self.assertRaises(mod.ValidationError):
|
|
mod.validate_result(r, {"src/a.py"})
|
|
r = result(); r["criteria"][0]["implementation_paths"] = ["../x"]
|
|
with self.assertRaises(mod.ValidationError):
|
|
mod.validate_result(r, {"../x"})
|
|
r = result("request_changes")
|
|
with self.assertRaises(mod.ValidationError):
|
|
mod.validate_result(r, {"src/a.py", "tests/test_a.py"})
|
|
|
|
def test_collect_pages_requires_identity_and_reads_all_pages(self):
|
|
pages = [[{"id": 1}, {"id": 2}], [{"id": 3}]]
|
|
def fetch(n):
|
|
return {"items": pages[n - 1], "total_count": 3, "has_more": n == 1}
|
|
self.assertEqual(mod.collect_pages(fetch, "id", page_size=2), pages[0] + pages[1])
|
|
def bad(n):
|
|
return {"items": [{"id": 1}], "total_count": 2, "has_more": True}
|
|
with self.assertRaises(mod.ValidationError):
|
|
mod.collect_pages(bad, "id", max_pages=1)
|
|
|
|
def test_verify_unchanged(self):
|
|
self.assertTrue(mod.verify_unchanged({"a": [1]}, {"a": [1]}))
|
|
with self.assertRaises(mod.ValidationError):
|
|
mod.verify_unchanged({"a": 1}, {"a": 2})
|
|
|
|
def test_cache_validation_and_rejects_symlink(self):
|
|
with tempfile.TemporaryDirectory(dir="/opt/data/cache/scratch") as td:
|
|
root = Path(td); ops = root / "harness" / "operations"; ops.mkdir(parents=True)
|
|
contract = ops / "repository-cache.md"; contract.write_text("cache", encoding="utf-8")
|
|
rs = root / "repositories"; owner = rs / "acme"; repo = owner / "repo"
|
|
(repo / "locks").mkdir(parents=True)
|
|
(repo / "repository.git").mkdir()
|
|
for p in (rs, owner, repo, repo / "locks"): os.chmod(p, 0o700)
|
|
info = mod.validate_cache_paths(contract, "acme", "repo", 3)
|
|
self.assertTrue(info["repository_git"].endswith("repository.git"))
|
|
(repo / "locks" / "issue-3.lock").symlink_to(contract)
|
|
with self.assertRaises(mod.ValidationError):
|
|
mod.validate_cache_paths(contract, "acme", "repo", 3)
|
|
|
|
def test_publication_accepts_one_exact_review_and_rejects_competing_event(self):
|
|
before, after, review, r, paths = publication_fixture()
|
|
self.assertEqual(verify_fixture(before, after, review, r, paths)["review_id"], 11)
|
|
bad = copy.deepcopy(after)
|
|
bad["timeline"].append({"id": 102, "review_id": 12, "user": {"id": 42}, "body": review["body"]})
|
|
with self.assertRaises(mod.ValidationError):
|
|
verify_fixture(before, bad, review, r, paths)
|
|
|
|
def test_cli_check_pages_and_unchanged(self):
|
|
with tempfile.TemporaryDirectory(dir="/opt/data/cache/scratch") as td:
|
|
td = Path(td); env = dict(os.environ, PYTHONDONTWRITEBYTECODE="1")
|
|
pages = td / "pages.json"; pages.write_text(json.dumps([
|
|
{"items": [{"id": 1}], "total_count": 1, "has_more": False}],), encoding="utf-8")
|
|
base = [sys.executable, str(MODPATH)]
|
|
p = subprocess.run(base + ["check-pages", "--pages", str(pages), "--key", "id"], capture_output=True, text=True, env=env)
|
|
self.assertEqual(p.returncode, 0, p.stderr)
|
|
b = td / "before.json"; a = td / "after.json"
|
|
b.write_text('{"x":1}', encoding="utf-8"); a.write_text('{"x":1}', encoding="utf-8")
|
|
p = subprocess.run(base + ["check-unchanged", "--before", str(b), "--after", str(a)], capture_output=True, text=True, env=env)
|
|
self.assertEqual(p.returncode, 0, p.stderr); self.assertIn('"unchanged": true', p.stdout)
|
|
|
|
def test_cli_validate_render_parse(self):
|
|
with tempfile.TemporaryDirectory(dir="/opt/data/cache/scratch") as td:
|
|
td = Path(td); rf = td / "r.json"; af = td / "a.json"; bf = td / "b.md"
|
|
rf.write_text(json.dumps(result(), ensure_ascii=False), encoding="utf-8")
|
|
af.write_text(json.dumps(["src/a.py", "tests/test_a.py"]), encoding="utf-8")
|
|
env = dict(os.environ, PYTHONDONTWRITEBYTECODE="1")
|
|
base = [sys.executable, str(MODPATH)]
|
|
p = subprocess.run(base + ["validate-result", "--result", str(rf), "--allowed-paths", str(af)],
|
|
text=True, capture_output=True, env=env)
|
|
self.assertEqual(p.returncode, 0, p.stderr)
|
|
p = subprocess.run(base + ["render", "--result", str(rf), "--allowed-paths", str(af)],
|
|
text=True, capture_output=True, env=env)
|
|
self.assertEqual(p.returncode, 0, p.stderr); bf.write_text(p.stdout, encoding="utf-8")
|
|
p = subprocess.run(base + ["parse", "--body", str(bf), "--allowed-paths", str(af)],
|
|
text=True, capture_output=True, env=env)
|
|
self.assertEqual(p.returncode, 0, p.stderr)
|
|
self.assertEqual(json.loads(p.stdout), result())
|
|
|
|
|
|
def test_result_codec_preserves_existing_native_format(self):
|
|
r = result()
|
|
paths = {"src/a.py", "tests/test_a.py"}
|
|
body = mod.render_review(r, paths)
|
|
self.assertIn(" - 구현 근거: src/a.py; 증거\n", body)
|
|
self.assertIn("모든 기준을 충족했다.\n\n<!--", body)
|
|
self.assertEqual(mod.parse_review(body, paths), r)
|
|
unordered = json.loads(json.dumps(r, sort_keys=True))
|
|
self.assertEqual(mod.render_review(unordered, paths), body)
|
|
|
|
def test_strict_codec_rejects_unrenderable_and_sensitive_human_values(self):
|
|
paths = {"src/a.py", "tests/test_a.py"}
|
|
for field, value in (("verdict", []), ("conclusion", "trailing "),
|
|
("conclusion", "line\u2028break"), ("conclusion", "ftp://example.test")):
|
|
r = result(); r[field] = value
|
|
with self.subTest(field=field, value=value), self.assertRaises(mod.ValidationError):
|
|
mod.validate_result(r, paths)
|
|
for field in ("criterion", "contract", "evidence", "reason"):
|
|
r = result(); r["criteria"][0][field] = "secretvalue"
|
|
with self.subTest(field=field), self.assertRaises(mod.ValidationError):
|
|
mod.validate_result(r, paths, sensitive_values=("secretvalue",))
|
|
r = result(); r["verdict"] = "blocked_spec"; r["scope"]["spec_conflicts"] = ["secretvalue"]
|
|
with self.assertRaises(mod.ValidationError):
|
|
mod.validate_result(r, paths, sensitive_values=("secretvalue",))
|
|
with self.assertRaises(mod.ValidationError):
|
|
mod.loads_json('{"n":1e999}')
|
|
with self.assertRaises(mod.ValidationError):
|
|
mod.validate_result(result(), "src/a.py")
|
|
|
|
def test_parser_rejects_forged_marker_and_body_drift(self):
|
|
paths = {"src/a.py", "tests/test_a.py"}
|
|
body = mod.render_review(result(), paths)
|
|
for bad in (body.replace("issue=3", "issue=4"), body.replace("증거", "다른 증거"),
|
|
body.replace("\n", "\r\n"), body + "\n", body.replace("correlation=", "other=")):
|
|
with self.subTest(body=bad[-100:]), self.assertRaises(mod.ValidationError):
|
|
mod.parse_review(bad, paths)
|
|
|
|
def test_codec_roundtrips_delimiter_bearing_paths_and_prose(self):
|
|
r = result(); r["criteria"][0]["implementation_paths"] = ["src/a, b.py", "src/c; d.py"]
|
|
r["criteria"][0]["evidence"] = "확인; 모두 — 일치"
|
|
r["verdict"] = "blocked_spec"; r["scope"]["spec_conflicts"] = ["하나, 둘", "세 가지"]
|
|
paths = {"src/a, b.py", "src/c; d.py", "tests/test_a.py"}
|
|
self.assertEqual(mod.parse_review(mod.render_review(r, paths), paths), r)
|
|
|
|
|
|
def test_cache_enforces_root_boundary_uid_and_dangling_locks(self):
|
|
with tempfile.TemporaryDirectory(dir="/opt/data/cache/scratch") as td:
|
|
root = Path(td); ops = root / "harness" / "operations"; ops.mkdir(parents=True)
|
|
contract = ops / "repository-cache.md"; contract.write_text("cache", encoding="utf-8")
|
|
rs = root / "repositories"; owner = rs / "acme"; repo = owner / "repo"
|
|
(repo / "locks").mkdir(parents=True); (repo / "repository.git").mkdir()
|
|
for path in (rs, owner, repo, repo / "locks"):
|
|
path.chmod(0o700)
|
|
os.chmod(root, 0o777)
|
|
with self.assertRaises(mod.ValidationError):
|
|
mod.validate_cache_paths(contract, "acme", "repo", 3)
|
|
os.chmod(root, 0o700)
|
|
with self.assertRaises(mod.ValidationError):
|
|
mod.validate_cache_paths(contract, "acme", "repo", 3, uid=os.getuid() + 10000)
|
|
(repo / "locks" / "issue-3.lock").symlink_to(root / "missing")
|
|
with self.assertRaises(mod.ValidationError):
|
|
mod.validate_cache_paths(contract, "acme", "repo", 3)
|
|
(repo / "locks" / "issue-3.lock").unlink()
|
|
alias = root / "alias"; alias.symlink_to(ops.parent, target_is_directory=True)
|
|
with self.assertRaises(mod.ValidationError):
|
|
mod.validate_cache_paths(alias / "operations" / "repository-cache.md", "acme", "repo", 3)
|
|
|
|
def test_cache_group_membership_includes_other_primary_accounts(self):
|
|
from types import SimpleNamespace
|
|
from unittest.mock import patch
|
|
role = SimpleNamespace(pw_name="role", pw_uid=os.getuid(), pw_gid=111)
|
|
other = SimpleNamespace(pw_name="other", pw_uid=os.getuid() + 1, pw_gid=111)
|
|
group = SimpleNamespace(gr_gid=111, gr_mem=["role"])
|
|
with tempfile.TemporaryDirectory(dir="/opt/data/cache/scratch") as td:
|
|
root = Path(td); ops = root / "harness" / "operations"; ops.mkdir(parents=True)
|
|
contract = ops / "repository-cache.md"; contract.write_text("cache")
|
|
rs = root / "repositories"; repo = rs / "acme" / "repo"
|
|
(repo / "locks").mkdir(parents=True); (repo / "repository.git").mkdir()
|
|
for path in (rs / "acme", repo, repo / "locks"):
|
|
path.chmod(0o700)
|
|
rs.chmod(0o770)
|
|
role.pw_gid = other.pw_gid = group.gr_gid = rs.stat().st_gid
|
|
with patch.object(mod.grp, "getgrgid", return_value=group), \
|
|
patch.object(mod.pwd, "getpwuid", return_value=role), \
|
|
patch.object(mod.pwd, "getpwall", return_value=[role, other]):
|
|
with self.assertRaises(mod.ValidationError):
|
|
mod.validate_cache_paths(contract, "acme", "repo", 3)
|
|
|
|
|
|
def test_publication_accepts_gitea_nested_identity(self):
|
|
before, after, review, r, paths = publication_fixture()
|
|
self.assertEqual(verify_fixture(before, after, review, r, paths)["review_id"], 11)
|
|
|
|
def test_publication_requires_exact_readback_not_only_review_id(self):
|
|
before, after, review, r, paths = publication_fixture()
|
|
for field, value in (("body", "changed"), ("official", False), ("submitted_at", "bad")):
|
|
exact = copy.deepcopy(review); exact[field] = value
|
|
with self.subTest(field=field), self.assertRaises(mod.ValidationError):
|
|
verify_fixture(before, after, exact, r, paths)
|
|
|
|
def test_publication_rejects_unrelated_drift_and_bad_projections(self):
|
|
before, after, review, r, paths = publication_fixture()
|
|
cases = []
|
|
for field, value in (("updated_at", "2026-10-03T10:00:00Z"), ("review_comments", 4),
|
|
("requested_reviewers", [{"id": 43}]), ("head", {"sha": "d" * 40})):
|
|
bad = copy.deepcopy(after); bad["pr"][field] = value; cases.append(bad)
|
|
for field, value in (("updated_at", "2026-10-03T10:00:00Z"), ("title", "changed")):
|
|
bad = copy.deepcopy(after); bad["issue"][field] = value; cases.append(bad)
|
|
bad = copy.deepcopy(after); bad["reviews"][0]["stale"] = 0; cases.append(bad)
|
|
bad = copy.deepcopy(after); bad["reviews"][0]["state"] = "PENDING"; cases.append(bad)
|
|
bad = copy.deepcopy(after); bad["reviews"].append(copy.deepcopy(review)); cases.append(bad)
|
|
bad = copy.deepcopy(after); bad["timeline"][0]["type"] = "comment"; cases.append(bad)
|
|
bad = copy.deepcopy(after); bad["timeline"][0]["review_id"] = True; cases.append(bad)
|
|
for bad in cases:
|
|
with self.subTest(snapshot=cases.index(bad)), self.assertRaises(mod.ValidationError):
|
|
verify_fixture(before, bad, bad["reviews"][0], r, paths)
|
|
|
|
def test_publication_binds_snapshot_repository_issue_and_head(self):
|
|
before, after, review, r, paths = publication_fixture()
|
|
for section, field, value in (("issue", "number", 4), ("issue", "repository", {"full_name": "wrong/repo"}),
|
|
("pr", "head", {"sha": "d" * 40}), ("pr", "number", 8)):
|
|
b = copy.deepcopy(before); a = copy.deepcopy(after)
|
|
b[section][field] = a[section][field] = value
|
|
with self.subTest(section=section, field=field), self.assertRaises(mod.ValidationError):
|
|
verify_fixture(b, a, review, r, paths)
|
|
|
|
def test_publication_preserves_previous_reviews_and_bounds_supersession(self):
|
|
before, after, review, r, paths = publication_fixture()
|
|
old = copy.deepcopy(review); old.update(id=10, body="previous", commit_id="d" * 40,
|
|
state="REQUEST_CHANGES", updated_at="2026-10-03T00:00:00Z")
|
|
before["reviews"] = [old]; after["reviews"].insert(0, copy.deepcopy(old))
|
|
verify_fixture(before, after, review, r, paths)
|
|
after["reviews"][0].update(official=False, dismissed=True, updated_at=review["submitted_at"])
|
|
with self.assertRaises(mod.ValidationError):
|
|
verify_fixture(before, after, review, r, paths)
|
|
contract = {"version": {"version": "1.27.3"}, "supersession_same_author_only": True,
|
|
"already_dismissed_review_timestamp_projection": True}
|
|
verify_fixture(before, after, review, r, paths, contract)
|
|
for mutation in ("body", "user", "missing"):
|
|
a = copy.deepcopy(after)
|
|
if mutation == "missing": a["reviews"].pop(0)
|
|
elif mutation == "user": a["reviews"][0]["user"] = {"id": 43}
|
|
else: a["reviews"][0]["body"] = "tampered"
|
|
with self.subTest(mutation=mutation), self.assertRaises(mod.ValidationError):
|
|
verify_fixture(before, a, review, r, paths, contract)
|
|
old["official"] = False; old["dismissed"] = True
|
|
after["reviews"][0] = copy.deepcopy(old); after["reviews"][0]["updated_at"] = review["submitted_at"]
|
|
verify_fixture(before, after, review, r, paths, contract)
|
|
contract["already_dismissed_review_timestamp_projection"] = False
|
|
with self.assertRaises(mod.ValidationError):
|
|
verify_fixture(before, after, review, r, paths, contract)
|
|
|
|
def test_publication_preserves_collection_identity_and_normalizes_only_files_order(self):
|
|
before, after, review, r, paths = publication_fixture()
|
|
second = {"filename": "tests/test_a.py", "status": "added", "additions": 2}
|
|
before["files"].append(second); after["files"].insert(0, copy.deepcopy(second))
|
|
verify_fixture(before, after, review, r, paths)
|
|
after["files"].append(copy.deepcopy(second))
|
|
with self.assertRaises(mod.ValidationError):
|
|
verify_fixture(before, after, review, r, paths)
|
|
|
|
def test_read_only_replay_uses_recursive_exact_json_types(self):
|
|
with self.assertRaises(mod.ValidationError):
|
|
mod.verify_unchanged({"nested": [True]}, {"nested": [1]})
|
|
|
|
|
|
def test_pagination_is_one_based_and_rejects_short_continuations(self):
|
|
calls = []
|
|
pages = [{"items": [{"id": 1}, {"id": 2}], "total_count": 3, "has_more": True},
|
|
{"items": [{"id": 3}], "total_count": 3, "has_more": False}]
|
|
def fetch(page):
|
|
calls.append(page)
|
|
return pages[page - 1]
|
|
self.assertEqual(mod.collect_pages(fetch, "id", page_size=2), [{"id": 1}, {"id": 2}, {"id": 3}])
|
|
self.assertEqual(calls, [1, 2])
|
|
with self.assertRaises(mod.ValidationError):
|
|
mod.collect_pages(lambda _: {"items": [{"id": 1}], "total_count": 3, "has_more": True}, "id", page_size=2)
|
|
|
|
def test_pagination_validates_all_declared_metadata_and_identity_types(self):
|
|
bad_pages = [
|
|
{"items": [{"id": 1}], "total_count": 0, "has_more": True},
|
|
{"items": [{"id": 1}, {"id": 2}], "total_count": 2, "has_more": True},
|
|
{"items": [{"id": []}], "has_more": False},
|
|
{"items": [{"id": 1.5}], "has_more": False},
|
|
{"items": [{"id": 0}], "has_more": False},
|
|
{"items": [{"id": True}], "has_more": False},
|
|
{"items": [{"id": 1}, {"id": 1}], "has_more": False},
|
|
{"items": None, "has_more": False},
|
|
{"items": [], "has_more": True},
|
|
]
|
|
for page in bad_pages:
|
|
with self.subTest(page=bad_pages.index(page)), self.assertRaises(mod.ValidationError):
|
|
mod.collect_pages(lambda _: page, "id", page_size=2, max_pages=2)
|
|
pages = [{"items": [{"id": 1}], "total_count": 2, "has_more": True},
|
|
{"items": [{"id": 2}], "total_count": 3, "has_more": False}]
|
|
with self.assertRaises(mod.ValidationError):
|
|
mod.collect_pages(lambda i: pages[i - 1], "id", page_size=1)
|
|
self.assertEqual(mod.collect_pages(lambda _: {"items": None, "total_count": 0, "has_more": False}, "id"), [])
|
|
|
|
def test_cli_errors_are_sanitized_without_tracebacks_or_echoed_values(self):
|
|
env = dict(os.environ, PYTHONDONTWRITEBYTECODE="1")
|
|
base = [sys.executable, str(MODPATH)]
|
|
for args in (["unknown-secret-value"], ["check-cache", "--issue", "secret-value"],
|
|
["check-unchanged", "--before", "secret-value", "--after", "missing"]):
|
|
p = subprocess.run(base + args, capture_output=True, text=True, env=env)
|
|
self.assertNotEqual(p.returncode, 0)
|
|
self.assertNotIn("secret-value", p.stderr + p.stdout)
|
|
self.assertNotIn("Traceback", p.stderr + p.stdout)
|
|
self.assertEqual(json.loads(p.stderr), {"error": "invalid input"})
|
|
with tempfile.TemporaryDirectory(dir="/opt/data/cache/scratch") as td:
|
|
path = Path(td) / "pages.json"
|
|
path.write_text(json.dumps([{ "items": [{"id": 1}], "has_more": True, "total_count": 2 }]))
|
|
p = subprocess.run(base + ["check-pages", "--pages", str(path), "--key", "id"],
|
|
capture_output=True, text=True, env=env)
|
|
self.assertNotEqual(p.returncode, 0)
|
|
self.assertNotIn("Traceback", p.stderr)
|
|
self.assertEqual(json.loads(p.stderr), {"error": "invalid input"})
|
|
|
|
|
|
def test_target_identifiers_preserve_dots_but_reject_marker_breaking_whitespace(self):
|
|
r = result(); paths = {"src/a.py", "tests/test_a.py"}
|
|
r["target"].update(owner="acme.name", repository="repo.name")
|
|
body = mod.render_review(r, paths)
|
|
self.assertEqual(mod.parse_review(body, paths), r)
|
|
r["target"]["owner"] = "acme name"
|
|
with self.assertRaises(mod.ValidationError):
|
|
mod.validate_result(r, paths)
|
|
|
|
def test_rendered_paths_cannot_inject_markdown_html_or_mentions(self):
|
|
r = result(); path = "src/<script>@user`[link].py"
|
|
r["criteria"][0]["implementation_paths"] = [path]
|
|
paths = {path, "tests/test_a.py"}
|
|
body = mod.render_review(r, paths)
|
|
self.assertNotIn("<script>", body)
|
|
self.assertNotIn("@user", body)
|
|
self.assertNotIn("`[link]", body)
|
|
self.assertEqual(mod.parse_review(body, paths), r)
|
|
|
|
def test_strict_types_limits_and_duplicate_nested_keys(self):
|
|
paths = {"src/a.py", "tests/test_a.py"}
|
|
for bad_json in ('{"outer":{"x":1,"x":2}}', '{"x":Infinity}', '{"x":-Infinity}'):
|
|
with self.subTest(bad_json=bad_json), self.assertRaises(mod.ValidationError):
|
|
mod.loads_json(bad_json)
|
|
cases = []
|
|
r = result(); r["target"]["pull"] = False; cases.append(r)
|
|
r = result(); r["criteria"].append(copy.deepcopy(r["criteria"][0])); cases.append(r)
|
|
r = result(); r["criteria"][0]["contract"] = "a" * 4001; cases.append(r)
|
|
r = result(); r["criteria"][0]["criterion"] = "a" * 201; cases.append(r)
|
|
r = result(); r["criteria"][0]["result"] = {}; cases.append(r)
|
|
r = result(); r["criteria"][0]["test_paths"] *= 2; cases.append(r)
|
|
r = result(); r["criteria"][0]["test_paths"] = ["/absolute"]; cases.append(r)
|
|
r = result(); r["criteria"][0]["test_paths"] = ["tests/../file.py"]; cases.append(r)
|
|
r = result(); r["extra"] = None; cases.append(r)
|
|
r = result(); del r["scope"]; cases.append(r)
|
|
for value in cases:
|
|
with self.subTest(case=cases.index(value)), self.assertRaises(mod.ValidationError):
|
|
mod.validate_result(value, paths)
|
|
|
|
def test_cli_verify_publication_and_rejects_raw_crlf_body(self):
|
|
before, after, review, r, paths = publication_fixture()
|
|
env = dict(os.environ, PYTHONDONTWRITEBYTECODE="1")
|
|
with tempfile.TemporaryDirectory(dir="/opt/data/cache/scratch") as td:
|
|
root = Path(td)
|
|
for name, value in (("before", before), ("after", after), ("exact", review),
|
|
("result", r), ("paths", sorted(paths)), ("inline", [])):
|
|
(root / (name + ".json")).write_text(json.dumps(value, ensure_ascii=False))
|
|
cmd = [sys.executable, str(MODPATH), "verify-publication", "--before", str(root / "before.json"),
|
|
"--after", str(root / "after.json"), "--exact-review", str(root / "exact.json"),
|
|
"--result", str(root / "result.json"), "--allowed-paths", str(root / "paths.json"),
|
|
"--inline-comments", str(root / "inline.json"), "--reviewer-id", "42",
|
|
"--expected-pr-url", "https://example.test/acme/repo/pulls/7"]
|
|
p = subprocess.run(cmd, capture_output=True, text=True, env=env)
|
|
self.assertEqual(p.returncode, 0, p.stderr)
|
|
self.assertEqual(json.loads(p.stdout)["review_id"], 11)
|
|
body_path = root / "body.md"; body_path.write_bytes(review["body"].replace("\n", "\r\n").encode())
|
|
p = subprocess.run([sys.executable, str(MODPATH), "parse", "--body", str(body_path),
|
|
"--allowed-paths", str(root / "paths.json")], capture_output=True, text=True, env=env)
|
|
self.assertNotEqual(p.returncode, 0)
|
|
self.assertEqual(json.loads(p.stderr), {"error": "invalid input"})
|
|
|
|
|
|
def test_publication_accepts_causal_event_timestamp_without_forcing_clock_equality(self):
|
|
before, after, review, r, paths = publication_fixture()
|
|
event = after["timeline"][0]
|
|
event["created_at"] = event["updated_at"] = "2026-10-03T01:24:01Z"
|
|
after["pr"]["updated_at"] = event["created_at"]
|
|
verify_fixture(before, after, review, r, paths)
|
|
|
|
|
|
def test_publication_requires_independent_canonical_pr_url_anchor(self):
|
|
before, after, review, r, paths = publication_fixture()
|
|
for foreign in ("https://evil.test/acme/repo/pulls/7", "https://example.test/prefix/acme/repo/pulls/7"):
|
|
b = copy.deepcopy(before); a = copy.deepcopy(after)
|
|
b["pr"]["html_url"] = b["pr"]["url"] = foreign
|
|
a["pr"]["html_url"] = a["pr"]["url"] = foreign
|
|
a["reviews"][0]["pull_request_url"] = foreign
|
|
a["timeline"][0]["pull_request_url"] = foreign
|
|
with self.subTest(url=foreign), self.assertRaises(mod.ValidationError):
|
|
verify_fixture(b, a, a["reviews"][0], r, paths)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|