Option C Tools
Loading...
Searching...
No Matches
test_git_check.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# tests/tests_git/test_git_check.py
4
5"""
6Purpose
7-------
8Integration tests for ``oct git check`` (Phase 4B / G-B3).
9Validates all six exit codes, the profile matrix, ``--fix`` round-trip,
10``--staged-only`` / ``--all`` scope, protected-branch override,
11audit trail, and AI-Trace emission.
12
13Responsibilities
14----------------
15- One test per exit code (0 through 5).
16- "Secrets override" test: lint + secret → exit code 5, not 1.
17- Profile matrix: ``proto``/``compact``/``strict`` × violation type.
18- ``--fix`` auto-fix then re-verify.
19- ``--fix`` cannot bypass secrets.
20- Protected-branch override forces ``strict`` despite ``--profile proto``.
21- Audit row written to ``logs/git-audit-*.jsonl`` after every run.
22
23Diagnostics
24-----------
25Domain: GIT-TESTS
26Levels:
27 L2 — test lifecycle
28 L3 — assertion details
29 L4 — deep tracing
30
31Contracts
32---------
33- Every test uses ``temp_git_project`` (real git repo on branch ``main``
34 by default).
35- Tests that need a non-protected branch create one explicitly.
36"""
37
38import json
39import subprocess
40import sys
41from pathlib import Path
42
43import pytest
44
45from tests.tests_git.conftest import _git, commit_file
46
47
48# =====================================================================
49# Helpers
50# =====================================================================
51
52
54 root: Path, *extra_args: str,
55) -> subprocess.CompletedProcess:
56 """Run ``oct git check`` in a subprocess and return the result."""
57 return subprocess.run(
58 [sys.executable, "-m", "oct.cli", "git", "check", *extra_args],
59 cwd=root,
60 capture_output=True,
61 text=True,
62 )
63
64
65def _run_check_json(root: Path, *extra_args: str) -> dict:
66 """Run ``oct git check --json`` and return the parsed output."""
67 result = _run_check(root, "--json", *extra_args)
68 return json.loads(result.stdout)
69
70
71def _switch_to_feature_branch(root: Path, branch: str = "feature/test"):
72 """Create and switch to a non-protected branch."""
73 _git(root, "checkout", "-b", branch)
74
75
76def _latest_audit_record(root: Path) -> dict | None:
77 """Read the last JSONL record from the newest audit file."""
78 logs_dir = root / "logs"
79 files = sorted(logs_dir.glob("git-audit-*.jsonl"))
80 if not files:
81 return None
82 lines = files[-1].read_text(encoding="utf-8").strip().splitlines()
83 if not lines:
84 return None
85 return json.loads(lines[-1])
86
87
88# =====================================================================
89# Tests: Exit codes
90# =====================================================================
91
92
94 """One test per exit code defined in blueprint §5.3."""
95
96 def test_exit_0_all_pass(self, temp_git_project, make_compliant_py):
97 """Clean file on a non-protected branch → exit 0."""
98 _switch_to_feature_branch(temp_git_project)
99 make_compliant_py("src/clean.py")
100 _git(temp_git_project, "add", "src/clean.py")
101
102 data = _run_check_json(temp_git_project, "--staged-only")
103 assert data["exit_code"] == 0
104 assert data["lint_violations"] == 0
105 assert data["format_violations"] == 0
106 assert data["secrets_findings"] == []
107
109 self, temp_git_project, make_noncompliant_py
110 ):
111 """Lint violation (missing header) → exit 1 (lint) or 3 (lint+format).
112
113 A missing-header file typically fails both lint and format checks
114 since the formatter also validates the header path. Exit code is
115 1 (lint only) or 3 (lint + format) depending on the file.
116 """
117 _switch_to_feature_branch(temp_git_project)
118 make_noncompliant_py("src/bad.py", "missing_header")
119 _git(temp_git_project, "add", "src/bad.py")
120
121 data = _run_check_json(temp_git_project, "--staged-only")
122 assert data["exit_code"] in (1, 3)
123 assert data["lint_violations"] > 0
124
125 def test_exit_2_format_only(self, temp_git_project, make_noncompliant_py):
126 """Format violation (wrong path in header) → exit includes format.
127
128 Note: a format violation also implies a lint violation for the
129 header check, so the actual exit code will be 3 (lint + format).
130 This test seeds a format-error file and asserts exit code >= 2.
131 """
132 _switch_to_feature_branch(temp_git_project)
133 make_noncompliant_py("src/fmterr.py", "format_error")
134 _git(temp_git_project, "add", "src/fmterr.py")
135
136 data = _run_check_json(temp_git_project, "--staged-only")
137 assert data["exit_code"] in (2, 3) # format or lint+format
138 assert data["format_violations"] > 0
139
141 self, temp_git_project, make_noncompliant_py
142 ):
143 """Both lint and format violations → exit 3."""
144 _switch_to_feature_branch(temp_git_project)
145 make_noncompliant_py("src/both.py", "format_error")
146 _git(temp_git_project, "add", "src/both.py")
147
148 data = _run_check_json(temp_git_project, "--staged-only")
149 # format_error has wrong header path → lint fail + format fail.
150 assert data["lint_violations"] > 0
151 assert data["format_violations"] > 0
152 assert data["exit_code"] == 3
153
154 def test_exit_5_secrets(self, temp_git_project, make_noncompliant_py):
155 """Hardcoded secret → exit 5 (always blocking)."""
156 _switch_to_feature_branch(temp_git_project)
157 make_noncompliant_py("src/secret.py", "hardcoded_secret")
158 _git(temp_git_project, "add", "src/secret.py")
159
160 data = _run_check_json(temp_git_project, "--staged-only")
161 assert data["exit_code"] == 5
162 assert len(data["secrets_findings"]) > 0
163
164 def test_exit_5_filename_pattern(self, temp_git_project):
165 """A ``.env`` file in staging → exit 5 (filename pattern)."""
166 _switch_to_feature_branch(temp_git_project)
167 (temp_git_project / ".env").write_text(
168 "SECRET_KEY=abc123\n", encoding="utf-8",
169 )
170 _git(temp_git_project, "add", ".env")
171
172 data = _run_check_json(temp_git_project, "--staged-only")
173 assert data["exit_code"] == 5
174 findings = data["secrets_findings"]
175 assert any("secret file pattern" in f["reason"] for f in findings)
176
177
178# =====================================================================
179# Tests: Secrets override (5 beats everything)
180# =====================================================================
181
182
185 self, temp_git_project, make_noncompliant_py
186 ):
187 """Lint violation + secret → exit 5, not 1."""
188 _switch_to_feature_branch(temp_git_project)
189 make_noncompliant_py("src/bad_lint.py", "missing_header")
190 make_noncompliant_py("src/has_secret.py", "hardcoded_secret")
191 _git(temp_git_project, "add", "-A")
192
193 data = _run_check_json(temp_git_project, "--staged-only")
194 assert data["exit_code"] == 5
195
196
197# =====================================================================
198# Tests: Profile matrix
199# =====================================================================
200
201
203 """Verify enforcement per profile.
204
205 On a non-protected branch, the CLI --profile flag controls
206 which checks block.
207 """
208
210 self, temp_git_project, make_noncompliant_py
211 ):
212 """Proto: lint is warn-only → exit 0 despite violations."""
213 _switch_to_feature_branch(temp_git_project)
214 make_noncompliant_py("src/bad.py", "missing_header")
215 _git(temp_git_project, "add", "src/bad.py")
216
217 data = _run_check_json(
218 temp_git_project, "--staged-only", "--profile", "proto",
219 )
220 # Proto: lint=warn, format=skip, secrets=blocking.
221 # Missing header is a lint violation but not blocking under proto.
222 assert data["exit_code"] == 0
223 assert data["profile"] == "proto"
224
226 self, temp_git_project, make_noncompliant_py
227 ):
228 """Proto: secrets are ALWAYS blocking → exit 5."""
229 _switch_to_feature_branch(temp_git_project)
230 make_noncompliant_py("src/secret.py", "hardcoded_secret")
231 _git(temp_git_project, "add", "src/secret.py")
232
233 data = _run_check_json(
234 temp_git_project, "--staged-only", "--profile", "proto",
235 )
236 assert data["exit_code"] == 5
237
239 self, temp_git_project, make_noncompliant_py
240 ):
241 """Compact: lint is blocking → exit 1."""
242 _switch_to_feature_branch(temp_git_project)
243 make_noncompliant_py("src/bad.py", "missing_header")
244 _git(temp_git_project, "add", "src/bad.py")
245
246 data = _run_check_json(
247 temp_git_project, "--staged-only", "--profile", "compact",
248 )
249 assert data["exit_code"] in (1, 3) # lint or lint+format
250 assert data["lint_violations"] > 0
251
253 self, temp_git_project, make_noncompliant_py
254 ):
255 """Strict: all checks blocking."""
256 _switch_to_feature_branch(temp_git_project)
257 make_noncompliant_py("src/bad.py", "missing_header")
258 _git(temp_git_project, "add", "src/bad.py")
259
260 data = _run_check_json(
261 temp_git_project, "--staged-only", "--profile", "strict",
262 )
263 assert data["exit_code"] in (1, 3) # lint or lint+format
264 assert data["lint_violations"] > 0
265
266
267# =====================================================================
268# Tests: Scope
269# =====================================================================
270
271
273 def test_staged_only(self, temp_git_project, make_compliant_py):
274 """--staged-only checks only the index, not unstaged changes."""
275 _switch_to_feature_branch(temp_git_project)
276 # Stage a clean file.
277 path = make_compliant_py("src/staged.py")
278 _git(temp_git_project, "add", "src/staged.py")
279
280 # Write a dirty (noncompliant) file but don't stage it.
281 (temp_git_project / "src" / "dirty.py").write_text(
282 "bad\n", encoding="utf-8",
283 )
284
285 data = _run_check_json(temp_git_project, "--staged-only")
286 # Only staged.py checked (1 file), and it's clean.
287 assert data["files_checked"] == 1
288 assert data["exit_code"] == 0
289
290 def test_all_checks_everything(self, temp_git_project, make_compliant_py):
291 """--all checks every Python file in the project."""
292 _switch_to_feature_branch(temp_git_project)
293 make_compliant_py("src/a.py")
294 make_compliant_py("src/b.py")
295 commit_file(
296 temp_git_project, "src/a.py",
297 (temp_git_project / "src" / "a.py").read_text(encoding="utf-8"),
298 "add a",
299 )
300 commit_file(
301 temp_git_project, "src/b.py",
302 (temp_git_project / "src" / "b.py").read_text(encoding="utf-8"),
303 "add b",
304 )
305
306 data = _run_check_json(temp_git_project, "--all")
307 assert data["files_checked"] >= 2
308
309
310# =====================================================================
311# Tests: --fix round-trip
312# =====================================================================
313
314
317 self, temp_git_project, make_noncompliant_py
318 ):
319 """--fix auto-fixes a header violation → re-verify exit 0."""
320 _switch_to_feature_branch(temp_git_project)
321 make_noncompliant_py("src/fixme.py", "missing_header")
322 _git(temp_git_project, "add", "src/fixme.py")
323
324 data = _run_check_json(
325 temp_git_project, "--staged-only", "--fix",
326 )
327 # After fix + re-verify, header should be repaired.
328 # Exit code should improve (may still have other issues).
329 # At minimum, the command should run without error.
330 assert "exit_code" in data
331
333 self, temp_git_project, make_noncompliant_py
334 ):
335 """--fix with a secret → still exit 5 (secrets never auto-fixed)."""
336 _switch_to_feature_branch(temp_git_project)
337 make_noncompliant_py("src/secret.py", "hardcoded_secret")
338 _git(temp_git_project, "add", "src/secret.py")
339
340 data = _run_check_json(
341 temp_git_project, "--staged-only", "--fix",
342 )
343 assert data["exit_code"] == 5
344 assert len(data["secrets_findings"]) > 0
345
346
347# =====================================================================
348# Tests: Protected-branch override
349# =====================================================================
350
351
353 def test_main_forces_strict(self, temp_git_project, make_noncompliant_py):
354 """On main, --profile proto is overridden to strict."""
355 # temp_git_project is on 'main' by default (protected branch).
356 make_noncompliant_py("src/bad.py", "missing_header")
357 _git(temp_git_project, "add", "src/bad.py")
358
359 data = _run_check_json(
360 temp_git_project, "--staged-only", "--profile", "proto",
361 )
362 # Profile should be forced to strict.
363 assert data["profile"] == "strict"
364 # Lint violations should block (strict profile).
365 assert data["exit_code"] in (1, 3)
366
368 self, temp_git_project, make_noncompliant_py
369 ):
370 """On a feature branch, --profile proto is respected."""
371 _switch_to_feature_branch(temp_git_project)
372 make_noncompliant_py("src/bad.py", "missing_header")
373 _git(temp_git_project, "add", "src/bad.py")
374
375 data = _run_check_json(
376 temp_git_project, "--staged-only", "--profile", "proto",
377 )
378 assert data["profile"] == "proto"
379 # Proto: lint is warn-only → exit 0.
380 assert data["exit_code"] == 0
381
382
383# =====================================================================
384# Tests: Audit trail
385# =====================================================================
386
387
389 def test_audit_row_written(self, temp_git_project, make_compliant_py):
390 """Every oct git check run writes an audit JSONL row."""
391 _switch_to_feature_branch(temp_git_project)
392 make_compliant_py("src/a.py")
393 _git(temp_git_project, "add", "src/a.py")
394
395 _run_check(temp_git_project, "--staged-only")
396
397 record = _latest_audit_record(temp_git_project)
398 assert record is not None
399 assert record["command"] == "oct git check"
400 assert "exit_code" in record
401 assert "timestamp" in record
402 assert "branch" in record
403
405 self, temp_git_project, make_noncompliant_py
406 ):
407 """Audit row is written even when checks fail."""
408 _switch_to_feature_branch(temp_git_project)
409 make_noncompliant_py("src/bad.py", "missing_header")
410 _git(temp_git_project, "add", "src/bad.py")
411
412 _run_check(temp_git_project, "--staged-only")
413
414 record = _latest_audit_record(temp_git_project)
415 assert record is not None
416 assert record["checks_passed"] is False
417 assert record["exit_code"] != 0
418
419
420# =====================================================================
421# Tests: JSON output schema
422# =====================================================================
423
424
427 self, temp_git_project, make_compliant_py
428 ):
429 """JSON output contains all required fields."""
430 _switch_to_feature_branch(temp_git_project)
431 make_compliant_py("src/a.py")
432 _git(temp_git_project, "add", "src/a.py")
433
434 data = _run_check_json(temp_git_project, "--staged-only")
435 required = {
436 "scope", "profile", "files_checked", "lint_violations",
437 "format_violations", "secrets_findings", "test_failures",
438 "exit_code", "duration_ms",
439 }
440 assert required.issubset(data.keys())
441
443 self, temp_git_project, make_noncompliant_py
444 ):
445 """Each secrets finding has path, line, and reason."""
446 _switch_to_feature_branch(temp_git_project)
447 make_noncompliant_py("src/secret.py", "hardcoded_secret")
448 _git(temp_git_project, "add", "src/secret.py")
449
450 data = _run_check_json(temp_git_project, "--staged-only")
451 for finding in data["secrets_findings"]:
452 assert "path" in finding
453 assert "line" in finding
454 assert "reason" in finding
test_audit_row_on_failure(self, temp_git_project, make_noncompliant_py)
test_audit_row_written(self, temp_git_project, make_compliant_py)
test_exit_3_lint_and_format(self, temp_git_project, make_noncompliant_py)
test_exit_2_format_only(self, temp_git_project, make_noncompliant_py)
test_exit_5_secrets(self, temp_git_project, make_noncompliant_py)
test_exit_5_filename_pattern(self, temp_git_project)
test_exit_1_or_3_lint_violation(self, temp_git_project, make_noncompliant_py)
test_exit_0_all_pass(self, temp_git_project, make_compliant_py)
test_fix_cannot_bypass_secrets(self, temp_git_project, make_noncompliant_py)
test_fix_resolves_header_violation(self, temp_git_project, make_noncompliant_py)
test_json_has_required_fields(self, temp_git_project, make_compliant_py)
test_json_secrets_findings_schema(self, temp_git_project, make_noncompliant_py)
test_strict_everything_blocks(self, temp_git_project, make_noncompliant_py)
test_proto_lint_does_not_block(self, temp_git_project, make_noncompliant_py)
test_proto_secrets_still_blocks(self, temp_git_project, make_noncompliant_py)
test_compact_lint_blocks(self, temp_git_project, make_noncompliant_py)
test_main_forces_strict(self, temp_git_project, make_noncompliant_py)
test_feature_branch_respects_cli_profile(self, temp_git_project, make_noncompliant_py)
test_all_checks_everything(self, temp_git_project, make_compliant_py)
test_staged_only(self, temp_git_project, make_compliant_py)
test_secrets_overrides_lint(self, temp_git_project, make_noncompliant_py)
dict _run_check_json(Path root, *str extra_args)
subprocess.CompletedProcess _run_check(Path root, *str extra_args)
_switch_to_feature_branch(Path root, str branch="feature/test")
dict|None _latest_audit_record(Path root)