Option C Tools
Loading...
Searching...
No Matches
test_mcp_redactor.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# tests/tests_mcp/test_mcp_redactor.py
4
5"""
6Purpose
7-------
8Regression tests for :mod:`oct.mcp.redactor` — five-step output
9sanitisation pipeline.
10
11Responsibilities
12----------------
13- Verify secret-line filtering (T-03) and path anonymisation (T-07).
14- Verify ANSI stripping, truncation, and git URL redaction.
15- Verify custom redaction patterns.
16
17Diagnostics
18-----------
19Domain: MCP-TESTS
20Levels:
21 L2 — test lifecycle
22 L3 — assertion details
23 L4 — deep tracing
24
25Contracts
26---------
27- Tests operate on in-memory strings; no files are written.
28"""
29
30from __future__ import annotations
31
32import os
33from pathlib import Path
34
35import pytest
36
37from oct.mcp.redactor import Redactor, RedactorResult
38
39
40def make_redactor(**kwargs) -> Redactor:
41 return Redactor(**kwargs)
42
43
44# -------------------------------------------------------------------
45# RedactorResult
46# -------------------------------------------------------------------
47
50 r = RedactorResult(text="hi", redactions_applied=0, truncated=False, original_size_bytes=2)
51 assert r.text == "hi"
52 assert r.redactions_applied == 0
53 assert r.truncated is False
54
55
56# -------------------------------------------------------------------
57# Step 1: custom patterns from config
58# -------------------------------------------------------------------
59
61 def test_custom_pattern_string_redacted(self, tmp_path: Path):
62 r = Redactor(redaction_patterns=["MYSECRET"])
63 result = r.apply_all("output MYSECRET value", tmp_path)
64 assert "MYSECRET" not in result.text
65 assert "[REDACTED]" in result.text
66 assert result.redactions_applied >= 1
67
69 r = Redactor(redaction_patterns=[{"pattern": r"\bJWT_\w+", "replacement": "[JWT]"}])
70 result = r.apply_all("token JWT_xyz123 used", tmp_path)
71 assert "[JWT]" in result.text
72
73 def test_invalid_pattern_skipped(self, tmp_path: Path):
74 # Should not raise — invalid patterns are silently skipped.
75 r = Redactor(redaction_patterns=["[invalid(regex"])
76 result = r.apply_all("some text", tmp_path)
77 assert result.text == "some text"
78
79 def test_no_patterns_no_change(self, tmp_path: Path):
80 r = Redactor(redaction_patterns=[])
81 result = r.apply_all("clean output", tmp_path)
82 assert result.text == "clean output"
83
84
85# -------------------------------------------------------------------
86# Step 2: secret-name assignment line filtering (T-03)
87# -------------------------------------------------------------------
88
90 def test_api_key_assignment_redacted(self, tmp_path: Path):
91 r = Redactor()
92 result = r.apply_all("API_KEY=abc123secret", tmp_path)
93 assert "abc123secret" not in result.text
94 assert "[REDACTED]" in result.text
95
96 def test_password_assignment_redacted(self, tmp_path: Path):
97 r = Redactor()
98 result = r.apply_all("password=hunter2", tmp_path)
99 assert "hunter2" not in result.text
100
101 def test_token_assignment_redacted(self, tmp_path: Path):
102 r = Redactor()
103 result = r.apply_all("token=ghp_xxxxxxxxxxxx", tmp_path)
104 assert "ghp_xxxxxxxxxxxx" not in result.text
105
106 def test_colon_separator_redacted(self, tmp_path: Path):
107 r = Redactor()
108 result = r.apply_all("secret: mysecretvalue", tmp_path)
109 assert "mysecretvalue" not in result.text
110
111 def test_non_secret_key_preserved(self, tmp_path: Path):
112 r = Redactor()
113 result = r.apply_all("filename=output.py", tmp_path)
114 assert "output.py" in result.text
115
117 r = Redactor()
118 text = "result=OK\napi_key=secret123\nstatus=pass"
119 result = r.apply_all(text, tmp_path)
120 assert "OK" in result.text
121 assert "pass" in result.text
122 assert "secret123" not in result.text
123
124 def test_t03_secret_exfiltration_blocked(self, tmp_path: Path):
125 """T-03: API_KEY in output must be redacted before reaching client."""
126 r = Redactor()
127 output = "Running lint...\nAPI_KEY=sk-abc123\nLint passed."
128 result = r.apply_all(output, tmp_path)
129 assert "sk-abc123" not in result.text
130 assert result.redactions_applied >= 1
131
132
133# -------------------------------------------------------------------
134# Step 2b: git URL credential redaction
135# -------------------------------------------------------------------
136
138 def test_http_credentials_redacted(self, tmp_path: Path):
139 r = Redactor()
140 result = r.apply_all("remote: https://user:token@github.com/repo", tmp_path)
141 assert "user:token" not in result.text
142 assert "***@" in result.text
143
144
145# -------------------------------------------------------------------
146# Step 3: path anonymisation (T-07)
147# -------------------------------------------------------------------
148
150 def test_home_path_replaced(self, tmp_path: Path):
151 r = Redactor()
152 home = str(Path.home())
153 result = r.apply_all(f"config at {home}/something", tmp_path)
154 assert "[HOME]" in result.text
155 assert home not in result.text
156
157 def test_project_root_replaced(self, tmp_path: Path):
158 r = Redactor()
159 result = r.apply_all(f"file: {tmp_path}/src/main.py", tmp_path)
160 assert "[PROJECT]" in result.text
161 assert str(tmp_path) not in result.text
162
163 def test_t07_info_disclosure_project_root(self, tmp_path: Path):
164 """T-07: Absolute project path must not reach the client."""
165 r = Redactor()
166 output = f"Error in {tmp_path}/oct/mcp/config.py at line 42"
167 result = r.apply_all(output, tmp_path)
168 assert str(tmp_path) not in result.text
169 assert "[PROJECT]" in result.text
170
171 def test_t07_home_path_not_disclosed(self, tmp_path: Path):
172 """T-07: Home directory path must be anonymised."""
173 r = Redactor()
174 home = str(Path.home())
175 output = f"Audit log at {home}/.oct-mcp/audit.log"
176 result = r.apply_all(output, tmp_path)
177 assert home not in result.text
178
179
180# -------------------------------------------------------------------
181# Step 4: ANSI stripping
182# -------------------------------------------------------------------
183
185 def test_colour_codes_removed(self, tmp_path: Path):
186 r = Redactor()
187 result = r.apply_all("\x1b[32mOK\x1b[0m", tmp_path)
188 assert "\x1b" not in result.text
189 assert "OK" in result.text
190
191 def test_bold_codes_removed(self, tmp_path: Path):
192 r = Redactor()
193 result = r.apply_all("\x1b[1mBold\x1b[22m", tmp_path)
194 assert "\x1b" not in result.text
195 assert "Bold" in result.text
196
197 def test_cursor_movement_removed(self, tmp_path: Path):
198 r = Redactor()
199 result = r.apply_all("line\x1b[Aclear", tmp_path)
200 assert "\x1b" not in result.text
201
202 def test_clean_text_unchanged(self, tmp_path: Path):
203 r = Redactor()
204 result = r.apply_all("No ANSI codes here", tmp_path)
205 assert result.text == "No ANSI codes here"
206 assert result.redactions_applied == 0
207
208
209# -------------------------------------------------------------------
210# Step 5: truncation
211# -------------------------------------------------------------------
212
214 def test_output_under_limit_not_truncated(self, tmp_path: Path):
215 r = Redactor(max_output_bytes=1024)
216 result = r.apply_all("short output", tmp_path)
217 assert result.truncated is False
218
219 def test_output_over_limit_truncated(self, tmp_path: Path):
220 r = Redactor(max_output_bytes=100)
221 big = "x" * 200
222 result = r.apply_all(big, tmp_path)
223 assert result.truncated is True
224 assert "TRUNCATED" in result.text
225
226 def test_truncation_marker_present(self, tmp_path: Path):
227 r = Redactor(max_output_bytes=50)
228 result = r.apply_all("a" * 200, tmp_path)
229 assert "[OUTPUT TRUNCATED AT 50 BYTES]" in result.text
230
231 def test_exactly_at_limit_not_truncated(self, tmp_path: Path):
232 r = Redactor(max_output_bytes=100)
233 text = "a" * 100 # exactly 100 bytes in UTF-8
234 result = r.apply_all(text, tmp_path)
235 assert result.truncated is False
236
237 def test_original_size_bytes_recorded(self, tmp_path: Path):
238 r = Redactor()
239 text = "hello world"
240 result = r.apply_all(text, tmp_path)
241 assert result.original_size_bytes == len(text.encode("utf-8"))
242
243
244# -------------------------------------------------------------------
245# apply_all — combined / never raises
246# -------------------------------------------------------------------
247
249 def test_never_raises_on_empty_text(self, tmp_path: Path):
250 r = Redactor()
251 result = r.apply_all("", tmp_path)
252 assert result.text == ""
253
254 def test_returns_redactor_result(self, tmp_path: Path):
255 r = Redactor()
256 result = r.apply_all("output", tmp_path)
257 assert isinstance(result, RedactorResult)
258
259 def test_redaction_count_accumulates(self, tmp_path: Path):
260 r = Redactor(redaction_patterns=["SECRET"])
261 text = "API_KEY=abc\nSECRET\nSECRET"
262 result = r.apply_all(text, tmp_path)
263 # api_key line + 2 pattern matches
264 assert result.redactions_applied >= 3
Redactor make_redactor(**kwargs)