8Unit tests for :mod:`oct.git.audit` — the rotating JSONL audit logger
9and the :func:`audited` decorator for Phase 4B ``oct git`` commands
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
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.
30 L3 — assertion details
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.
41from __future__
import annotations
44from pathlib
import Path
45from types
import SimpleNamespace
46from unittest
import mock
50from oct.git import audit
as audit_mod
57 _resolve_project_root_from_ctx,
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"
78 """Factory returning a minimal populated :class:`AuditRecord`."""
80 def _make(**overrides) -> AuditRecord:
82 timestamp=
"2026-04-11T12:00:00Z",
83 command=
"oct git check",
84 args=[
"oct",
"git",
"check",
"--staged-only"],
88 secrets_detected=
False,
94 base.update(overrides)
107 assert _redact_args(
None) == []
108 assert _redact_args([]) == []
109 assert _redact_args(()) == []
112 assert _redact_args([
"oct",
"git",
"check"]) == [
"oct",
"git",
"check"]
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]
123 out = _redact_args([1,
None,
"ok"])
124 assert out == [
"1",
"None",
"ok"]
135 serialised = json.dumps(rec.__dict__)
136 loaded = json.loads(serialised)
137 assert loaded[
"command"] ==
"oct git check"
138 assert loaded[
"exit_code"] == 0
144 "timestamp": rec.timestamp,
145 "command": rec.command,
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,
165 assert not (project_root /
"logs").exists()
168 assert (project_root /
"logs").is_dir()
173 files = list((project_root /
"logs").glob(
"git-audit-*.jsonl"))
174 assert len(files) == 1
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
186 parsed = json.loads(line)
187 assert "exit_code" in parsed
191 rec =
make_record(args=[
"https://user:tok@example.com/r.git"])
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
202 bogus = project_root /
"logs"
203 bogus.write_text(
"not a dir")
209 assert logger.current_path
is None
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")
223 self, project_root, make_record, monkeypatch
226 monkeypatch.setattr(audit_mod,
"AUDIT_MAX_SIZE_BYTES", 200)
230 first_path = logger.current_path
231 assert first_path
is not None
234 with first_path.open(
"a", encoding=
"utf-8")
as fh:
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
242 files = sorted((project_root /
"logs").glob(
"git-audit-*.jsonl"))
243 assert len(files) == 2
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
253 assert AUDIT_MAX_SIZE_BYTES == 5 * 1024 * 1024
263 assert AUDIT_MAX_FILES == 20
266 self, project_root, make_record, monkeypatch
269 logs = project_root /
"logs"
274 name = f
"git-audit-20260101-{i:06d}.jsonl"
276 p.write_text(f
"seed {i}\n", encoding=
"utf-8")
281 monkeypatch.setattr(audit_mod,
"AUDIT_MAX_SIZE_BYTES", 50)
285 remaining = sorted(logs.glob(
"git-audit-*.jsonl"))
287 assert len(remaining) == AUDIT_MAX_FILES
290 assert logger.current_path
in remaining
293 assert seeded[0]
not in remaining
296 self, project_root, make_record, monkeypatch
298 monkeypatch.setattr(audit_mod,
"AUDIT_MAX_SIZE_BYTES", 50)
302 files = list((project_root /
"logs").glob(
"git-audit-*.jsonl"))
305 assert 1 <= len(files) <= AUDIT_MAX_FILES
315 ctx = SimpleNamespace(obj={
"project_root": tmp_path})
316 assert _resolve_project_root_from_ctx(ctx) == tmp_path
319 ctx = SimpleNamespace(obj={
"root": tmp_path})
320 assert _resolve_project_root_from_ctx(ctx) == tmp_path
323 ctx = SimpleNamespace(obj=
None)
324 monkeypatch.chdir(tmp_path)
325 result = _resolve_project_root_from_ctx(ctx)
327 assert isinstance(result, Path)
337 @audited("oct git test-cmd")
339 ctx.obj[
"_audit_record"] =
make_record(command=
"oct git test-cmd")
342 ctx = SimpleNamespace(obj={
"project_root": project_root})
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
351 @audited("oct git crash-cmd")
354 raise RuntimeError(
"boom")
356 ctx = SimpleNamespace(obj={
"project_root": project_root})
357 with pytest.raises(RuntimeError, match=
"boom"):
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
368 @audited("oct git time-cmd")
370 ctx.obj[
"_audit_record"] =
make_record(command=
"oct git time-cmd")
373 ctx = SimpleNamespace(obj={
"project_root": project_root})
375 files = list((project_root /
"logs").glob(
"git-audit-*.jsonl"))
376 loaded = json.loads(files[0].read_text(encoding=
"utf-8").strip())
378 assert isinstance(loaded[
"duration_ms"], int)
379 assert loaded[
"duration_ms"] >= 0
382 @audited("oct git no-obj")
385 timestamp=
"2026-04-11T00:00:00Z",
386 command=
"oct git no-obj",
391 ctx = SimpleNamespace(obj=
None, project_root=project_root)
393 ctx.obj = {
"project_root": project_root}
397 self, project_root, make_record
399 @audited("oct git populate")
401 ctx.obj[
"_audit_record"] =
make_record(command=
"oct git populate")
404 ctx = SimpleNamespace(obj={
"project_root": project_root})
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_asdict_roundtrip(self, make_record)
test_defaults_are_json_safe(self)
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_max_files_default_is_twenty(self)
test_prune_is_no_op_when_under_limit(self, project_root, make_record, monkeypatch)
test_empty_returns_empty_list(self)
test_http_url_with_token_is_redacted(self)
test_plain_args_pass_through(self)
test_non_string_entries_stringified(self)
test_reads_project_root_key(self, tmp_path)
test_none_ctx_obj_falls_through(self, tmp_path, monkeypatch)
test_reads_root_key_fallback(self, tmp_path)
test_rotation_threshold_default_is_5mb(self)
test_rotation_opens_new_file_when_size_exceeds_threshold(self, project_root, make_record, monkeypatch)