Option C Tools
Loading...
Searching...
No Matches
test_audit.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# tests/tests_git/test_audit.py
4
5"""
6Purpose
7-------
8Unit tests for :mod:`oct.git.audit` — the rotating JSONL audit logger
9and the :func:`audited` decorator for Phase 4B ``oct git`` commands
10(G-B5 / G-B6).
11
12Responsibilities
13----------------
14- Verify :class:`AuditRecord` serialises to JSONL as one-object-per-line.
15- Verify the size-rotation threshold is honoured: the very first write
16 that lifts a file at/above :data:`AUDIT_MAX_SIZE_BYTES` goes into a
17 fresh file.
18- Verify the count-rotation threshold: no more than
19 :data:`AUDIT_MAX_FILES` files live in ``logs/`` at once.
20- Verify credential redaction applies to every ``args`` entry.
21- Verify :func:`audited` writes a row on both success and exception,
22 never swallows exceptions from the wrapped function, and records
23 ``duration_ms`` monotonically.
24
25Diagnostics
26-----------
27Domain: GIT-TESTS
28Levels:
29 L2 — test lifecycle
30 L3 — assertion details
31 L4 — deep tracing
32
33Contracts
34---------
35- No real git subprocess, no real Click group — the decorator is tested
36 against a minimal SimpleNamespace stand-in for ``ctx``.
37- Every test uses a ``tmp_path`` subdirectory as its project root so
38 the real ``logs/`` directory is never touched.
39"""
40
41from __future__ import annotations
42
43import json
44from pathlib import Path
45from types import SimpleNamespace
46from unittest import mock
47
48import pytest
49
50from oct.git import audit as audit_mod
51from oct.git.audit import (
52 AUDIT_MAX_FILES,
53 AUDIT_MAX_SIZE_BYTES,
54 AuditLogger,
55 AuditRecord,
56 _redact_args,
57 _resolve_project_root_from_ctx,
58 audited,
59)
60
61
62# ---------------------------------------------------------------------
63# Fixtures
64# ---------------------------------------------------------------------
65
66
67@pytest.fixture
68def project_root(tmp_path: Path) -> Path:
69 """A throwaway project root. The logs dir is NOT pre-created so we
70 can assert the logger materialises it on demand."""
71 root = tmp_path / "proj"
72 root.mkdir()
73 return root
74
75
76@pytest.fixture
78 """Factory returning a minimal populated :class:`AuditRecord`."""
79
80 def _make(**overrides) -> AuditRecord:
81 base = dict(
82 timestamp="2026-04-11T12:00:00Z",
83 command="oct git check",
84 args=["oct", "git", "check", "--staged-only"],
85 branch="feature/x",
86 staged_files=3,
87 checks_passed=True,
88 secrets_detected=False,
89 lint_violations=0,
90 format_violations=0,
91 exit_code=0,
92 duration_ms=120,
93 )
94 base.update(overrides)
95 return AuditRecord(**base)
96
97 return _make
98
99
100# ---------------------------------------------------------------------
101# _redact_args
102# ---------------------------------------------------------------------
103
104
107 assert _redact_args(None) == []
108 assert _redact_args([]) == []
109 assert _redact_args(()) == []
110
112 assert _redact_args(["oct", "git", "check"]) == ["oct", "git", "check"]
113
115 argv = ["git", "clone", "https://user:t0ken@example.com/x.git"]
116 out = _redact_args(argv)
117 assert out[0] == "git"
118 assert out[1] == "clone"
119 assert "t0ken" not in out[2]
120 assert "***@example.com" in out[2]
121
123 out = _redact_args([1, None, "ok"])
124 assert out == ["1", "None", "ok"]
125
126
127# ---------------------------------------------------------------------
128# AuditRecord <-> JSON
129# ---------------------------------------------------------------------
130
131
133 def test_asdict_roundtrip(self, make_record):
134 rec = make_record()
135 serialised = json.dumps(rec.__dict__) # cheap proxy for asdict
136 loaded = json.loads(serialised)
137 assert loaded["command"] == "oct git check"
138 assert loaded["exit_code"] == 0
139
141 rec = AuditRecord()
142 # Must not raise.
143 json.dumps({
144 "timestamp": rec.timestamp,
145 "command": rec.command,
146 "args": rec.args,
147 "branch": rec.branch,
148 "staged_files": rec.staged_files,
149 "checks_passed": rec.checks_passed,
150 "secrets_detected": rec.secrets_detected,
151 "lint_violations": rec.lint_violations,
152 "format_violations": rec.format_violations,
153 "exit_code": rec.exit_code,
154 "duration_ms": rec.duration_ms,
155 })
156
157
158# ---------------------------------------------------------------------
159# AuditLogger — basic write
160# ---------------------------------------------------------------------
161
162
164 def test_write_creates_logs_dir(self, project_root, make_record):
165 assert not (project_root / "logs").exists()
166 logger = AuditLogger(project_root)
167 logger.write(make_record())
168 assert (project_root / "logs").is_dir()
169
170 def test_write_creates_one_jsonl_file(self, project_root, make_record):
171 logger = AuditLogger(project_root)
172 logger.write(make_record())
173 files = list((project_root / "logs").glob("git-audit-*.jsonl"))
174 assert len(files) == 1
175
176 def test_write_appends_one_line_per_record(self, project_root, make_record):
177 logger = AuditLogger(project_root)
178 logger.write(make_record(exit_code=0))
179 logger.write(make_record(exit_code=1))
180 logger.write(make_record(exit_code=5))
181 files = list((project_root / "logs").glob("git-audit-*.jsonl"))
182 assert len(files) == 1
183 lines = files[0].read_text(encoding="utf-8").strip().splitlines()
184 assert len(lines) == 3
185 for line in lines:
186 parsed = json.loads(line)
187 assert "exit_code" in parsed
188
189 def test_write_redacts_args_defensively(self, project_root, make_record):
190 logger = AuditLogger(project_root)
191 rec = make_record(args=["https://user:tok@example.com/r.git"])
192 logger.write(rec)
193 files = list((project_root / "logs").glob("git-audit-*.jsonl"))
194 content = files[0].read_text(encoding="utf-8")
195 assert "tok" not in content
196 assert "***@example.com" in content
197
198 def test_write_never_raises_on_io_error(self, project_root, make_record):
199 logger = AuditLogger(project_root)
200 # Break the logs dir by pointing it at something unwriteable-ish:
201 # swap the Path for a file rather than a directory.
202 bogus = project_root / "logs"
203 bogus.write_text("not a dir")
204 # Must not raise.
205 logger.write(make_record())
206
207 def test_current_path_none_until_first_write(self, project_root, make_record):
208 logger = AuditLogger(project_root)
209 assert logger.current_path is None
210 logger.write(make_record())
211 assert logger.current_path is not None
212 assert logger.current_path.name.startswith("git-audit-")
213 assert logger.current_path.name.endswith(".jsonl")
214
215
216# ---------------------------------------------------------------------
217# AuditLogger — size rotation
218# ---------------------------------------------------------------------
219
220
223 self, project_root, make_record, monkeypatch
224 ):
225 # Shrink the threshold so rotation is cheap to trigger.
226 monkeypatch.setattr(audit_mod, "AUDIT_MAX_SIZE_BYTES", 200)
227 logger = AuditLogger(project_root)
228 # Two records below threshold, then keep writing until we roll over.
229 logger.write(make_record(command="tiny"))
230 first_path = logger.current_path
231 assert first_path is not None
232
233 # Stuff the file so the next write triggers rotation.
234 with first_path.open("a", encoding="utf-8") as fh:
235 fh.write("x" * 300)
236
237 logger.write(make_record(command="after-rotation"))
238 second_path = logger.current_path
239 assert second_path is not None
240 assert second_path != first_path
241
242 files = sorted((project_root / "logs").glob("git-audit-*.jsonl"))
243 assert len(files) == 2
244
245 # The "after-rotation" record must be in the NEW file.
246 new_content = second_path.read_text(encoding="utf-8")
247 assert "after-rotation" in new_content
248 old_content = first_path.read_text(encoding="utf-8")
249 assert "after-rotation" not in old_content
250
252 # Regression: blueprint §12.2 pins the 5 MB default.
253 assert AUDIT_MAX_SIZE_BYTES == 5 * 1024 * 1024
254
255
256# ---------------------------------------------------------------------
257# AuditLogger — count rotation
258# ---------------------------------------------------------------------
259
260
263 assert AUDIT_MAX_FILES == 20
264
266 self, project_root, make_record, monkeypatch
267 ):
268 # Seed logs/ with 25 fake audit files, each with a unique timestamp.
269 logs = project_root / "logs"
270 logs.mkdir()
271 seeded = []
272 for i in range(25):
273 # Lexical order == chronological order by construction.
274 name = f"git-audit-20260101-{i:06d}.jsonl"
275 p = logs / name
276 p.write_text(f"seed {i}\n", encoding="utf-8")
277 seeded.append(p)
278
279 # Shrink size threshold so the first real write creates a new file
280 # and triggers pruning without us having to write 5 MB of text.
281 monkeypatch.setattr(audit_mod, "AUDIT_MAX_SIZE_BYTES", 50)
282 logger = AuditLogger(project_root)
283 logger.write(make_record(command="newest"))
284
285 remaining = sorted(logs.glob("git-audit-*.jsonl"))
286 # After writing one new file + pruning down to the limit.
287 assert len(remaining) == AUDIT_MAX_FILES
288
289 # The newest file (the one just written) must still be there.
290 assert logger.current_path in remaining
291
292 # The oldest of the 25 seeds must be gone.
293 assert seeded[0] not in remaining
294
296 self, project_root, make_record, monkeypatch
297 ):
298 monkeypatch.setattr(audit_mod, "AUDIT_MAX_SIZE_BYTES", 50)
299 logger = AuditLogger(project_root)
300 for _ in range(5):
301 logger.write(make_record())
302 files = list((project_root / "logs").glob("git-audit-*.jsonl"))
303 # 5 writes under a 50-byte threshold rotate aggressively but must
304 # never prune because we stay under AUDIT_MAX_FILES.
305 assert 1 <= len(files) <= AUDIT_MAX_FILES
306
307
308# ---------------------------------------------------------------------
309# _resolve_project_root_from_ctx
310# ---------------------------------------------------------------------
311
312
314 def test_reads_project_root_key(self, tmp_path):
315 ctx = SimpleNamespace(obj={"project_root": tmp_path})
316 assert _resolve_project_root_from_ctx(ctx) == tmp_path
317
318 def test_reads_root_key_fallback(self, tmp_path):
319 ctx = SimpleNamespace(obj={"root": tmp_path})
320 assert _resolve_project_root_from_ctx(ctx) == tmp_path
321
322 def test_none_ctx_obj_falls_through(self, tmp_path, monkeypatch):
323 ctx = SimpleNamespace(obj=None)
324 monkeypatch.chdir(tmp_path)
325 result = _resolve_project_root_from_ctx(ctx)
326 # Either get_project_root returns something, or we fall to cwd.
327 assert isinstance(result, Path)
328
329
330# ---------------------------------------------------------------------
331# @audited decorator
332# ---------------------------------------------------------------------
333
334
336 def test_writes_audit_row_on_success(self, project_root, make_record):
337 @audited("oct git test-cmd")
338 def fn(ctx):
339 ctx.obj["_audit_record"] = make_record(command="oct git test-cmd")
340 return "ok"
341
342 ctx = SimpleNamespace(obj={"project_root": project_root})
343 result = fn(ctx)
344 assert result == "ok"
345 files = list((project_root / "logs").glob("git-audit-*.jsonl"))
346 assert len(files) == 1
347 content = files[0].read_text(encoding="utf-8")
348 assert "oct git test-cmd" in content
349
350 def test_writes_audit_row_on_exception(self, project_root):
351 @audited("oct git crash-cmd")
352 def fn(ctx):
353 # Command crashes before populating the record.
354 raise RuntimeError("boom")
355
356 ctx = SimpleNamespace(obj={"project_root": project_root})
357 with pytest.raises(RuntimeError, match="boom"):
358 fn(ctx)
359 files = list((project_root / "logs").glob("git-audit-*.jsonl"))
360 assert len(files) == 1
361 content = files[0].read_text(encoding="utf-8")
362 loaded = json.loads(content.strip().splitlines()[0])
363 assert loaded["command"] == "oct git crash-cmd"
364 assert loaded["exit_code"] is None
365 assert loaded["checks_passed"] is False
366
367 def test_duration_ms_is_recorded(self, project_root, make_record):
368 @audited("oct git time-cmd")
369 def fn(ctx):
370 ctx.obj["_audit_record"] = make_record(command="oct git time-cmd")
371 return 0
372
373 ctx = SimpleNamespace(obj={"project_root": project_root})
374 fn(ctx)
375 files = list((project_root / "logs").glob("git-audit-*.jsonl"))
376 loaded = json.loads(files[0].read_text(encoding="utf-8").strip())
377 # Duration is non-negative and an int.
378 assert isinstance(loaded["duration_ms"], int)
379 assert loaded["duration_ms"] >= 0
380
381 def test_creates_ctx_obj_if_missing(self, project_root):
382 @audited("oct git no-obj")
383 def fn(ctx):
384 ctx.obj["_audit_record"] = AuditRecord(
385 timestamp="2026-04-11T00:00:00Z",
386 command="oct git no-obj",
387 exit_code=0,
388 )
389 return None
390
391 ctx = SimpleNamespace(obj=None, project_root=project_root)
392 # Also cover fallback resolution via ctx.obj setdefault
393 ctx.obj = {"project_root": project_root} # seed so resolver works
394 fn(ctx)
395
397 self, project_root, make_record
398 ):
399 @audited("oct git populate")
400 def fn(ctx):
401 ctx.obj["_audit_record"] = make_record(command="oct git populate")
402 return "fine"
403
404 ctx = SimpleNamespace(obj={"project_root": project_root})
405 fn(ctx)
406 assert ctx.obj["_audit_record"] is not None
407 assert ctx.obj["_audit_record"].command == "oct git populate"
test_write_appends_one_line_per_record(self, project_root, make_record)
test_write_never_raises_on_io_error(self, project_root, make_record)
test_write_creates_logs_dir(self, project_root, make_record)
test_write_redacts_args_defensively(self, project_root, make_record)
test_current_path_none_until_first_write(self, project_root, make_record)
test_write_creates_one_jsonl_file(self, project_root, make_record)
test_writes_audit_row_on_exception(self, project_root)
test_duration_ms_is_recorded(self, project_root, make_record)
test_ctx_obj_audit_record_never_none_after_call(self, project_root, make_record)
test_creates_ctx_obj_if_missing(self, project_root)
test_writes_audit_row_on_success(self, project_root, make_record)
test_old_files_are_pruned_when_count_exceeds_max(self, project_root, make_record, monkeypatch)
test_prune_is_no_op_when_under_limit(self, project_root, make_record, monkeypatch)
test_none_ctx_obj_falls_through(self, tmp_path, monkeypatch)
test_rotation_opens_new_file_when_size_exceeds_threshold(self, project_root, make_record, monkeypatch)