Option C Tools
Loading...
Searching...
No Matches
test_mcp_audit.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# tests/tests_mcp/test_mcp_audit.py
4
5"""
6Purpose
7-------
8Regression tests for :mod:`oct.mcp.audit` — ``McpAuditRecord`` and
9``McpAuditLogger``.
10
11Responsibilities
12----------------
13- Verify JSONL write and rotation (size and count thresholds).
14- Verify pruning of old audit files.
15- Verify session/request ID propagation and redaction flag.
16
17Diagnostics
18-----------
19Domain: MCP-TESTS
20Levels:
21 L2 — test lifecycle
22 L3 — assertion details
23 L4 — deep tracing
24
25Contracts
26---------
27- All audit files are written to ``tmp_path``; no real logs directory
28 is touched.
29"""
30
31from __future__ import annotations
32
33import json
34import time
35from pathlib import Path
36
37import pytest
38
39from oct.mcp.audit import (
40 MCP_AUDIT_MAX_FILES,
41 MCP_AUDIT_MAX_SIZE_BYTES,
42 McpAuditLogger,
43 McpAuditRecord,
44 _hash_project_root,
45 _utc_now_iso,
46)
47
48
49# -------------------------------------------------------------------
50# McpAuditRecord
51# -------------------------------------------------------------------
52
54 def test_defaults(self):
55 r = McpAuditRecord()
56 assert r.timestamp == ""
57 assert r.session_id == ""
58 assert r.request_id == ""
59 assert r.tool == ""
60 assert r.args == {}
61 assert r.args_redacted is True
62 assert r.decision == ""
63 assert r.policy_rule == ""
64 assert r.exit_code is None
65 assert r.duration_ms == 0
66 assert r.output_size_bytes == 0
67 assert r.output_truncated is False
68 assert r.output_hash_sha256 == ""
69 assert r.redactions_applied == 0
70 assert r.user == ""
71 assert r.project_root_hash == ""
72
75 timestamp="2026-01-01T00:00:00Z",
76 session_id="s1",
77 request_id="r1",
78 tool="oct_lint",
79 args={"paths": []},
80 decision="allowed",
81 policy_rule="manifest_auto_approve",
82 exit_code=0,
83 duration_ms=42,
84 output_size_bytes=100,
85 redactions_applied=3,
86 user="alice",
87 project_root_hash="abcd1234",
88 )
89 from dataclasses import asdict
90 line = json.dumps(asdict(r))
91 assert "oct_lint" in line
92
93
94# -------------------------------------------------------------------
95# Helpers
96# -------------------------------------------------------------------
97
100 ts = _utc_now_iso()
101 assert ts.endswith("Z")
102 assert "T" in ts
103
104 def test_hash_project_root_length(self, tmp_path: Path):
105 h = _hash_project_root(tmp_path)
106 assert len(h) == 8
107
108 def test_hash_project_root_hex(self, tmp_path: Path):
109 h = _hash_project_root(tmp_path)
110 int(h, 16) # must be valid hex
111
112 def test_hash_project_root_stable(self, tmp_path: Path):
113 h1 = _hash_project_root(tmp_path)
114 h2 = _hash_project_root(tmp_path)
115 assert h1 == h2
116
117 def test_hash_different_roots_differ(self, tmp_path: Path):
118 a = tmp_path / "a"
119 b = tmp_path / "b"
120 a.mkdir()
121 b.mkdir()
122 assert _hash_project_root(a) != _hash_project_root(b)
123
124
125# -------------------------------------------------------------------
126# McpAuditLogger — basic write
127# -------------------------------------------------------------------
128
130 def test_creates_audit_dir(self, tmp_path: Path):
131 audit_dir = tmp_path / "audit_dir"
132 logger = McpAuditLogger(audit_log_path=audit_dir / "audit.log")
133 r = McpAuditRecord(tool="oct_lint", timestamp=_utc_now_iso())
134 logger.write(r)
135 assert audit_dir.exists()
136
137 def test_creates_audit_log_file(self, tmp_path: Path):
138 log = tmp_path / "audit.log"
139 logger = McpAuditLogger(audit_log_path=log)
140 logger.write(McpAuditRecord(tool="oct_lint", timestamp=_utc_now_iso()))
141 assert log.exists()
142
143 def test_writes_jsonl_line(self, tmp_path: Path):
144 log = tmp_path / "audit.log"
145 logger = McpAuditLogger(audit_log_path=log)
146 logger.write(McpAuditRecord(tool="oct_health", timestamp=_utc_now_iso()))
147 lines = log.read_text(encoding="utf-8").strip().splitlines()
148 assert len(lines) == 1
149 record = json.loads(lines[0])
150 assert record["tool"] == "oct_health"
151
152 def test_multiple_writes_multiple_lines(self, tmp_path: Path):
153 log = tmp_path / "audit.log"
154 logger = McpAuditLogger(audit_log_path=log)
155 for i in range(5):
156 logger.write(McpAuditRecord(tool=f"oct_lint_{i}", timestamp=_utc_now_iso()))
157 lines = log.read_text(encoding="utf-8").strip().splitlines()
158 assert len(lines) == 5
159
160 def test_session_id_preserved(self, tmp_path: Path):
161 log = tmp_path / "audit.log"
162 logger = McpAuditLogger(audit_log_path=log)
163 logger.write(McpAuditRecord(
164 tool="oct_lint",
165 session_id="sess-abc",
166 request_id="req-xyz",
167 timestamp=_utc_now_iso(),
168 ))
169 record = json.loads(log.read_text(encoding="utf-8").strip())
170 assert record["session_id"] == "sess-abc"
171 assert record["request_id"] == "req-xyz"
172
173 def test_args_redacted_flag(self, tmp_path: Path):
174 log = tmp_path / "audit.log"
175 logger = McpAuditLogger(audit_log_path=log)
176 logger.write(McpAuditRecord(
177 tool="oct_lint",
178 args={"paths": ["src/"]},
179 args_redacted=True,
180 timestamp=_utc_now_iso(),
181 ))
182 record = json.loads(log.read_text(encoding="utf-8").strip())
183 assert record["args_redacted"] is True
184
185 def test_timestamp_auto_filled_when_empty(self, tmp_path: Path):
186 log = tmp_path / "audit.log"
187 logger = McpAuditLogger(audit_log_path=log)
188 # timestamp is empty — logger should fill it.
189 logger.write(McpAuditRecord(tool="oct_lint"))
190 record = json.loads(log.read_text(encoding="utf-8").strip())
191 assert record["timestamp"].endswith("Z")
192
193 def test_current_path_set_after_write(self, tmp_path: Path):
194 log = tmp_path / "audit.log"
195 logger = McpAuditLogger(audit_log_path=log)
196 assert logger.current_path is None
197 logger.write(McpAuditRecord(tool="oct_lint", timestamp=_utc_now_iso()))
198 assert logger.current_path is not None
199
201 # Writing to an invalid path should not raise.
202 logger = McpAuditLogger(audit_log_path="/\x00/invalid/audit.log")
203 # Should silently fail, not raise.
204 logger.write(McpAuditRecord(tool="oct_lint", timestamp=_utc_now_iso()))
205
206
207# -------------------------------------------------------------------
208# McpAuditLogger — rotation
209# -------------------------------------------------------------------
210
212 def test_rotation_creates_new_file(self, tmp_path: Path):
213 log = tmp_path / "audit.log"
214 # Very small rotation threshold to trigger rotation easily.
215 logger = McpAuditLogger(
216 audit_log_path=log,
217 max_size_bytes=10, # tiny threshold
218 )
219 logger.write(McpAuditRecord(tool="oct_lint", timestamp=_utc_now_iso()))
220 path_after_first = logger.current_path
221 # Write again — should rotate since file > 10 bytes.
222 logger.write(McpAuditRecord(tool="oct_health", timestamp=_utc_now_iso()))
223 path_after_second = logger.current_path
224 assert path_after_second != path_after_first
225
227 log = tmp_path / "audit.log"
228 logger = McpAuditLogger(
229 audit_log_path=log,
230 max_size_bytes=5,
231 )
232 for _ in range(3):
233 logger.write(McpAuditRecord(tool="oct_lint", timestamp=_utc_now_iso()))
234 # At least one rotated file should have the mcp-audit-* prefix.
235 rotated = list(tmp_path.glob("mcp-audit-*.jsonl"))
236 assert len(rotated) >= 1
237
238
239# -------------------------------------------------------------------
240# McpAuditLogger — pruning
241# -------------------------------------------------------------------
242
244 def test_pruning_keeps_max_files(self, tmp_path: Path):
245 log = tmp_path / "audit.log"
246 max_files = 5
247 logger = McpAuditLogger(
248 audit_log_path=log,
249 max_size_bytes=5, # rotate every write
250 max_files=max_files,
251 )
252 # Write enough to create max_files + 5 rotated files.
253 for i in range(max_files + 5):
254 logger.write(McpAuditRecord(tool="oct_lint", timestamp=_utc_now_iso()))
255 time.sleep(0.001) # ensure distinct timestamps
256
257 rotated = list(tmp_path.glob("mcp-audit-*.jsonl"))
258 assert len(rotated) <= max_files
259
260 def test_audit_dir_property(self, tmp_path: Path):
261 log = tmp_path / "subdir" / "audit.log"
262 logger = McpAuditLogger(audit_log_path=log)
263 assert logger.audit_dir == tmp_path / "subdir"
test_hash_project_root_stable(self, Path tmp_path)
test_hash_project_root_hex(self, Path tmp_path)
test_hash_different_roots_differ(self, Path tmp_path)
test_hash_project_root_length(self, Path tmp_path)