Skip to content

Commit b285b3b

Browse files
committed
CM-55551 - fix inconsistent path resolution between the lockfile check and the manifest path resolution, to guarantee restore is skipped when any npm lockfile exists
1 parent edb4e20 commit b285b3b

File tree

2 files changed

+355
-1
lines changed

2 files changed

+355
-1
lines changed

cycode/cli/files_collector/sca/sca_file_collector.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,11 @@ def _add_dependencies_tree_documents(
153153
continue
154154

155155
if restore_dependencies_document.path in documents_to_add:
156-
logger.debug('Duplicate document on restore for path: %s', restore_dependencies_document.path)
156+
# Lockfile was already collected during file discovery, so we skip adding it again
157+
logger.debug(
158+
'Lockfile already exists in scan, skipping duplicate document, %s',
159+
{'path': restore_dependencies_document.path, 'source': 'restore'},
160+
)
157161
else:
158162
logger.debug('Adding dependencies tree document, %s', restore_dependencies_document.path)
159163
documents_to_add[restore_dependencies_document.path] = restore_dependencies_document
Lines changed: 350 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,350 @@
1+
from pathlib import Path
2+
from unittest.mock import MagicMock, patch
3+
4+
import pytest
5+
import typer
6+
7+
from cycode.cli.files_collector.sca.npm.restore_npm_dependencies import (
8+
ALTERNATIVE_LOCK_FILES,
9+
NPM_LOCK_FILE_NAME,
10+
RestoreNpmDependencies,
11+
)
12+
from cycode.cli.models import Document
13+
14+
15+
@pytest.fixture
16+
def mock_ctx(tmp_path: Path) -> typer.Context:
17+
"""Create a mock typer context."""
18+
ctx = MagicMock(spec=typer.Context)
19+
ctx.obj = {'monitor': False}
20+
ctx.params = {'path': str(tmp_path)}
21+
return ctx
22+
23+
24+
@pytest.fixture
25+
def restore_npm_dependencies(mock_ctx: typer.Context) -> RestoreNpmDependencies:
26+
"""Create a RestoreNpmDependencies instance."""
27+
return RestoreNpmDependencies(mock_ctx, is_git_diff=False, command_timeout=30)
28+
29+
30+
class TestRestoreNpmDependenciesAlternativeLockfiles:
31+
"""Test that lockfiles prevent npm install from running."""
32+
33+
@pytest.mark.parametrize(
34+
'lockfile_name,lockfile_content,expected_content',
35+
[
36+
('pnpm-lock.yaml', 'lockfileVersion: 5.4\n', 'lockfileVersion: 5.4\n'),
37+
('yarn.lock', '# yarn lockfile v1\n', '# yarn lockfile v1\n'),
38+
('deno.lock', '{"version": 2}\n', '{"version": 2}\n'),
39+
('package-lock.json', '{"lockfileVersion": 2}\n', '{"lockfileVersion": 2}\n'),
40+
],
41+
)
42+
def test_lockfile_exists_should_skip_npm_install(
43+
self,
44+
restore_npm_dependencies: RestoreNpmDependencies,
45+
tmp_path: Path,
46+
lockfile_name: str,
47+
lockfile_content: str,
48+
expected_content: str,
49+
) -> None:
50+
"""Test that when any lockfile exists, npm install is skipped."""
51+
# Setup: Create package.json and lockfile
52+
package_json_path = tmp_path / 'package.json'
53+
lockfile_path = tmp_path / lockfile_name
54+
55+
package_json_path.write_text('{"name": "test", "version": "1.0.0"}')
56+
lockfile_path.write_text(lockfile_content)
57+
58+
document = Document(
59+
path=str(package_json_path),
60+
content=package_json_path.read_text(),
61+
absolute_path=str(package_json_path),
62+
)
63+
64+
# Execute
65+
result = restore_npm_dependencies.try_restore_dependencies(document)
66+
67+
# Verify: Should return lockfile content without running npm install
68+
assert result is not None
69+
assert lockfile_name in result.path
70+
assert result.content == expected_content
71+
72+
def test_no_lockfile_exists_should_proceed_with_normal_flow(
73+
self, restore_npm_dependencies: RestoreNpmDependencies, tmp_path: Path
74+
) -> None:
75+
"""Test that when no lockfile exists, normal flow proceeds (will run npm install)."""
76+
# Setup: Create only package.json (no lockfile)
77+
package_json_path = tmp_path / 'package.json'
78+
package_json_path.write_text('{"name": "test", "version": "1.0.0"}')
79+
80+
document = Document(
81+
path=str(package_json_path),
82+
content=package_json_path.read_text(),
83+
absolute_path=str(package_json_path),
84+
)
85+
86+
# Mock the base class's try_restore_dependencies to verify it's called
87+
with patch.object(
88+
restore_npm_dependencies.__class__.__bases__[0],
89+
'try_restore_dependencies',
90+
return_value=None,
91+
) as mock_super:
92+
# Execute
93+
result = restore_npm_dependencies.try_restore_dependencies(document)
94+
95+
# Verify: Should call parent's try_restore_dependencies (which will run npm install)
96+
mock_super.assert_called_once_with(document)
97+
98+
99+
class TestRestoreNpmDependenciesPathResolution:
100+
"""Test path resolution scenarios."""
101+
102+
@pytest.mark.parametrize(
103+
'has_absolute_path',
104+
[True, False],
105+
)
106+
def test_path_resolution_with_different_path_types(
107+
self,
108+
restore_npm_dependencies: RestoreNpmDependencies,
109+
tmp_path: Path,
110+
has_absolute_path: bool,
111+
) -> None:
112+
"""Test path resolution with absolute or relative paths."""
113+
package_json_path = tmp_path / 'package.json'
114+
pnpm_lock_path = tmp_path / 'pnpm-lock.yaml'
115+
116+
package_json_path.write_text('{"name": "test"}')
117+
pnpm_lock_path.write_text('lockfileVersion: 5.4\n')
118+
119+
document = Document(
120+
path=str(package_json_path),
121+
content='{"name": "test"}',
122+
absolute_path=str(package_json_path) if has_absolute_path else None,
123+
)
124+
125+
result = restore_npm_dependencies.try_restore_dependencies(document)
126+
127+
assert result is not None
128+
assert result.content == 'lockfileVersion: 5.4\n'
129+
130+
def test_path_resolution_in_monitor_mode(
131+
self, tmp_path: Path
132+
) -> None:
133+
"""Test path resolution in monitor mode."""
134+
# Setup monitor mode context
135+
ctx = MagicMock(spec=typer.Context)
136+
ctx.obj = {'monitor': True}
137+
ctx.params = {'path': str(tmp_path)}
138+
139+
restore_npm = RestoreNpmDependencies(ctx, is_git_diff=False, command_timeout=30)
140+
141+
# Create files in a subdirectory
142+
subdir = tmp_path / 'project'
143+
subdir.mkdir()
144+
package_json_path = subdir / 'package.json'
145+
pnpm_lock_path = subdir / 'pnpm-lock.yaml'
146+
147+
package_json_path.write_text('{"name": "test"}')
148+
pnpm_lock_path.write_text('lockfileVersion: 5.4\n')
149+
150+
# Document with a relative path
151+
document = Document(
152+
path='project/package.json',
153+
content='{"name": "test"}',
154+
absolute_path=str(package_json_path),
155+
)
156+
157+
result = restore_npm.try_restore_dependencies(document)
158+
159+
assert result is not None
160+
assert result.content == 'lockfileVersion: 5.4\n'
161+
162+
def test_path_resolution_with_nested_directory(
163+
self, restore_npm_dependencies: RestoreNpmDependencies, tmp_path: Path
164+
) -> None:
165+
"""Test path resolution with a nested directory structure."""
166+
subdir = tmp_path / 'src' / 'app'
167+
subdir.mkdir(parents=True)
168+
169+
package_json_path = subdir / 'package.json'
170+
pnpm_lock_path = subdir / 'pnpm-lock.yaml'
171+
172+
package_json_path.write_text('{"name": "test"}')
173+
pnpm_lock_path.write_text('lockfileVersion: 5.4\n')
174+
175+
document = Document(
176+
path=str(package_json_path),
177+
content='{"name": "test"}',
178+
absolute_path=str(package_json_path),
179+
)
180+
181+
result = restore_npm_dependencies.try_restore_dependencies(document)
182+
183+
assert result is not None
184+
assert result.content == 'lockfileVersion: 5.4\n'
185+
186+
187+
class TestRestoreNpmDependenciesEdgeCases:
188+
"""Test edge cases and error scenarios."""
189+
190+
def test_empty_lockfile_should_still_be_used(
191+
self, restore_npm_dependencies: RestoreNpmDependencies, tmp_path: Path
192+
) -> None:
193+
"""Test that the empty lockfile is still used (prevents npm install)."""
194+
package_json_path = tmp_path / 'package.json'
195+
pnpm_lock_path = tmp_path / 'pnpm-lock.yaml'
196+
197+
package_json_path.write_text('{"name": "test"}')
198+
pnpm_lock_path.write_text('') # Empty file
199+
200+
document = Document(
201+
path=str(package_json_path),
202+
content='{"name": "test"}',
203+
absolute_path=str(package_json_path),
204+
)
205+
206+
result = restore_npm_dependencies.try_restore_dependencies(document)
207+
208+
# Should still return the empty lockfile (prevents npm install)
209+
assert result is not None
210+
assert result.content == ''
211+
212+
def test_multiple_lockfiles_should_use_first_found(
213+
self, restore_npm_dependencies: RestoreNpmDependencies, tmp_path: Path
214+
) -> None:
215+
"""Test that when multiple lockfiles exist, the first one found is used (package-lock.json has priority)."""
216+
package_json_path = tmp_path / 'package.json'
217+
package_lock_path = tmp_path / 'package-lock.json'
218+
yarn_lock_path = tmp_path / 'yarn.lock'
219+
pnpm_lock_path = tmp_path / 'pnpm-lock.yaml'
220+
221+
package_json_path.write_text('{"name": "test"}')
222+
package_lock_path.write_text('{"lockfileVersion": 2}\n')
223+
yarn_lock_path.write_text('# yarn lockfile\n')
224+
pnpm_lock_path.write_text('lockfileVersion: 5.4\n')
225+
226+
document = Document(
227+
path=str(package_json_path),
228+
content='{"name": "test"}',
229+
absolute_path=str(package_json_path),
230+
)
231+
232+
result = restore_npm_dependencies.try_restore_dependencies(document)
233+
234+
# Should use package-lock.json (first in the check order)
235+
assert result is not None
236+
assert 'package-lock.json' in result.path
237+
assert result.content == '{"lockfileVersion": 2}\n'
238+
239+
def test_multiple_alternative_lockfiles_should_use_first_found(
240+
self, restore_npm_dependencies: RestoreNpmDependencies, tmp_path: Path
241+
) -> None:
242+
"""Test that when multiple alternative lockfiles exist (but no package-lock.json), the first one found is used."""
243+
package_json_path = tmp_path / 'package.json'
244+
yarn_lock_path = tmp_path / 'yarn.lock'
245+
pnpm_lock_path = tmp_path / 'pnpm-lock.yaml'
246+
247+
package_json_path.write_text('{"name": "test"}')
248+
yarn_lock_path.write_text('# yarn lockfile\n')
249+
pnpm_lock_path.write_text('lockfileVersion: 5.4\n')
250+
251+
document = Document(
252+
path=str(package_json_path),
253+
content='{"name": "test"}',
254+
absolute_path=str(package_json_path),
255+
)
256+
257+
result = restore_npm_dependencies.try_restore_dependencies(document)
258+
259+
# Should use yarn.lock (first in ALTERNATIVE_LOCK_FILES list)
260+
assert result is not None
261+
assert 'yarn.lock' in result.path
262+
assert result.content == '# yarn lockfile\n'
263+
264+
def test_lockfile_in_different_directory_should_not_be_found(
265+
self, restore_npm_dependencies: RestoreNpmDependencies, tmp_path: Path
266+
) -> None:
267+
"""Test that lockfile in a different directory is not found."""
268+
package_json_path = tmp_path / 'package.json'
269+
other_dir = tmp_path / 'other'
270+
other_dir.mkdir()
271+
pnpm_lock_path = other_dir / 'pnpm-lock.yaml'
272+
273+
package_json_path.write_text('{"name": "test"}')
274+
pnpm_lock_path.write_text('lockfileVersion: 5.4\n')
275+
276+
document = Document(
277+
path=str(package_json_path),
278+
content='{"name": "test"}',
279+
absolute_path=str(package_json_path),
280+
)
281+
282+
# Mock the base class to verify it's called (since lockfile not found)
283+
with patch.object(
284+
restore_npm_dependencies.__class__.__bases__[0],
285+
'try_restore_dependencies',
286+
return_value=None,
287+
) as mock_super:
288+
result = restore_npm_dependencies.try_restore_dependencies(document)
289+
290+
# Should proceed with normal flow since lockfile not in same directory
291+
mock_super.assert_called_once_with(document)
292+
293+
def test_non_json_file_should_not_trigger_restore(
294+
self, restore_npm_dependencies: RestoreNpmDependencies, tmp_path: Path
295+
) -> None:
296+
"""Test that non-JSON files don't trigger restore."""
297+
text_file = tmp_path / 'readme.txt'
298+
text_file.write_text('Some text')
299+
300+
document = Document(
301+
path=str(text_file),
302+
content='Some text',
303+
absolute_path=str(text_file),
304+
)
305+
306+
# Should return None because is_project() returns False
307+
result = restore_npm_dependencies.try_restore_dependencies(document)
308+
309+
assert result is None
310+
311+
312+
class TestRestoreNpmDependenciesHelperMethods:
313+
"""Test helper methods."""
314+
315+
def test_is_project_with_json_file(self, restore_npm_dependencies: RestoreNpmDependencies) -> None:
316+
"""Test is_project identifies JSON files correctly."""
317+
document = Document('package.json', '{}')
318+
assert restore_npm_dependencies.is_project(document) is True
319+
320+
document = Document('tsconfig.json', '{}')
321+
assert restore_npm_dependencies.is_project(document) is True
322+
323+
def test_is_project_with_non_json_file(self, restore_npm_dependencies: RestoreNpmDependencies) -> None:
324+
"""Test is_project returns False for non-JSON files."""
325+
document = Document('readme.txt', 'text')
326+
assert restore_npm_dependencies.is_project(document) is False
327+
328+
document = Document('script.js', 'code')
329+
assert restore_npm_dependencies.is_project(document) is False
330+
331+
def test_get_lock_file_name(self, restore_npm_dependencies: RestoreNpmDependencies) -> None:
332+
"""Test get_lock_file_name returns the correct name."""
333+
assert restore_npm_dependencies.get_lock_file_name() == NPM_LOCK_FILE_NAME
334+
335+
def test_get_lock_file_names(self, restore_npm_dependencies: RestoreNpmDependencies) -> None:
336+
"""Test get_lock_file_names returns all lockfile names."""
337+
lock_file_names = restore_npm_dependencies.get_lock_file_names()
338+
assert NPM_LOCK_FILE_NAME in lock_file_names
339+
for alt_lock in ALTERNATIVE_LOCK_FILES:
340+
assert alt_lock in lock_file_names
341+
342+
def test_prepare_manifest_file_path_for_command(
343+
self, restore_npm_dependencies: RestoreNpmDependencies
344+
) -> None:
345+
"""Test prepare_manifest_file_path_for_command removes package.json from the path."""
346+
result = restore_npm_dependencies.prepare_manifest_file_path_for_command('/path/to/package.json')
347+
assert result == '/path/to'
348+
349+
result = restore_npm_dependencies.prepare_manifest_file_path_for_command('package.json')
350+
assert result == ''

0 commit comments

Comments
 (0)