from __future__ import annotations import subprocess import sys from pathlib import Path import tempfile import unittest import json ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT)) from patchwork.audit import analyze_source, build_manifest, verify_manifest class AuditTests(unittest.TestCase): def run_cli(self, *args: str) -> subprocess.CompletedProcess[str]: return subprocess.run( [sys.executable, "-m", "patchwork", *args], cwd=ROOT, capture_output=True, text=True, ) def test_analyze_source_reports_review_indicators(self) -> None: audit = analyze_source("import subprocess\nsubprocess.run(['echo', 'x'])\n") self.assertTrue(audit["review"]["requires_human_review"]) names = {item["name"] for item in audit["review"]["indicators"]} self.assertIn("subprocess", names) self.assertIn("subprocess.run", names) def test_analyze_source_collects_basic_structure(self) -> None: audit = analyze_source("class App:\n pass\n\ndef main():\n return 1\n") self.assertEqual(["App"], audit["classes"]) self.assertEqual(["main"], audit["functions"]) self.assertFalse(audit["review"]["requires_human_review"]) def test_build_manifest_contains_hashes_and_options(self) -> None: audit = analyze_source("print('hello')\n") manifest = build_manifest( input_path="hello.py", output_path="hello_obf.py", input_source="print('hello')\n", output_source="print('hello')\n", seed=123, options={"layers": 3}, audit=audit, ) self.assertEqual("patchwork.manifest.v1", manifest["schema"]) self.assertEqual(123, manifest["build"]["seed"]) self.assertEqual(manifest["input"]["sha256"], manifest["output"]["sha256"]) self.assertIn("stats", manifest) def test_cli_strict_audit_refuses_sensitive_input(self) -> None: with tempfile.TemporaryDirectory() as raw: path = Path(raw) / "sample.py" path.write_text("import subprocess\nsubprocess.run(['echo', 'x'])\n", encoding="utf-8") result = self.run_cli(str(path), "--strict-audit") self.assertEqual(3, result.returncode) self.assertIn("strict audit refused", result.stderr) def test_cli_version_flag_does_not_require_input(self) -> None: result = self.run_cli("--version") self.assertEqual(0, result.returncode) self.assertIn("patchwork 0.8.0", result.stdout) def test_config_dump_and_keep_file_merge_options(self) -> None: with tempfile.TemporaryDirectory() as raw: directory = Path(raw) source = directory / "sample.py" config = directory / "config.json" keep_file = directory / "keep.txt" dumped = directory / "effective.json" source.write_text("def public():\n return 1\n", encoding="utf-8") config.write_text(json.dumps({"layers": 2, "rename": False, "keep": ["public"]}), encoding="utf-8") keep_file.write_text("external_name\n# ignored\n", encoding="utf-8") result = self.run_cli(str(source), "--config", str(config), "--keep-file", str(keep_file), "--dump-config", str(dumped), "--dry-run", "--quiet") self.assertEqual(0, result.returncode) effective = json.loads(dumped.read_text(encoding="utf-8")) self.assertEqual(2, effective["layers"]) self.assertFalse(effective["rename"]) self.assertEqual(["external_name", "public"], effective["keep"]) def test_dry_run_does_not_write_obfuscated_output(self) -> None: with tempfile.TemporaryDirectory() as raw: source = Path(raw) / "sample.py" output = Path(raw) / "sample_obf.py" source.write_text("print('dry run')\n", encoding="utf-8") result = self.run_cli(str(source), "-o", str(output), "--dry-run", "--quiet") self.assertEqual(0, result.returncode) self.assertFalse(output.exists()) def test_cli_writes_stats_report_manifest_and_verifies_manifest(self) -> None: with tempfile.TemporaryDirectory() as raw: directory = Path(raw) source = directory / "sample.py" output = directory / "sample_obf.py" stats = directory / "stats.json" report = directory / "report.html" manifest = directory / "manifest.json" source.write_text("print('manifest')\n", encoding="utf-8") result = self.run_cli( str(source), "-o", str(output), "--seed", "123", "--stats-json", str(stats), "--report", str(report), "--manifest", str(manifest), "--quiet", ) self.assertEqual(0, result.returncode) self.assertTrue(output.exists()) self.assertTrue(report.read_text(encoding="utf-8").startswith("")) stats_doc = json.loads(stats.read_text(encoding="utf-8")) self.assertGreater(stats_doc["output_bytes"], stats_doc["input_bytes"]) self.assertTrue(verify_manifest(manifest)["valid"]) verify_result = self.run_cli("--verify-manifest", str(manifest), "--quiet") self.assertEqual(0, verify_result.returncode) def test_budget_gate_refuses_to_write_large_output(self) -> None: with tempfile.TemporaryDirectory() as raw: source = Path(raw) / "sample.py" output = Path(raw) / "sample_obf.py" source.write_text("print('too large')\n", encoding="utf-8") result = self.run_cli(str(source), "-o", str(output), "--max-output-bytes", "1", "--quiet") self.assertEqual(4, result.returncode) self.assertFalse(output.exists()) self.assertIn("exceed max_output_bytes", result.stderr) def test_review_indicator_gate_refuses_sensitive_input(self) -> None: with tempfile.TemporaryDirectory() as raw: source = Path(raw) / "sample.py" source.write_text("import subprocess\n", encoding="utf-8") result = self.run_cli(str(source), "--max-review-indicators", "0", "--quiet") self.assertEqual(4, result.returncode) self.assertIn("review indicators", result.stderr) if __name__ == "__main__": unittest.main()