Option C Tools
Loading...
Searching...
No Matches
test_mcp_validator.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# tests/tests_mcp/test_mcp_validator.py
4
5"""
6Purpose
7-------
8Regression tests for :mod:`oct.mcp.validator` — Pydantic input models
9and ``validate_tool_args()`` entry point.
10
11Responsibilities
12----------------
13- Verify all 9 Pydantic tool-argument models.
14- Verify path traversal rejection (T-01, T-02) and shell metacharacter
15 rejection (T-04).
16- Verify unknown field rejection, integer clamping, and enum allowlists.
17
18Diagnostics
19-----------
20Domain: MCP-TESTS
21Levels:
22 L2 — test lifecycle
23 L3 — assertion details
24 L4 — deep tracing
25
26Contracts
27---------
28- Tests are in-memory validation checks; no subprocess or filesystem
29 writes.
30"""
31
32from __future__ import annotations
33
34from pathlib import Path
35
36import pytest
37from pydantic import ValidationError
38
39from oct.mcp.validator import (
40 DiagArgs,
41 DepsArgs,
42 GitCheckArgs,
43 GitStatusArgs,
44 HealthArgs,
45 LintArgs,
46 OctTestArgs,
47 SkeletonArgs,
48 TypecheckArgs,
49 check_shell_metacharacters,
50 validate_path_in_project,
51 validate_tool_args,
52)
53
54
55# -------------------------------------------------------------------
56# Shell metacharacter guard
57# -------------------------------------------------------------------
58
61 assert check_shell_metacharacters("src/module.py") == "src/module.py"
62
64 with pytest.raises(ValueError, match="metacharacters"):
65 check_shell_metacharacters("file; rm -rf /")
66
68 with pytest.raises(ValueError, match="metacharacters"):
69 check_shell_metacharacters("file | cat /etc/passwd")
70
72 with pytest.raises(ValueError, match="metacharacters"):
73 check_shell_metacharacters("`id`")
74
76 with pytest.raises(ValueError, match="metacharacters"):
77 check_shell_metacharacters("$HOME")
78
80 with pytest.raises(ValueError, match="metacharacters"):
81 check_shell_metacharacters("file & background")
82
84 with pytest.raises(ValueError, match="metacharacters"):
85 check_shell_metacharacters("file > output")
86
88 with pytest.raises(ValueError, match="metacharacters"):
89 check_shell_metacharacters("it's a file")
90
92 with pytest.raises(ValueError, match="metacharacters"):
93 check_shell_metacharacters("C:\\path\\file")
94
96 assert check_shell_metacharacters("") == ""
97
98
99# -------------------------------------------------------------------
100# Path containment helper
101# -------------------------------------------------------------------
102
104 def test_path_inside_project_allowed(self, tmp_path: Path):
105 (tmp_path / "src").mkdir()
106 validate_path_in_project("src", tmp_path) # should not raise
107
108 def test_path_traversal_rejected(self, tmp_path: Path):
109 with pytest.raises(ValueError, match="outside the project root"):
110 validate_path_in_project("../../../etc/passwd", tmp_path)
111
112 def test_absolute_path_outside_rejected(self, tmp_path: Path):
113 import sys
114 if sys.platform == "win32":
115 outside = "C:\\Windows\\System32"
116 else:
117 outside = "/etc/passwd"
118 with pytest.raises(ValueError, match="outside the project root"):
119 validate_path_in_project(outside, tmp_path)
120
122 # When project_root is None, containment check is skipped.
123 result = validate_path_in_project("../../../etc/passwd", None)
124 assert result == "../../../etc/passwd"
125
126
127# -------------------------------------------------------------------
128# LintArgs
129# -------------------------------------------------------------------
130
132 def test_defaults(self):
133 m = LintArgs.model_validate({})
134 assert m.paths == []
135 assert m.json_output is True
136 assert m.jobs == 1
137 assert m.changed is False
138 assert m.fix_headers is False
139 assert m.profile is None
140
142 m = LintArgs.model_validate({"profile": "strict"})
143 assert m.profile == "strict"
144
146 with pytest.raises(ValidationError):
147 LintArgs.model_validate({"profile": "admin"})
148
150 with pytest.raises(ValidationError):
151 LintArgs.model_validate({"jobs": 99})
152
154 m = LintArgs.model_validate({"jobs": 1})
155 assert m.jobs == 1
156
158 m = LintArgs.model_validate({"jobs": 8})
159 assert m.jobs == 8
160
162 with pytest.raises(ValidationError):
163 LintArgs.model_validate({"evil_flag": True})
164
165 def test_path_traversal_rejected(self, tmp_path: Path):
166 with pytest.raises(ValidationError):
167 LintArgs.model_validate(
168 {"paths": ["../../../etc/passwd"]},
169 context={"project_root": tmp_path},
170 )
171
172 def test_shell_metachar_in_path_rejected(self, tmp_path: Path):
173 with pytest.raises(ValidationError):
174 LintArgs.model_validate(
175 {"paths": ["src; rm -rf /"]},
176 context={"project_root": tmp_path},
177 )
178
179 def test_valid_path_accepted(self, tmp_path: Path):
180 (tmp_path / "src").mkdir()
181 m = LintArgs.model_validate(
182 {"paths": ["src"]},
183 context={"project_root": tmp_path},
184 )
185 assert m.paths == ["src"]
186
187
188# -------------------------------------------------------------------
189# HealthArgs
190# -------------------------------------------------------------------
191
193 def test_defaults(self):
194 m = HealthArgs.model_validate({})
195 assert m.json_output is True
196 assert m.verbose is False
197
199 with pytest.raises(ValidationError):
200 HealthArgs.model_validate({"secret_flag": True})
201
202
203# -------------------------------------------------------------------
204# SkeletonArgs
205# -------------------------------------------------------------------
206
208 def test_defaults(self):
209 m = SkeletonArgs.model_validate({})
210 assert m.json_output is True
211
213 # OI-502: ``output_dir`` was removed because the CLI does not
214 # accept ``--output-dir``. The model now rejects it via
215 # ``extra="forbid"``.
216 with pytest.raises(ValidationError):
217 SkeletonArgs.model_validate({"output_dir": "/tmp"})
218
220 with pytest.raises(ValidationError):
221 SkeletonArgs.model_validate({"bad": 1})
222
223
224# -------------------------------------------------------------------
225# DepsArgs
226# -------------------------------------------------------------------
227
229 def test_defaults(self):
230 m = DepsArgs.model_validate({})
231 assert m.json_output is True
232
234 # OI-502: ``check`` was removed because the CLI does not accept
235 # ``--check``. Pin auditing uses ``--audit-pins`` instead.
236 with pytest.raises(ValidationError):
237 DepsArgs.model_validate({"check": True})
238
239
240# -------------------------------------------------------------------
241# TestArgs
242# -------------------------------------------------------------------
243
245 def test_defaults(self):
246 m = OctTestArgs.model_validate({})
247 assert m.json_output is True
248 assert m.paths == []
249 assert m.changed is False
250
251 def test_path_traversal_rejected(self, tmp_path: Path):
252 with pytest.raises(ValidationError):
253 OctTestArgs.model_validate(
254 {"paths": ["../../evil"]},
255 context={"project_root": tmp_path},
256 )
257
258
259# -------------------------------------------------------------------
260# TypecheckArgs
261# -------------------------------------------------------------------
262
264 def test_defaults(self):
265 m = TypecheckArgs.model_validate({})
266 assert m.json_output is True
267 assert m.paths == []
268
269
270# -------------------------------------------------------------------
271# DiagArgs
272# -------------------------------------------------------------------
273
275 def test_defaults(self):
276 m = DiagArgs.model_validate({})
277 assert m.subcommand == "validate-config"
278
280 # OI-502: Literal narrowed to subcommands the CLI actually
281 # exposes (``show-config`` and ``list-checks`` were removed).
282 for sub in ("validate-config", "list-domains"):
283 m = DiagArgs.model_validate({"subcommand": sub})
284 assert m.subcommand == sub
285
287 with pytest.raises(ValidationError):
288 DiagArgs.model_validate({"subcommand": "evil-command"})
289
291 with pytest.raises(ValidationError):
292 DiagArgs.model_validate({"subcommand": "show-config"})
293
294
295# -------------------------------------------------------------------
296# GitStatusArgs / GitCheckArgs
297# -------------------------------------------------------------------
298
301 m = GitStatusArgs.model_validate({})
302 assert m.json_output is True
303 assert m.verbose is False
304
306 m = GitCheckArgs.model_validate({})
307 assert m.json_output is True
308
310 with pytest.raises(ValidationError):
311 GitStatusArgs.model_validate({"inject": "malicious"})
312
313
314# -------------------------------------------------------------------
315# validate_tool_args() entry point
316# -------------------------------------------------------------------
317
320 result = validate_tool_args("oct_lint", {})
321 assert isinstance(result, LintArgs)
322
324 with pytest.raises(ValueError, match="Unknown MCP tool"):
325 validate_tool_args("evil_tool", {})
326
328 tools = [
329 "oct_lint", "oct_health", "oct_skeleton", "oct_deps",
330 "oct_test", "oct_typecheck", "oct_diag", "oct_git_status",
331 "oct_git_check",
332 ]
333 for tool in tools:
334 result = validate_tool_args(tool, {})
335 assert result is not None
336
338 # Traversal attempt should be caught when project_root is provided.
339 with pytest.raises(ValidationError):
340 validate_tool_args(
341 "oct_lint",
342 {"paths": ["../../evil"]},
343 project_root=tmp_path,
344 )
345
347 """T-01: Unknown tool name must be rejected before any execution."""
348 with pytest.raises(ValueError):
349 validate_tool_args("oct_lint; rm -rf /", {})
350
351 def test_t02_path_traversal_via_paths(self, tmp_path: Path):
352 """T-02: ../../../ in paths field must be rejected."""
353 with pytest.raises(ValidationError):
354 validate_tool_args(
355 "oct_lint",
356 {"paths": ["../../../etc/passwd"]},
357 project_root=tmp_path,
358 )