224 lines
13 KiB
Python
224 lines
13 KiB
Python
import copy
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
from harness.tests._support.paths import ROOT
|
|
from harness.tests._support.spec import MODPATH, mod, publication_fixture, result, verify_fixture
|
|
|
|
|
|
class CodecTests(unittest.TestCase):
|
|
|
|
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"}, declaration={"mode": "none"})
|
|
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"}, declaration={"mode": "none"}), 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_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", "--no-delegated-evidence", "--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, declaration={"mode": "none"})
|
|
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, declaration={"mode": "none"}), 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, declaration={"mode": "none"})
|
|
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, declaration={"mode": "none"}), paths), r)
|
|
|
|
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, declaration={"mode": "none"})
|
|
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, declaration={"mode": "none"})
|
|
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 ('{"a":1,"a":2}', '{"a":NaN}',
|
|
'{"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)
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|