Skip to content

Commit 5b71338

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

5 files changed

Lines changed: 906 additions & 0 deletions

File tree

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+
5+
import pytest
6+
7+
from cycode.cli.apps.mcp.mcp_command import (
8+
_TempFilesManager,
9+
_sanitize_file_path,
10+
)
11+
12+
13+
# --- _sanitize_file_path input validation ---
14+
15+
16+
def test_sanitize_file_path_rejects_empty_string() -> None:
17+
with pytest.raises(ValueError, match='non-empty string'):
18+
_sanitize_file_path('')
19+
20+
21+
def test_sanitize_file_path_rejects_none() -> None:
22+
with pytest.raises(ValueError, match='non-empty string'):
23+
_sanitize_file_path(None)
24+
25+
26+
def test_sanitize_file_path_rejects_non_string() -> None:
27+
with pytest.raises(ValueError, match='non-empty string'):
28+
_sanitize_file_path(123)
29+
30+
31+
def test_sanitize_file_path_strips_null_bytes() -> None:
32+
result = _sanitize_file_path('foo/bar\x00baz.py')
33+
assert '\x00' not in result
34+
35+
36+
def test_sanitize_file_path_passes_valid_path_through() -> None:
37+
assert _sanitize_file_path('src/main.py') == 'src/main.py'
38+
39+
40+
# --- _TempFilesManager: path traversal prevention ---
41+
#
42+
# _sanitize_file_path delegates to pathvalidate which does NOT block
43+
# path traversal (../ passes through). The real security boundary is
44+
# the normpath containment check in _TempFilesManager.__enter__ (lines 136-139).
45+
# These tests verify that the two layers together prevent escaping the temp dir.
46+
47+
48+
def test_traversal_simple_dotdot_rejected() -> None:
49+
"""../../../etc/passwd must not escape the temp directory."""
50+
files = {
51+
'../../../etc/passwd': 'malicious',
52+
'safe.py': 'ok',
53+
}
54+
with _TempFilesManager(files, 'test-traversal') as temp_files:
55+
assert len(temp_files) == 1
56+
assert temp_files[0].endswith('safe.py')
57+
for tf in temp_files:
58+
assert '/etc/passwd' not in tf
59+
60+
61+
def test_traversal_backslash_dotdot_rejected() -> None:
62+
"""..\\..\\windows\\system32 must not escape the temp directory."""
63+
files = {
64+
'..\\..\\windows\\system32\\config': 'malicious',
65+
'safe.py': 'ok',
66+
}
67+
with _TempFilesManager(files, 'test-backslash') as temp_files:
68+
assert len(temp_files) == 1
69+
assert temp_files[0].endswith('safe.py')
70+
71+
72+
def test_traversal_embedded_dotdot_rejected() -> None:
73+
"""foo/../../../etc/passwd resolves outside temp dir and must be rejected."""
74+
files = {
75+
'foo/../../../etc/passwd': 'malicious',
76+
'safe.py': 'ok',
77+
}
78+
with _TempFilesManager(files, 'test-embedded') as temp_files:
79+
assert len(temp_files) == 1
80+
assert temp_files[0].endswith('safe.py')
81+
82+
83+
def test_traversal_absolute_path_rejected() -> None:
84+
"""Absolute paths must not be written outside the temp directory."""
85+
files = {
86+
'/etc/passwd': 'malicious',
87+
'safe.py': 'ok',
88+
}
89+
with _TempFilesManager(files, 'test-absolute') as temp_files:
90+
assert len(temp_files) == 1
91+
assert temp_files[0].endswith('safe.py')
92+
93+
94+
def test_traversal_dotdot_only_rejected() -> None:
95+
"""A bare '..' path must be rejected."""
96+
files = {
97+
'..': 'malicious',
98+
'safe.py': 'ok',
99+
}
100+
with _TempFilesManager(files, 'test-bare-dotdot') as temp_files:
101+
assert len(temp_files) == 1
102+
103+
104+
def test_traversal_all_malicious_raises() -> None:
105+
"""If every file path is a traversal attempt, no files are created and ValueError is raised."""
106+
files = {
107+
'../../../etc/passwd': 'malicious',
108+
'../../shadow': 'also malicious',
109+
}
110+
with pytest.raises(ValueError, match='No valid files'):
111+
with _TempFilesManager(files, 'test-all-malicious'):
112+
pass
113+
114+
115+
def test_all_created_files_are_inside_temp_dir() -> None:
116+
"""Every created file must be under the temp base directory."""
117+
files = {
118+
'a.py': 'aaa',
119+
'sub/b.py': 'bbb',
120+
'sub/deep/c.py': 'ccc',
121+
}
122+
manager = _TempFilesManager(files, 'test-containment')
123+
with manager as temp_files:
124+
base = os.path.normpath(manager.temp_base_dir)
125+
for tf in temp_files:
126+
normalized = os.path.normpath(tf)
127+
assert normalized.startswith(base + os.sep), f'{tf} escaped temp dir {base}'
128+
129+
130+
def test_mixed_valid_and_traversal_only_creates_valid() -> None:
131+
"""Valid files are created, traversal attempts are silently skipped."""
132+
files = {
133+
'../escape.py': 'bad',
134+
'legit.py': 'good',
135+
'foo/../../escape2.py': 'bad',
136+
'src/app.py': 'good',
137+
}
138+
manager = _TempFilesManager(files, 'test-mixed')
139+
with manager as temp_files:
140+
base = os.path.normpath(manager.temp_base_dir)
141+
assert len(temp_files) == 2
142+
for tf in temp_files:
143+
assert os.path.normpath(tf).startswith(base + os.sep)
144+
basenames = [os.path.basename(tf) for tf in temp_files]
145+
assert 'legit.py' in basenames
146+
assert 'app.py' in basenames
147+
148+
149+
# --- _TempFilesManager: general functionality ---
150+
151+
152+
def test_temp_files_manager_creates_files() -> None:
153+
files = {
154+
'test1.py': 'print("hello")',
155+
'subdir/test2.js': 'console.log("world")',
156+
}
157+
with _TempFilesManager(files, 'test-call-id') as temp_files:
158+
assert len(temp_files) == 2
159+
for tf in temp_files:
160+
assert os.path.exists(tf)
161+
162+
163+
def test_temp_files_manager_writes_correct_content() -> None:
164+
files = {'hello.py': 'print("hello world")'}
165+
with _TempFilesManager(files, 'test-content') as temp_files:
166+
with open(temp_files[0]) as f:
167+
assert f.read() == 'print("hello world")'
168+
169+
170+
def test_temp_files_manager_cleans_up_on_exit() -> None:
171+
files = {'cleanup.py': 'code'}
172+
manager = _TempFilesManager(files, 'test-cleanup')
173+
with manager as temp_files:
174+
temp_dir = manager.temp_base_dir
175+
assert os.path.exists(temp_dir)
176+
assert len(temp_files) == 1
177+
assert not os.path.exists(temp_dir)
178+
179+
180+
def test_temp_files_manager_empty_path_raises() -> None:
181+
files = {'': 'empty path'}
182+
with pytest.raises(ValueError, match='No valid files'):
183+
with _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)