Skip to content

Commit 47fa235

Browse files
committed
CM-59844: add some basic test coverage to validate updates
1 parent 29fbf6e commit 47fa235

File tree

5 files changed

+872
-0
lines changed

5 files changed

+872
-0
lines changed

tests/cli/apps/__init__.py

Whitespace-only changes.

tests/cli/apps/mcp/__init__.py

Whitespace-only changes.
Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
import asyncio
2+
import json
3+
import os
4+
import sys
5+
6+
import pytest
7+
8+
if sys.version_info < (3, 10):
9+
pytest.skip('MCP requires Python 3.10+', allow_module_level=True)
10+
11+
from cycode.cli.apps.mcp.mcp_command import (
12+
_sanitize_file_path,
13+
_TempFilesManager,
14+
)
15+
16+
# --- _sanitize_file_path input validation ---
17+
18+
19+
def test_sanitize_file_path_rejects_empty_string() -> None:
20+
with pytest.raises(ValueError, match='non-empty string'):
21+
_sanitize_file_path('')
22+
23+
24+
def test_sanitize_file_path_rejects_none() -> None:
25+
with pytest.raises(ValueError, match='non-empty string'):
26+
_sanitize_file_path(None)
27+
28+
29+
def test_sanitize_file_path_rejects_non_string() -> None:
30+
with pytest.raises(ValueError, match='non-empty string'):
31+
_sanitize_file_path(123)
32+
33+
34+
def test_sanitize_file_path_strips_null_bytes() -> None:
35+
result = _sanitize_file_path('foo/bar\x00baz.py')
36+
assert '\x00' not in result
37+
38+
39+
def test_sanitize_file_path_passes_valid_path_through() -> None:
40+
assert _sanitize_file_path('src/main.py') == 'src/main.py'
41+
42+
43+
# --- _TempFilesManager: path traversal prevention ---
44+
#
45+
# _sanitize_file_path delegates to pathvalidate which does NOT block
46+
# path traversal (../ passes through). The real security boundary is
47+
# the normpath containment check in _TempFilesManager.__enter__ (lines 136-139).
48+
# These tests verify that the two layers together prevent escaping the temp dir.
49+
50+
51+
def test_traversal_simple_dotdot_rejected() -> None:
52+
"""../../../etc/passwd must not escape the temp directory."""
53+
files = {
54+
'../../../etc/passwd': 'malicious',
55+
'safe.py': 'ok',
56+
}
57+
with _TempFilesManager(files, 'test-traversal') as temp_files:
58+
assert len(temp_files) == 1
59+
assert temp_files[0].endswith('safe.py')
60+
for tf in temp_files:
61+
assert '/etc/passwd' not in tf
62+
63+
64+
def test_traversal_backslash_dotdot_rejected() -> None:
65+
"""..\\..\\windows\\system32 must not escape the temp directory."""
66+
files = {
67+
'..\\..\\windows\\system32\\config': 'malicious',
68+
'safe.py': 'ok',
69+
}
70+
with _TempFilesManager(files, 'test-backslash') as temp_files:
71+
assert len(temp_files) == 1
72+
assert temp_files[0].endswith('safe.py')
73+
74+
75+
def test_traversal_embedded_dotdot_rejected() -> None:
76+
"""foo/../../../etc/passwd resolves outside temp dir and must be rejected."""
77+
files = {
78+
'foo/../../../etc/passwd': 'malicious',
79+
'safe.py': 'ok',
80+
}
81+
with _TempFilesManager(files, 'test-embedded') as temp_files:
82+
assert len(temp_files) == 1
83+
assert temp_files[0].endswith('safe.py')
84+
85+
86+
def test_traversal_absolute_path_rejected() -> None:
87+
"""Absolute paths must not be written outside the temp directory."""
88+
files = {
89+
'/etc/passwd': 'malicious',
90+
'safe.py': 'ok',
91+
}
92+
with _TempFilesManager(files, 'test-absolute') as temp_files:
93+
assert len(temp_files) == 1
94+
assert temp_files[0].endswith('safe.py')
95+
96+
97+
def test_traversal_dotdot_only_rejected() -> None:
98+
"""A bare '..' path must be rejected."""
99+
files = {
100+
'..': 'malicious',
101+
'safe.py': 'ok',
102+
}
103+
with _TempFilesManager(files, 'test-bare-dotdot') as temp_files:
104+
assert len(temp_files) == 1
105+
106+
107+
def test_traversal_all_malicious_raises() -> None:
108+
"""If every file path is a traversal attempt, no files are created and ValueError is raised."""
109+
files = {
110+
'../../../etc/passwd': 'malicious',
111+
'../../shadow': 'also malicious',
112+
}
113+
with pytest.raises(ValueError, match='No valid files'), _TempFilesManager(files, 'test-all-malicious'):
114+
pass
115+
116+
117+
def test_all_created_files_are_inside_temp_dir() -> None:
118+
"""Every created file must be under the temp base directory."""
119+
files = {
120+
'a.py': 'aaa',
121+
'sub/b.py': 'bbb',
122+
'sub/deep/c.py': 'ccc',
123+
}
124+
manager = _TempFilesManager(files, 'test-containment')
125+
with manager as temp_files:
126+
base = os.path.normpath(manager.temp_base_dir)
127+
for tf in temp_files:
128+
normalized = os.path.normpath(tf)
129+
assert normalized.startswith(base + os.sep), f'{tf} escaped temp dir {base}'
130+
131+
132+
def test_mixed_valid_and_traversal_only_creates_valid() -> None:
133+
"""Valid files are created, traversal attempts are silently skipped."""
134+
files = {
135+
'../escape.py': 'bad',
136+
'legit.py': 'good',
137+
'foo/../../escape2.py': 'bad',
138+
'src/app.py': 'good',
139+
}
140+
manager = _TempFilesManager(files, 'test-mixed')
141+
with manager as temp_files:
142+
base = os.path.normpath(manager.temp_base_dir)
143+
assert len(temp_files) == 2
144+
for tf in temp_files:
145+
assert os.path.normpath(tf).startswith(base + os.sep)
146+
basenames = [os.path.basename(tf) for tf in temp_files]
147+
assert 'legit.py' in basenames
148+
assert 'app.py' in basenames
149+
150+
151+
# --- _TempFilesManager: general functionality ---
152+
153+
154+
def test_temp_files_manager_creates_files() -> None:
155+
files = {
156+
'test1.py': 'print("hello")',
157+
'subdir/test2.js': 'console.log("world")',
158+
}
159+
with _TempFilesManager(files, 'test-call-id') as temp_files:
160+
assert len(temp_files) == 2
161+
for tf in temp_files:
162+
assert os.path.exists(tf)
163+
164+
165+
def test_temp_files_manager_writes_correct_content() -> None:
166+
files = {'hello.py': 'print("hello world")'}
167+
with _TempFilesManager(files, 'test-content') as temp_files, open(temp_files[0]) as f:
168+
assert f.read() == 'print("hello world")'
169+
170+
171+
def test_temp_files_manager_cleans_up_on_exit() -> None:
172+
files = {'cleanup.py': 'code'}
173+
manager = _TempFilesManager(files, 'test-cleanup')
174+
with manager as temp_files:
175+
temp_dir = manager.temp_base_dir
176+
assert os.path.exists(temp_dir)
177+
assert len(temp_files) == 1
178+
assert not os.path.exists(temp_dir)
179+
180+
181+
def test_temp_files_manager_empty_path_raises() -> None:
182+
files = {'': 'empty path'}
183+
with pytest.raises(ValueError, match='No valid files'), _TempFilesManager(files, 'test-empty-path'):
184+
pass
185+
186+
187+
def test_temp_files_manager_preserves_subdirectory_structure() -> None:
188+
files = {
189+
'src/main.py': 'main',
190+
'src/utils/helper.py': 'helper',
191+
}
192+
with _TempFilesManager(files, 'test-dirs') as temp_files:
193+
assert len(temp_files) == 2
194+
paths = [os.path.basename(tf) for tf in temp_files]
195+
assert 'main.py' in paths
196+
assert 'helper.py' in paths
197+
198+
199+
# --- _run_cycode_command (async) ---
200+
201+
202+
def test_run_cycode_command_returns_dict() -> None:
203+
from cycode.cli.apps.mcp.mcp_command import _run_cycode_command
204+
205+
result = asyncio.run(_run_cycode_command('--invalid-flag-for-test'))
206+
assert isinstance(result, dict)
207+
208+
209+
def test_run_cycode_command_timeout() -> None:
210+
from cycode.cli.apps.mcp.mcp_command import _run_cycode_command
211+
212+
result = asyncio.run(_run_cycode_command('status', timeout=0.001))
213+
assert isinstance(result, dict)
214+
assert 'error' in result
215+
216+
217+
# --- _cycode_scan_tool ---
218+
219+
220+
def test_cycode_scan_tool_no_files() -> None:
221+
from cycode.cli.apps.mcp.mcp_command import _cycode_scan_tool
222+
from cycode.cli.cli_types import ScanTypeOption
223+
224+
result = asyncio.run(_cycode_scan_tool(ScanTypeOption.SECRET, {}))
225+
parsed = json.loads(result)
226+
assert 'error' in parsed
227+
assert 'No files provided' in parsed['error']
228+
229+
230+
def test_cycode_scan_tool_invalid_files() -> None:
231+
from cycode.cli.apps.mcp.mcp_command import _cycode_scan_tool
232+
from cycode.cli.cli_types import ScanTypeOption
233+
234+
result = asyncio.run(_cycode_scan_tool(ScanTypeOption.SECRET, {'': 'content'}))
235+
parsed = json.loads(result)
236+
assert 'error' in parsed
237+
238+
239+
# --- _create_mcp_server ---
240+
241+
242+
def test_create_mcp_server() -> None:
243+
from cycode.cli.apps.mcp.mcp_command import _create_mcp_server
244+
245+
server = _create_mcp_server('127.0.0.1', 8000)
246+
assert server is not None
247+
assert server.name == 'cycode'
248+
249+
250+
def test_create_mcp_server_registers_tools() -> None:
251+
from cycode.cli.apps.mcp.mcp_command import _create_mcp_server
252+
253+
server = _create_mcp_server('127.0.0.1', 8000)
254+
tool_names = [t.name for t in server._tool_manager._tools.values()]
255+
assert 'cycode_status' in tool_names
256+
assert 'cycode_secret_scan' in tool_names
257+
assert 'cycode_sca_scan' in tool_names
258+
assert 'cycode_iac_scan' in tool_names
259+
assert 'cycode_sast_scan' in tool_names

0 commit comments

Comments
 (0)