8Regression tests for :mod:`oct.mcp.validator` — Pydantic input models
9and ``validate_tool_args()`` entry point.
13- Verify all 9 Pydantic tool-argument models.
14- Verify path traversal rejection (T-01, T-02) and shell metacharacter
16- Verify unknown field rejection, integer clamping, and enum allowlists.
23 L3 — assertion details
28- Tests are in-memory validation checks; no subprocess or filesystem
32from __future__
import annotations
34from pathlib
import Path
37from pydantic
import ValidationError
49 check_shell_metacharacters,
50 validate_path_in_project,
61 assert check_shell_metacharacters(
"src/module.py") ==
"src/module.py"
64 with pytest.raises(ValueError, match=
"metacharacters"):
65 check_shell_metacharacters(
"file; rm -rf /")
68 with pytest.raises(ValueError, match=
"metacharacters"):
69 check_shell_metacharacters(
"file | cat /etc/passwd")
72 with pytest.raises(ValueError, match=
"metacharacters"):
73 check_shell_metacharacters(
"`id`")
76 with pytest.raises(ValueError, match=
"metacharacters"):
77 check_shell_metacharacters(
"$HOME")
80 with pytest.raises(ValueError, match=
"metacharacters"):
81 check_shell_metacharacters(
"file & background")
84 with pytest.raises(ValueError, match=
"metacharacters"):
85 check_shell_metacharacters(
"file > output")
88 with pytest.raises(ValueError, match=
"metacharacters"):
89 check_shell_metacharacters(
"it's a file")
92 with pytest.raises(ValueError, match=
"metacharacters"):
93 check_shell_metacharacters(
"C:\\path\\file")
96 assert check_shell_metacharacters(
"") ==
""
105 (tmp_path /
"src").mkdir()
106 validate_path_in_project(
"src", tmp_path)
109 with pytest.raises(ValueError, match=
"outside the project root"):
110 validate_path_in_project(
"../../../etc/passwd", tmp_path)
114 if sys.platform ==
"win32":
115 outside =
"C:\\Windows\\System32"
117 outside =
"/etc/passwd"
118 with pytest.raises(ValueError, match=
"outside the project root"):
119 validate_path_in_project(outside, tmp_path)
123 result = validate_path_in_project(
"../../../etc/passwd",
None)
124 assert result ==
"../../../etc/passwd"
133 m = LintArgs.model_validate({})
135 assert m.json_output
is True
137 assert m.changed
is False
138 assert m.fix_headers
is False
139 assert m.profile
is None
142 m = LintArgs.model_validate({
"profile":
"strict"})
143 assert m.profile ==
"strict"
146 with pytest.raises(ValidationError):
147 LintArgs.model_validate({
"profile":
"admin"})
150 with pytest.raises(ValidationError):
151 LintArgs.model_validate({
"jobs": 99})
154 m = LintArgs.model_validate({
"jobs": 1})
158 m = LintArgs.model_validate({
"jobs": 8})
162 with pytest.raises(ValidationError):
163 LintArgs.model_validate({
"evil_flag":
True})
166 with pytest.raises(ValidationError):
167 LintArgs.model_validate(
168 {
"paths": [
"../../../etc/passwd"]},
169 context={
"project_root": tmp_path},
173 with pytest.raises(ValidationError):
174 LintArgs.model_validate(
175 {
"paths": [
"src; rm -rf /"]},
176 context={
"project_root": tmp_path},
180 (tmp_path /
"src").mkdir()
181 m = LintArgs.model_validate(
183 context={
"project_root": tmp_path},
185 assert m.paths == [
"src"]
194 m = HealthArgs.model_validate({})
195 assert m.json_output
is True
196 assert m.verbose
is False
199 with pytest.raises(ValidationError):
200 HealthArgs.model_validate({
"secret_flag":
True})
209 m = SkeletonArgs.model_validate({})
210 assert m.json_output
is True
216 with pytest.raises(ValidationError):
217 SkeletonArgs.model_validate({
"output_dir":
"/tmp"})
220 with pytest.raises(ValidationError):
221 SkeletonArgs.model_validate({
"bad": 1})
230 m = DepsArgs.model_validate({})
231 assert m.json_output
is True
236 with pytest.raises(ValidationError):
237 DepsArgs.model_validate({
"check":
True})
246 m = OctTestArgs.model_validate({})
247 assert m.json_output
is True
249 assert m.changed
is False
252 with pytest.raises(ValidationError):
253 OctTestArgs.model_validate(
254 {
"paths": [
"../../evil"]},
255 context={
"project_root": tmp_path},
265 m = TypecheckArgs.model_validate({})
266 assert m.json_output
is True
276 m = DiagArgs.model_validate({})
277 assert m.subcommand ==
"validate-config"
282 for sub
in (
"validate-config",
"list-domains"):
283 m = DiagArgs.model_validate({
"subcommand": sub})
284 assert m.subcommand == sub
287 with pytest.raises(ValidationError):
288 DiagArgs.model_validate({
"subcommand":
"evil-command"})
291 with pytest.raises(ValidationError):
292 DiagArgs.model_validate({
"subcommand":
"show-config"})
301 m = GitStatusArgs.model_validate({})
302 assert m.json_output
is True
303 assert m.verbose
is False
306 m = GitCheckArgs.model_validate({})
307 assert m.json_output
is True
310 with pytest.raises(ValidationError):
311 GitStatusArgs.model_validate({
"inject":
"malicious"})
320 result = validate_tool_args(
"oct_lint", {})
321 assert isinstance(result, LintArgs)
324 with pytest.raises(ValueError, match=
"Unknown MCP tool"):
325 validate_tool_args(
"evil_tool", {})
329 "oct_lint",
"oct_health",
"oct_skeleton",
"oct_deps",
330 "oct_test",
"oct_typecheck",
"oct_diag",
"oct_git_status",
334 result = validate_tool_args(tool, {})
335 assert result
is not None
339 with pytest.raises(ValidationError):
342 {
"paths": [
"../../evil"]},
343 project_root=tmp_path,
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 /", {})
352 """T-02: ../../../ in paths field must be rejected."""
353 with pytest.raises(ValidationError):
356 {
"paths": [
"../../../etc/passwd"]},
357 project_root=tmp_path,
test_check_field_removed(self)
test_invalid_subcommand_rejected(self)
test_valid_subcommands(self)
test_removed_subcommand_rejected(self)
test_git_check_defaults(self)
test_git_status_defaults(self)
test_git_status_unknown_field_rejected(self)
test_unknown_field_rejected(self)
test_jobs_min_boundary(self)
test_shell_metachar_in_path_rejected(self, Path tmp_path)
test_invalid_profile_rejected(self)
test_jobs_max_boundary(self)
test_valid_path_accepted(self, Path tmp_path)
test_path_traversal_rejected(self, Path tmp_path)
test_jobs_out_of_range_rejected(self)
test_unknown_field_rejected(self)
test_path_traversal_rejected(self, Path tmp_path)
test_unknown_field_rejected(self)
test_output_dir_field_removed(self)
test_absolute_path_outside_rejected(self, Path tmp_path)
test_path_traversal_rejected(self, Path tmp_path)
test_none_project_root_skips_check(self)
test_path_inside_project_allowed(self, Path tmp_path)