diff --git a/cycode/cli/files_collector/sca/base_restore_dependencies.py b/cycode/cli/files_collector/sca/base_restore_dependencies.py index ac391727..06431f72 100644 --- a/cycode/cli/files_collector/sca/base_restore_dependencies.py +++ b/cycode/cli/files_collector/sca/base_restore_dependencies.py @@ -92,7 +92,9 @@ def try_restore_dependencies(self, document: Document) -> Optional[Document]: ) if output is None: # one of the commands failed return None + file_was_generated = True else: + file_was_generated = False logger.debug( 'Lock file already exists, skipping restore commands, %s', {'restore_file_path': restore_file_path}, @@ -107,6 +109,14 @@ def try_restore_dependencies(self, document: Document) -> Optional[Document]: 'content_empty': not restore_file_content, }, ) + + if file_was_generated: + try: + Path(restore_file_path).unlink(missing_ok=True) + logger.debug('Cleaned up generated restore file, %s', {'restore_file_path': restore_file_path}) + except Exception as e: + logger.debug('Failed to clean up generated restore file', exc_info=e) + return Document(relative_restore_file_path, restore_file_content, self.is_git_diff) def get_manifest_dir(self, document: Document) -> Optional[str]: diff --git a/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py b/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py index 34499bdf..740ccca9 100644 --- a/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py +++ b/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py @@ -1,4 +1,5 @@ from os import path +from pathlib import Path from typing import Optional import typer @@ -9,7 +10,10 @@ execute_commands, ) from cycode.cli.models import Document -from cycode.cli.utils.path_utils import get_file_content, get_file_dir, join_paths +from cycode.cli.utils.path_utils import get_file_content, join_paths +from cycode.logger import get_logger + +logger = get_logger('Maven Restore Dependencies') BUILD_MAVEN_FILE_NAME = 'pom.xml' MAVEN_CYCLONE_DEP_TREE_FILE_NAME = 'bom.json' @@ -42,15 +46,8 @@ def try_restore_dependencies(self, document: Document) -> Optional[Document]: if document.content is None: return self.restore_from_secondary_command(document, manifest_file_path) - restore_dependencies_document = super().try_restore_dependencies(document) - if restore_dependencies_document is None: - return None - - restore_dependencies_document.content = get_file_content( - join_paths(get_file_dir(manifest_file_path), self.get_lock_file_name()) - ) - - return restore_dependencies_document + # super() reads the content and cleans up any generated file; no re-read needed + return super().try_restore_dependencies(document) def restore_from_secondary_command(self, document: Document, manifest_file_path: str) -> Optional[Document]: restore_content = execute_commands( @@ -62,11 +59,17 @@ def restore_from_secondary_command(self, document: Document, manifest_file_path: return None restore_file_path = build_dep_tree_path(document.absolute_path, MAVEN_DEP_TREE_FILE_NAME) + content = get_file_content(restore_file_path) + + try: + Path(restore_file_path).unlink(missing_ok=True) + except Exception as e: + logger.debug('Failed to clean up generated maven dep tree file', exc_info=e) + return Document( path=build_dep_tree_path(document.path, MAVEN_DEP_TREE_FILE_NAME), - content=get_file_content(restore_file_path), + content=content, is_git_diff_format=self.is_git_diff, - absolute_path=restore_file_path, ) def create_secondary_restore_commands(self, manifest_file_path: str) -> list[list[str]]: diff --git a/tests/cli/files_collector/sca/__init__.py b/tests/cli/files_collector/sca/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/files_collector/sca/go/__init__.py b/tests/cli/files_collector/sca/go/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/files_collector/sca/go/test_restore_go_dependencies.py b/tests/cli/files_collector/sca/go/test_restore_go_dependencies.py new file mode 100644 index 00000000..633d24e8 --- /dev/null +++ b/tests/cli/files_collector/sca/go/test_restore_go_dependencies.py @@ -0,0 +1,90 @@ +from pathlib import Path +from typing import Optional +from unittest.mock import MagicMock, patch + +import pytest +import typer + +from cycode.cli.files_collector.sca.go.restore_go_dependencies import ( + GO_RESTORE_FILE_NAME, + RestoreGoDependencies, +) +from cycode.cli.models import Document + +_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies' + + +@pytest.fixture +def mock_ctx(tmp_path: Path) -> typer.Context: + ctx = MagicMock(spec=typer.Context) + ctx.obj = {'monitor': False} + ctx.params = {'path': str(tmp_path)} + return ctx + + +@pytest.fixture +def restore_go(mock_ctx: typer.Context) -> RestoreGoDependencies: + return RestoreGoDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + + +class TestIsProject: + def test_go_mod_matches(self, restore_go: RestoreGoDependencies) -> None: + doc = Document('go.mod', 'module example.com/mymod\ngo 1.21\n') + assert restore_go.is_project(doc) is True + + def test_go_sum_matches(self, restore_go: RestoreGoDependencies) -> None: + doc = Document('go.sum', 'github.com/pkg/errors v0.9.1 h1:...\n') + assert restore_go.is_project(doc) is True + + def test_go_in_subdir_matches(self, restore_go: RestoreGoDependencies) -> None: + doc = Document('myapp/go.mod', 'module example.com/mymod\n') + assert restore_go.is_project(doc) is True + + def test_pom_xml_does_not_match(self, restore_go: RestoreGoDependencies) -> None: + doc = Document('pom.xml', '') + assert restore_go.is_project(doc) is False + + +class TestCleanup: + def test_generated_output_file_is_deleted_after_restore( + self, restore_go: RestoreGoDependencies, tmp_path: Path + ) -> None: + # Go handler requires both go.mod and go.sum to be present + (tmp_path / 'go.mod').write_text('module example.com/test\ngo 1.21\n') + (tmp_path / 'go.sum').write_text('github.com/pkg/errors v0.9.1 h1:abc\n') + doc = Document( + str(tmp_path / 'go.mod'), + 'module example.com/test\ngo 1.21\n', + absolute_path=str(tmp_path / 'go.mod'), + ) + output_path = tmp_path / GO_RESTORE_FILE_NAME + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + # Go uses create_output_file_manually=True; output_file_path is provided + target = output_file_path or str(output_path) + Path(target).write_text('example.com/test github.com/pkg/errors@v0.9.1\n') + return 'graph output' + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect): + result = restore_go.try_restore_dependencies(doc) + + assert result is not None + assert not output_path.exists(), f'{GO_RESTORE_FILE_NAME} must be deleted after restore' + + def test_missing_go_sum_returns_none(self, restore_go: RestoreGoDependencies, tmp_path: Path) -> None: + (tmp_path / 'go.mod').write_text('module example.com/test\ngo 1.21\n') + # go.sum intentionally absent + doc = Document( + str(tmp_path / 'go.mod'), + 'module example.com/test\ngo 1.21\n', + absolute_path=str(tmp_path / 'go.mod'), + ) + + result = restore_go.try_restore_dependencies(doc) + + assert result is None diff --git a/tests/cli/files_collector/sca/maven/__init__.py b/tests/cli/files_collector/sca/maven/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/files_collector/sca/maven/test_restore_gradle_dependencies.py b/tests/cli/files_collector/sca/maven/test_restore_gradle_dependencies.py new file mode 100644 index 00000000..72ca8a7d --- /dev/null +++ b/tests/cli/files_collector/sca/maven/test_restore_gradle_dependencies.py @@ -0,0 +1,120 @@ +from pathlib import Path +from typing import Optional +from unittest.mock import MagicMock, patch + +import pytest +import typer + +from cycode.cli.files_collector.sca.maven.restore_gradle_dependencies import ( + BUILD_GRADLE_DEP_TREE_FILE_NAME, + BUILD_GRADLE_FILE_NAME, + BUILD_GRADLE_KTS_FILE_NAME, + RestoreGradleDependencies, +) +from cycode.cli.models import Document + +_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies' + + +@pytest.fixture +def mock_ctx(tmp_path: Path) -> typer.Context: + ctx = MagicMock(spec=typer.Context) + ctx.obj = {'monitor': False, 'gradle_all_sub_projects': False} + ctx.params = {'path': str(tmp_path)} + return ctx + + +@pytest.fixture +def restore_gradle(mock_ctx: typer.Context) -> RestoreGradleDependencies: + return RestoreGradleDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + + +class TestIsProject: + def test_build_gradle_matches(self, restore_gradle: RestoreGradleDependencies) -> None: + doc = Document('build.gradle', 'apply plugin: "java"\n') + assert restore_gradle.is_project(doc) is True + + def test_build_gradle_kts_matches(self, restore_gradle: RestoreGradleDependencies) -> None: + doc = Document('build.gradle.kts', 'plugins { java }\n') + assert restore_gradle.is_project(doc) is True + + def test_pom_xml_does_not_match(self, restore_gradle: RestoreGradleDependencies) -> None: + doc = Document('pom.xml', '') + assert restore_gradle.is_project(doc) is False + + def test_settings_gradle_does_not_match(self, restore_gradle: RestoreGradleDependencies) -> None: + doc = Document('settings.gradle', 'rootProject.name = "test"') + assert restore_gradle.is_project(doc) is False + + +class TestCleanup: + def test_generated_dep_tree_file_is_deleted_after_restore( + self, restore_gradle: RestoreGradleDependencies, tmp_path: Path + ) -> None: + (tmp_path / BUILD_GRADLE_FILE_NAME).write_text('apply plugin: "java"\n') + doc = Document( + str(tmp_path / BUILD_GRADLE_FILE_NAME), + 'apply plugin: "java"\n', + absolute_path=str(tmp_path / BUILD_GRADLE_FILE_NAME), + ) + output_path = tmp_path / BUILD_GRADLE_DEP_TREE_FILE_NAME + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + # Gradle uses create_output_file_manually=True; output_file_path is provided + target = output_file_path or str(output_path) + Path(target).write_text('compileClasspath - Compile classpath:\n\\--- org.example:lib:1.0\n') + return 'dep tree output' + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect): + result = restore_gradle.try_restore_dependencies(doc) + + assert result is not None + assert not output_path.exists(), f'{BUILD_GRADLE_DEP_TREE_FILE_NAME} must be deleted after restore' + + def test_preexisting_dep_tree_file_is_not_deleted( + self, restore_gradle: RestoreGradleDependencies, tmp_path: Path + ) -> None: + dep_tree_content = 'compileClasspath - Compile classpath:\n\\--- org.example:lib:1.0\n' + (tmp_path / BUILD_GRADLE_FILE_NAME).write_text('apply plugin: "java"\n') + output_path = tmp_path / BUILD_GRADLE_DEP_TREE_FILE_NAME + output_path.write_text(dep_tree_content) + doc = Document( + str(tmp_path / BUILD_GRADLE_FILE_NAME), + 'apply plugin: "java"\n', + absolute_path=str(tmp_path / BUILD_GRADLE_FILE_NAME), + ) + + result = restore_gradle.try_restore_dependencies(doc) + + assert result is not None + assert output_path.exists(), f'Pre-existing {BUILD_GRADLE_DEP_TREE_FILE_NAME} must not be deleted' + + def test_kts_build_file_also_cleaned_up(self, restore_gradle: RestoreGradleDependencies, tmp_path: Path) -> None: + (tmp_path / BUILD_GRADLE_KTS_FILE_NAME).write_text('plugins { java }\n') + doc = Document( + str(tmp_path / BUILD_GRADLE_KTS_FILE_NAME), + 'plugins { java }\n', + absolute_path=str(tmp_path / BUILD_GRADLE_KTS_FILE_NAME), + ) + output_path = tmp_path / BUILD_GRADLE_DEP_TREE_FILE_NAME + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + target = output_file_path or str(output_path) + Path(target).write_text('compileClasspath\n') + return 'output' + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect): + result = restore_gradle.try_restore_dependencies(doc) + + assert result is not None + assert not output_path.exists(), f'{BUILD_GRADLE_DEP_TREE_FILE_NAME} must be deleted after restore' diff --git a/tests/cli/files_collector/sca/maven/test_restore_maven_dependencies.py b/tests/cli/files_collector/sca/maven/test_restore_maven_dependencies.py new file mode 100644 index 00000000..f365bd92 --- /dev/null +++ b/tests/cli/files_collector/sca/maven/test_restore_maven_dependencies.py @@ -0,0 +1,124 @@ +from pathlib import Path +from typing import Optional +from unittest.mock import MagicMock, patch + +import pytest +import typer + +from cycode.cli.files_collector.sca.maven.restore_maven_dependencies import ( + BUILD_MAVEN_FILE_NAME, + MAVEN_CYCLONE_DEP_TREE_FILE_NAME, + MAVEN_DEP_TREE_FILE_NAME, + RestoreMavenDependencies, +) +from cycode.cli.models import Document + +_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies' +_MAVEN_MODULE = 'cycode.cli.files_collector.sca.maven.restore_maven_dependencies' + + +@pytest.fixture +def mock_ctx(tmp_path: Path) -> typer.Context: + ctx = MagicMock(spec=typer.Context) + ctx.obj = {'monitor': False, 'maven_settings_file': None} + ctx.params = {'path': str(tmp_path)} + return ctx + + +@pytest.fixture +def restore_maven(mock_ctx: typer.Context) -> RestoreMavenDependencies: + return RestoreMavenDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + + +class TestIsProject: + def test_pom_xml_matches(self, restore_maven: RestoreMavenDependencies) -> None: + doc = Document('pom.xml', '') + assert restore_maven.is_project(doc) is True + + def test_pom_xml_in_subdir_matches(self, restore_maven: RestoreMavenDependencies) -> None: + doc = Document('mymodule/pom.xml', '') + assert restore_maven.is_project(doc) is True + + def test_build_gradle_does_not_match(self, restore_maven: RestoreMavenDependencies) -> None: + doc = Document('build.gradle', '') + assert restore_maven.is_project(doc) is False + + +class TestCleanup: + def test_generated_bom_is_deleted_after_primary_restore( + self, restore_maven: RestoreMavenDependencies, tmp_path: Path + ) -> None: + """Primary path: super().try_restore_dependencies() generates target/bom.json and cleans it up.""" + pom_content = '4.0.0' + (tmp_path / BUILD_MAVEN_FILE_NAME).write_text(pom_content) + target_dir = tmp_path / 'target' + target_dir.mkdir() + bom_path = target_dir / MAVEN_CYCLONE_DEP_TREE_FILE_NAME + doc = Document( + str(tmp_path / BUILD_MAVEN_FILE_NAME), + pom_content, + absolute_path=str(tmp_path / BUILD_MAVEN_FILE_NAME), + ) + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + bom_path.write_text('{"bomFormat": "CycloneDX", "components": []}') + return 'output' + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect): + result = restore_maven.try_restore_dependencies(doc) + + assert result is not None + assert result.content is not None, 'Document content must be populated even after file deletion' + assert not bom_path.exists(), f'target/{MAVEN_CYCLONE_DEP_TREE_FILE_NAME} must be deleted after restore' + + def test_generated_dep_tree_is_deleted_after_secondary_restore( + self, restore_maven: RestoreMavenDependencies, tmp_path: Path + ) -> None: + """Secondary path (content=None): mvn dependency:tree generates bcde.mvndeps and it must be cleaned up.""" + (tmp_path / BUILD_MAVEN_FILE_NAME).write_text('') + dep_tree_path = tmp_path / MAVEN_DEP_TREE_FILE_NAME + # content=None triggers the secondary command path + doc = Document( + str(tmp_path / BUILD_MAVEN_FILE_NAME), + None, + absolute_path=str(tmp_path / BUILD_MAVEN_FILE_NAME), + ) + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + dep_tree_path.write_text('[INFO] com.example:my-app:jar:1.0.0\n') + return '[INFO] BUILD SUCCESS' + + with patch(f'{_MAVEN_MODULE}.execute_commands', side_effect=side_effect): + result = restore_maven.try_restore_dependencies(doc) + + assert result is not None + assert result.content is not None + assert not dep_tree_path.exists(), f'{MAVEN_DEP_TREE_FILE_NAME} must be deleted after restore' + + def test_preexisting_bom_is_not_deleted(self, restore_maven: RestoreMavenDependencies, tmp_path: Path) -> None: + pom_content = '4.0.0' + (tmp_path / BUILD_MAVEN_FILE_NAME).write_text(pom_content) + target_dir = tmp_path / 'target' + target_dir.mkdir() + bom_path = target_dir / MAVEN_CYCLONE_DEP_TREE_FILE_NAME + bom_path.write_text('{"bomFormat": "CycloneDX", "components": [{"name": "requests"}]}') + doc = Document( + str(tmp_path / BUILD_MAVEN_FILE_NAME), + pom_content, + absolute_path=str(tmp_path / BUILD_MAVEN_FILE_NAME), + ) + + result = restore_maven.try_restore_dependencies(doc) + + assert result is not None + assert bom_path.exists(), f'Pre-existing target/{MAVEN_CYCLONE_DEP_TREE_FILE_NAME} must not be deleted' diff --git a/tests/cli/files_collector/sca/npm/test_restore_npm_dependencies.py b/tests/cli/files_collector/sca/npm/test_restore_npm_dependencies.py index aa145de3..c418b659 100644 --- a/tests/cli/files_collector/sca/npm/test_restore_npm_dependencies.py +++ b/tests/cli/files_collector/sca/npm/test_restore_npm_dependencies.py @@ -1,4 +1,5 @@ from pathlib import Path +from typing import Optional from unittest.mock import MagicMock, patch import pytest @@ -99,6 +100,44 @@ def test_get_lock_file_names_contains_only_npm_lock(self, restore_npm: RestoreNp assert restore_npm.get_lock_file_names() == [NPM_LOCK_FILE_NAME] +_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies' + + +class TestCleanup: + def test_generated_lockfile_is_deleted_after_restore( + self, restore_npm: RestoreNpmDependencies, tmp_path: Path + ) -> None: + (tmp_path / 'package.json').write_text('{"name": "test"}') + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + lock_path = tmp_path / NPM_LOCK_FILE_NAME + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + lock_path.write_text('{"lockfileVersion": 3}') + return 'output' + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect): + result = restore_npm.try_restore_dependencies(doc) + + assert result is not None + assert not lock_path.exists(), f'{NPM_LOCK_FILE_NAME} must be deleted after restore' + + def test_preexisting_lockfile_is_not_deleted(self, restore_npm: RestoreNpmDependencies, tmp_path: Path) -> None: + (tmp_path / 'package.json').write_text('{"name": "test"}') + lock_path = tmp_path / NPM_LOCK_FILE_NAME + lock_path.write_text('{"lockfileVersion": 3, "packages": {}}') + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + + result = restore_npm.try_restore_dependencies(doc) + + assert result is not None + assert lock_path.exists(), f'Pre-existing {NPM_LOCK_FILE_NAME} must not be deleted' + + class TestPrepareManifestFilePath: def test_strips_package_json_filename(self, restore_npm: RestoreNpmDependencies) -> None: path = str(Path('/path/to/package.json')) diff --git a/tests/cli/files_collector/sca/npm/test_restore_pnpm_dependencies.py b/tests/cli/files_collector/sca/npm/test_restore_pnpm_dependencies.py index 312cce83..88502578 100644 --- a/tests/cli/files_collector/sca/npm/test_restore_pnpm_dependencies.py +++ b/tests/cli/files_collector/sca/npm/test_restore_pnpm_dependencies.py @@ -1,5 +1,6 @@ from pathlib import Path -from unittest.mock import MagicMock +from typing import Optional +from unittest.mock import MagicMock, patch import pytest import typer @@ -89,3 +90,44 @@ def test_get_lock_file_name(self, restore_pnpm: RestorePnpmDependencies) -> None def test_get_commands_returns_pnpm_install(self, restore_pnpm: RestorePnpmDependencies) -> None: commands = restore_pnpm.get_commands('/path/to/package.json') assert commands == [['pnpm', 'install', '--ignore-scripts']] + + +_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies' + + +class TestCleanup: + def test_generated_lockfile_is_deleted_after_restore( + self, restore_pnpm: RestorePnpmDependencies, tmp_path: Path + ) -> None: + # pnpm: no pre-existing pnpm-lock.yaml but package.json indicates pnpm + content = '{"name": "test", "packageManager": "pnpm@8.6.2"}' + (tmp_path / 'package.json').write_text(content) + doc = Document(str(tmp_path / 'package.json'), content, absolute_path=str(tmp_path / 'package.json')) + lock_path = tmp_path / PNPM_LOCK_FILE_NAME + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + lock_path.write_text('lockfileVersion: 5.4\n') + return 'output' + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect): + result = restore_pnpm.try_restore_dependencies(doc) + + assert result is not None + assert not lock_path.exists(), f'{PNPM_LOCK_FILE_NAME} must be deleted after restore' + + def test_preexisting_lockfile_is_not_deleted(self, restore_pnpm: RestorePnpmDependencies, tmp_path: Path) -> None: + lock_content = 'lockfileVersion: 5.4\n\npackages:\n /pkg@1.0.0:\n resolution: {}\n' + (tmp_path / 'package.json').write_text('{"name": "test"}') + lock_path = tmp_path / PNPM_LOCK_FILE_NAME + lock_path.write_text(lock_content) + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + + result = restore_pnpm.try_restore_dependencies(doc) + + assert result is not None + assert lock_path.exists(), f'Pre-existing {PNPM_LOCK_FILE_NAME} must not be deleted' diff --git a/tests/cli/files_collector/sca/npm/test_restore_yarn_dependencies.py b/tests/cli/files_collector/sca/npm/test_restore_yarn_dependencies.py index 13e321c9..88175031 100644 --- a/tests/cli/files_collector/sca/npm/test_restore_yarn_dependencies.py +++ b/tests/cli/files_collector/sca/npm/test_restore_yarn_dependencies.py @@ -1,5 +1,6 @@ from pathlib import Path -from unittest.mock import MagicMock +from typing import Optional +from unittest.mock import MagicMock, patch import pytest import typer @@ -89,3 +90,44 @@ def test_get_lock_file_name(self, restore_yarn: RestoreYarnDependencies) -> None def test_get_commands_returns_yarn_install(self, restore_yarn: RestoreYarnDependencies) -> None: commands = restore_yarn.get_commands('/path/to/package.json') assert commands == [['yarn', 'install', '--ignore-scripts']] + + +_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies' + + +class TestCleanup: + def test_generated_lockfile_is_deleted_after_restore( + self, restore_yarn: RestoreYarnDependencies, tmp_path: Path + ) -> None: + # Yarn: no pre-existing yarn.lock but package.json indicates yarn + content = '{"name": "test", "packageManager": "yarn@4.0.2"}' + (tmp_path / 'package.json').write_text(content) + doc = Document(str(tmp_path / 'package.json'), content, absolute_path=str(tmp_path / 'package.json')) + lock_path = tmp_path / YARN_LOCK_FILE_NAME + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + lock_path.write_text('# yarn lockfile v1\n') + return 'output' + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect): + result = restore_yarn.try_restore_dependencies(doc) + + assert result is not None + assert not lock_path.exists(), f'{YARN_LOCK_FILE_NAME} must be deleted after restore' + + def test_preexisting_lockfile_is_not_deleted(self, restore_yarn: RestoreYarnDependencies, tmp_path: Path) -> None: + lock_content = '# yarn lockfile v1\n\npackage@1.0.0:\n resolved "https://example.com"\n' + (tmp_path / 'package.json').write_text('{"name": "test"}') + lock_path = tmp_path / YARN_LOCK_FILE_NAME + lock_path.write_text(lock_content) + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + + result = restore_yarn.try_restore_dependencies(doc) + + assert result is not None + assert lock_path.exists(), f'Pre-existing {YARN_LOCK_FILE_NAME} must not be deleted' diff --git a/tests/cli/files_collector/sca/nuget/__init__.py b/tests/cli/files_collector/sca/nuget/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/files_collector/sca/nuget/test_restore_nuget_dependencies.py b/tests/cli/files_collector/sca/nuget/test_restore_nuget_dependencies.py new file mode 100644 index 00000000..0ec13441 --- /dev/null +++ b/tests/cli/files_collector/sca/nuget/test_restore_nuget_dependencies.py @@ -0,0 +1,89 @@ +from pathlib import Path +from typing import Optional +from unittest.mock import MagicMock, patch + +import pytest +import typer + +from cycode.cli.files_collector.sca.nuget.restore_nuget_dependencies import ( + NUGET_LOCK_FILE_NAME, + RestoreNugetDependencies, +) +from cycode.cli.models import Document + +_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies' + + +@pytest.fixture +def mock_ctx(tmp_path: Path) -> typer.Context: + ctx = MagicMock(spec=typer.Context) + ctx.obj = {'monitor': False} + ctx.params = {'path': str(tmp_path)} + return ctx + + +@pytest.fixture +def restore_nuget(mock_ctx: typer.Context) -> RestoreNugetDependencies: + return RestoreNugetDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + + +class TestIsProject: + def test_csproj_matches(self, restore_nuget: RestoreNugetDependencies) -> None: + doc = Document('MyProject.csproj', '') + assert restore_nuget.is_project(doc) is True + + def test_vbproj_matches(self, restore_nuget: RestoreNugetDependencies) -> None: + doc = Document('MyProject.vbproj', '') + assert restore_nuget.is_project(doc) is True + + def test_sln_does_not_match(self, restore_nuget: RestoreNugetDependencies) -> None: + doc = Document('MySolution.sln', '') + assert restore_nuget.is_project(doc) is False + + def test_packages_json_does_not_match(self, restore_nuget: RestoreNugetDependencies) -> None: + doc = Document('packages.json', '{}') + assert restore_nuget.is_project(doc) is False + + +class TestCleanup: + def test_generated_lockfile_is_deleted_after_restore( + self, restore_nuget: RestoreNugetDependencies, tmp_path: Path + ) -> None: + (tmp_path / 'MyProject.csproj').write_text('') + doc = Document( + str(tmp_path / 'MyProject.csproj'), + '', + absolute_path=str(tmp_path / 'MyProject.csproj'), + ) + lock_path = tmp_path / NUGET_LOCK_FILE_NAME + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + lock_path.write_text('{"version": 1, "dependencies": {}}') + return 'output' + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect): + result = restore_nuget.try_restore_dependencies(doc) + + assert result is not None + assert not lock_path.exists(), f'{NUGET_LOCK_FILE_NAME} must be deleted after restore' + + def test_preexisting_lockfile_is_not_deleted(self, restore_nuget: RestoreNugetDependencies, tmp_path: Path) -> None: + lock_content = '{"version": 1, "dependencies": {"net8.0": {}}}' + (tmp_path / 'MyProject.csproj').write_text('') + lock_path = tmp_path / NUGET_LOCK_FILE_NAME + lock_path.write_text(lock_content) + doc = Document( + str(tmp_path / 'MyProject.csproj'), + '', + absolute_path=str(tmp_path / 'MyProject.csproj'), + ) + + result = restore_nuget.try_restore_dependencies(doc) + + assert result is not None + assert lock_path.exists(), f'Pre-existing {NUGET_LOCK_FILE_NAME} must not be deleted' diff --git a/tests/cli/files_collector/sca/php/test_restore_composer_dependencies.py b/tests/cli/files_collector/sca/php/test_restore_composer_dependencies.py index 463eeddb..6e3ea53b 100644 --- a/tests/cli/files_collector/sca/php/test_restore_composer_dependencies.py +++ b/tests/cli/files_collector/sca/php/test_restore_composer_dependencies.py @@ -1,5 +1,6 @@ from pathlib import Path -from unittest.mock import MagicMock +from typing import Optional +from unittest.mock import MagicMock, patch import pytest import typer @@ -68,6 +69,54 @@ def test_existing_composer_lock_returned_directly( def test_get_lock_file_name(self, restore_composer: RestoreComposerDependencies) -> None: assert restore_composer.get_lock_file_name() == COMPOSER_LOCK_FILE_NAME + +_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies' + + +class TestCleanup: + def test_generated_lockfile_is_deleted_after_restore( + self, restore_composer: RestoreComposerDependencies, tmp_path: Path + ) -> None: + manifest_content = '{"name": "vendor/project"}\n' + (tmp_path / 'composer.json').write_text(manifest_content) + doc = Document(str(tmp_path / 'composer.json'), manifest_content, absolute_path=str(tmp_path / 'composer.json')) + lock_path = tmp_path / COMPOSER_LOCK_FILE_NAME + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + lock_path.write_text('{"_readme": [], "packages": []}') + return 'output' + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect): + result = restore_composer.try_restore_dependencies(doc) + + assert result is not None + assert not lock_path.exists(), f'{COMPOSER_LOCK_FILE_NAME} must be deleted after restore' + + def test_preexisting_lockfile_is_not_deleted( + self, restore_composer: RestoreComposerDependencies, tmp_path: Path + ) -> None: + lock_content = '{\n "_readme": ["This file is @generated by Composer"],\n "packages": []\n}\n' + (tmp_path / 'composer.json').write_text('{"name": "vendor/project"}\n') + lock_path = tmp_path / COMPOSER_LOCK_FILE_NAME + lock_path.write_text(lock_content) + doc = Document( + str(tmp_path / 'composer.json'), + '{"name": "vendor/project"}\n', + absolute_path=str(tmp_path / 'composer.json'), + ) + + result = restore_composer.try_restore_dependencies(doc) + + assert result is not None + assert lock_path.exists(), f'Pre-existing {COMPOSER_LOCK_FILE_NAME} must not be deleted' + + +class TestGetCommands: def test_get_commands_returns_composer_update(self, restore_composer: RestoreComposerDependencies) -> None: commands = restore_composer.get_commands('/path/to/composer.json') assert commands == [ diff --git a/tests/cli/files_collector/sca/python/test_restore_pipenv_dependencies.py b/tests/cli/files_collector/sca/python/test_restore_pipenv_dependencies.py index 9d34a7e3..a6d97320 100644 --- a/tests/cli/files_collector/sca/python/test_restore_pipenv_dependencies.py +++ b/tests/cli/files_collector/sca/python/test_restore_pipenv_dependencies.py @@ -1,5 +1,6 @@ from pathlib import Path -from unittest.mock import MagicMock +from typing import Optional +from unittest.mock import MagicMock, patch import pytest import typer @@ -71,3 +72,47 @@ def test_get_lock_file_name(self, restore_pipenv: RestorePipenvDependencies) -> def test_get_commands_returns_pipenv_lock(self, restore_pipenv: RestorePipenvDependencies) -> None: commands = restore_pipenv.get_commands('/path/to/Pipfile') assert commands == [['pipenv', 'lock']] + + +_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies' + + +class TestCleanup: + def test_generated_lockfile_is_deleted_after_restore( + self, restore_pipenv: RestorePipenvDependencies, tmp_path: Path + ) -> None: + manifest_content = '[[source]]\nname = "pypi"\n' + (tmp_path / 'Pipfile').write_text(manifest_content) + doc = Document(str(tmp_path / 'Pipfile'), manifest_content, absolute_path=str(tmp_path / 'Pipfile')) + lock_path = tmp_path / PIPENV_LOCK_FILE_NAME + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + lock_path.write_text('{"_meta": {}, "default": {}, "develop": {}}') + return 'output' + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect): + result = restore_pipenv.try_restore_dependencies(doc) + + assert result is not None + assert not lock_path.exists(), f'{PIPENV_LOCK_FILE_NAME} must be deleted after restore' + + def test_preexisting_lockfile_is_not_deleted( + self, restore_pipenv: RestorePipenvDependencies, tmp_path: Path + ) -> None: + lock_content = '{"_meta": {"hash": {"sha256": "abc"}}, "default": {}, "develop": {}}\n' + (tmp_path / 'Pipfile').write_text('[[source]]\nname = "pypi"\n') + lock_path = tmp_path / PIPENV_LOCK_FILE_NAME + lock_path.write_text(lock_content) + doc = Document( + str(tmp_path / 'Pipfile'), '[[source]]\nname = "pypi"\n', absolute_path=str(tmp_path / 'Pipfile') + ) + + result = restore_pipenv.try_restore_dependencies(doc) + + assert result is not None + assert lock_path.exists(), f'Pre-existing {PIPENV_LOCK_FILE_NAME} must not be deleted' diff --git a/tests/cli/files_collector/sca/python/test_restore_poetry_dependencies.py b/tests/cli/files_collector/sca/python/test_restore_poetry_dependencies.py index 73f0d14f..cf4c312b 100644 --- a/tests/cli/files_collector/sca/python/test_restore_poetry_dependencies.py +++ b/tests/cli/files_collector/sca/python/test_restore_poetry_dependencies.py @@ -1,5 +1,6 @@ from pathlib import Path -from unittest.mock import MagicMock +from typing import Optional +from unittest.mock import MagicMock, patch import pytest import typer @@ -97,3 +98,52 @@ def test_get_lock_file_name(self, restore_poetry: RestorePoetryDependencies) -> def test_get_commands_returns_poetry_lock(self, restore_poetry: RestorePoetryDependencies) -> None: commands = restore_poetry.get_commands('/path/to/pyproject.toml') assert commands == [['poetry', 'lock']] + + +_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies' + + +class TestCleanup: + def test_generated_lockfile_is_deleted_after_restore( + self, restore_poetry: RestorePoetryDependencies, tmp_path: Path + ) -> None: + # Poetry: no pre-existing poetry.lock but pyproject.toml indicates poetry + manifest_content = '[tool.poetry]\nname = "test"\nversion = "1.0.0"\n' + (tmp_path / 'pyproject.toml').write_text(manifest_content) + doc = Document( + str(tmp_path / 'pyproject.toml'), manifest_content, absolute_path=str(tmp_path / 'pyproject.toml') + ) + lock_path = tmp_path / POETRY_LOCK_FILE_NAME + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + lock_path.write_text('# This file is generated by Poetry\n') + return 'output' + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect): + result = restore_poetry.try_restore_dependencies(doc) + + assert result is not None + assert not lock_path.exists(), f'{POETRY_LOCK_FILE_NAME} must be deleted after restore' + + def test_preexisting_lockfile_is_not_deleted( + self, restore_poetry: RestorePoetryDependencies, tmp_path: Path + ) -> None: + lock_content = '# This file is generated by Poetry\n\n[[package]]\nname = "requests"\n' + (tmp_path / 'pyproject.toml').write_text('[tool.poetry]\nname = "test"\n') + lock_path = tmp_path / POETRY_LOCK_FILE_NAME + lock_path.write_text(lock_content) + doc = Document( + str(tmp_path / 'pyproject.toml'), + '[tool.poetry]\nname = "test"\n', + absolute_path=str(tmp_path / 'pyproject.toml'), + ) + + result = restore_poetry.try_restore_dependencies(doc) + + assert result is not None + assert lock_path.exists(), f'Pre-existing {POETRY_LOCK_FILE_NAME} must not be deleted' diff --git a/tests/cli/files_collector/sca/ruby/__init__.py b/tests/cli/files_collector/sca/ruby/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/files_collector/sca/ruby/test_restore_ruby_dependencies.py b/tests/cli/files_collector/sca/ruby/test_restore_ruby_dependencies.py new file mode 100644 index 00000000..ac3e9d73 --- /dev/null +++ b/tests/cli/files_collector/sca/ruby/test_restore_ruby_dependencies.py @@ -0,0 +1,89 @@ +from pathlib import Path +from typing import Optional +from unittest.mock import MagicMock, patch + +import pytest +import typer + +from cycode.cli.files_collector.sca.ruby.restore_ruby_dependencies import ( + RUBY_LOCK_FILE_NAME, + RestoreRubyDependencies, +) +from cycode.cli.models import Document + +_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies' + + +@pytest.fixture +def mock_ctx(tmp_path: Path) -> typer.Context: + ctx = MagicMock(spec=typer.Context) + ctx.obj = {'monitor': False} + ctx.params = {'path': str(tmp_path)} + return ctx + + +@pytest.fixture +def restore_ruby(mock_ctx: typer.Context) -> RestoreRubyDependencies: + return RestoreRubyDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + + +class TestIsProject: + def test_gemfile_matches(self, restore_ruby: RestoreRubyDependencies) -> None: + doc = Document('Gemfile', "source 'https://rubygems.org'\n") + assert restore_ruby.is_project(doc) is True + + def test_gemfile_in_subdir_matches(self, restore_ruby: RestoreRubyDependencies) -> None: + doc = Document('myapp/Gemfile', "source 'https://rubygems.org'\n") + assert restore_ruby.is_project(doc) is True + + def test_gemfile_lock_does_not_match(self, restore_ruby: RestoreRubyDependencies) -> None: + doc = Document('Gemfile.lock', 'GEM\n remote: https://rubygems.org/\n') + assert restore_ruby.is_project(doc) is False + + def test_other_file_does_not_match(self, restore_ruby: RestoreRubyDependencies) -> None: + doc = Document('Rakefile', '') + assert restore_ruby.is_project(doc) is False + + +class TestCleanup: + def test_generated_lockfile_is_deleted_after_restore( + self, restore_ruby: RestoreRubyDependencies, tmp_path: Path + ) -> None: + (tmp_path / 'Gemfile').write_text("source 'https://rubygems.org'\n") + doc = Document( + str(tmp_path / 'Gemfile'), + "source 'https://rubygems.org'\n", + absolute_path=str(tmp_path / 'Gemfile'), + ) + lock_path = tmp_path / RUBY_LOCK_FILE_NAME + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + lock_path.write_text('GEM\n remote: https://rubygems.org/\n specs:\n') + return 'output' + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect): + result = restore_ruby.try_restore_dependencies(doc) + + assert result is not None + assert not lock_path.exists(), f'{RUBY_LOCK_FILE_NAME} must be deleted after restore' + + def test_preexisting_lockfile_is_not_deleted(self, restore_ruby: RestoreRubyDependencies, tmp_path: Path) -> None: + lock_content = 'GEM\n remote: https://rubygems.org/\n specs:\n rake (13.0.6)\n' + (tmp_path / 'Gemfile').write_text("source 'https://rubygems.org'\ngem 'rake'\n") + lock_path = tmp_path / RUBY_LOCK_FILE_NAME + lock_path.write_text(lock_content) + doc = Document( + str(tmp_path / 'Gemfile'), + "source 'https://rubygems.org'\ngem 'rake'\n", + absolute_path=str(tmp_path / 'Gemfile'), + ) + + result = restore_ruby.try_restore_dependencies(doc) + + assert result is not None + assert lock_path.exists(), f'Pre-existing {RUBY_LOCK_FILE_NAME} must not be deleted' diff --git a/tests/cli/files_collector/sca/sbt/__init__.py b/tests/cli/files_collector/sca/sbt/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/files_collector/sca/sbt/test_restore_sbt_dependencies.py b/tests/cli/files_collector/sca/sbt/test_restore_sbt_dependencies.py new file mode 100644 index 00000000..415e5f94 --- /dev/null +++ b/tests/cli/files_collector/sca/sbt/test_restore_sbt_dependencies.py @@ -0,0 +1,89 @@ +from pathlib import Path +from typing import Optional +from unittest.mock import MagicMock, patch + +import pytest +import typer + +from cycode.cli.files_collector.sca.sbt.restore_sbt_dependencies import ( + SBT_LOCK_FILE_NAME, + RestoreSbtDependencies, +) +from cycode.cli.models import Document + +_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies' + + +@pytest.fixture +def mock_ctx(tmp_path: Path) -> typer.Context: + ctx = MagicMock(spec=typer.Context) + ctx.obj = {'monitor': False} + ctx.params = {'path': str(tmp_path)} + return ctx + + +@pytest.fixture +def restore_sbt(mock_ctx: typer.Context) -> RestoreSbtDependencies: + return RestoreSbtDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + + +class TestIsProject: + def test_sbt_file_matches(self, restore_sbt: RestoreSbtDependencies) -> None: + doc = Document('build.sbt', 'name := "my-project"\n') + assert restore_sbt.is_project(doc) is True + + def test_sbt_in_subdir_matches(self, restore_sbt: RestoreSbtDependencies) -> None: + doc = Document('myapp/build.sbt', 'name := "my-project"\n') + assert restore_sbt.is_project(doc) is True + + def test_build_gradle_does_not_match(self, restore_sbt: RestoreSbtDependencies) -> None: + doc = Document('build.gradle', '') + assert restore_sbt.is_project(doc) is False + + def test_pom_xml_does_not_match(self, restore_sbt: RestoreSbtDependencies) -> None: + doc = Document('pom.xml', '') + assert restore_sbt.is_project(doc) is False + + +class TestCleanup: + def test_generated_lockfile_is_deleted_after_restore( + self, restore_sbt: RestoreSbtDependencies, tmp_path: Path + ) -> None: + (tmp_path / 'build.sbt').write_text('name := "test"\n') + doc = Document( + str(tmp_path / 'build.sbt'), + 'name := "test"\n', + absolute_path=str(tmp_path / 'build.sbt'), + ) + lock_path = tmp_path / SBT_LOCK_FILE_NAME + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + lock_path.write_text('[{"org": "org.typelevel", "name": "cats-core", "version": "2.10.0"}]') + return 'output' + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect): + result = restore_sbt.try_restore_dependencies(doc) + + assert result is not None + assert not lock_path.exists(), f'{SBT_LOCK_FILE_NAME} must be deleted after restore' + + def test_preexisting_lockfile_is_not_deleted(self, restore_sbt: RestoreSbtDependencies, tmp_path: Path) -> None: + lock_content = '[{"org": "org.typelevel", "name": "cats-core", "version": "2.10.0"}]' + (tmp_path / 'build.sbt').write_text('name := "test"\n') + lock_path = tmp_path / SBT_LOCK_FILE_NAME + lock_path.write_text(lock_content) + doc = Document( + str(tmp_path / 'build.sbt'), + 'name := "test"\n', + absolute_path=str(tmp_path / 'build.sbt'), + ) + + result = restore_sbt.try_restore_dependencies(doc) + + assert result is not None + assert lock_path.exists(), f'Pre-existing {SBT_LOCK_FILE_NAME} must not be deleted' diff --git a/tests/cli/files_collector/sca/test_base_restore_dependencies.py b/tests/cli/files_collector/sca/test_base_restore_dependencies.py new file mode 100644 index 00000000..b291a95f --- /dev/null +++ b/tests/cli/files_collector/sca/test_base_restore_dependencies.py @@ -0,0 +1,148 @@ +"""Tests for BaseRestoreDependencies cleanup behavior. + +Verifies that lock files generated by restore commands are deleted after +scanning, while pre-existing lock files are left untouched. +""" + +from pathlib import Path +from typing import Optional +from unittest.mock import MagicMock, patch + +import pytest +import typer + +from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies +from cycode.cli.models import Document + +_LOCK_FILE_NAME = 'generated.lock' +_MANIFEST_FILE_NAME = 'manifest.txt' +_LOCK_CONTENT = 'generated lock content' + +_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies' + + +class _MinimalRestoreHandler(BaseRestoreDependencies): + """Minimal concrete subclass for directly testing BaseRestoreDependencies.""" + + def is_project(self, document: Document) -> bool: + return document.path.endswith(_MANIFEST_FILE_NAME) + + def get_commands(self, manifest_file_path: str) -> list[list[str]]: + return [['echo', 'fake']] + + def get_lock_file_name(self) -> str: + return _LOCK_FILE_NAME + + def get_lock_file_names(self) -> list[str]: + return [_LOCK_FILE_NAME] + + +@pytest.fixture +def mock_ctx(tmp_path: Path) -> typer.Context: + ctx = MagicMock(spec=typer.Context) + ctx.obj = {'monitor': False} + ctx.params = {'path': str(tmp_path)} + return ctx + + +@pytest.fixture +def handler(mock_ctx: typer.Context) -> _MinimalRestoreHandler: + return _MinimalRestoreHandler(mock_ctx, is_git_diff=False, command_timeout=30) + + +def _make_doc(tmp_path: Path) -> Document: + manifest = tmp_path / _MANIFEST_FILE_NAME + manifest.write_text('content') + return Document(str(manifest), 'content', absolute_path=str(manifest)) + + +def _make_execute_side_effect(lock_path: Path, content: str = _LOCK_CONTENT) -> object: + """Returns an execute_commands side_effect that writes the lock file.""" + + def side_effect( + commands: list, timeout: int, output_file_path: Optional[str] = None, working_directory: Optional[str] = None + ) -> str: + lock_path.write_text(content) + return 'output' + + return side_effect + + +class TestCleanupGeneratedFile: + def test_generated_lockfile_is_deleted_after_restore(self, handler: _MinimalRestoreHandler, tmp_path: Path) -> None: + doc = _make_doc(tmp_path) + lock_path = tmp_path / _LOCK_FILE_NAME + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=_make_execute_side_effect(lock_path)): + result = handler.try_restore_dependencies(doc) + + assert result is not None + assert result.content == _LOCK_CONTENT + assert not lock_path.exists(), 'Generated lock file must be deleted after restore' + + def test_preexisting_lockfile_is_not_deleted(self, handler: _MinimalRestoreHandler, tmp_path: Path) -> None: + doc = _make_doc(tmp_path) + lock_path = tmp_path / _LOCK_FILE_NAME + lock_path.write_text('pre-existing content') + + result = handler.try_restore_dependencies(doc) + + assert result is not None + assert result.content == 'pre-existing content' + assert lock_path.exists(), 'Pre-existing lock file must not be deleted' + + def test_returned_document_content_matches_generated_file( + self, handler: _MinimalRestoreHandler, tmp_path: Path + ) -> None: + doc = _make_doc(tmp_path) + expected = '{"dependencies": {"requests": "^2.31"}}' + lock_path = tmp_path / _LOCK_FILE_NAME + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=_make_execute_side_effect(lock_path, expected)): + result = handler.try_restore_dependencies(doc) + + assert result is not None + assert result.content == expected + + def test_cleanup_does_not_raise_when_generated_file_missing( + self, handler: _MinimalRestoreHandler, tmp_path: Path + ) -> None: + """unlink(missing_ok=True) must not raise even if the command didn't create the file.""" + doc = _make_doc(tmp_path) + + def side_effect(**_kwargs: object) -> str: + return 'output' # returned non-None but didn't create the file + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect): + result = handler.try_restore_dependencies(doc) + + # File was never created; content is None but no exception raised + assert result is not None + assert result.content is None + + def test_failed_command_returns_none_and_no_file_created( + self, handler: _MinimalRestoreHandler, tmp_path: Path + ) -> None: + doc = _make_doc(tmp_path) + lock_path = tmp_path / _LOCK_FILE_NAME + + with patch(f'{_BASE_MODULE}.execute_commands', return_value=None): + result = handler.try_restore_dependencies(doc) + + assert result is None + assert not lock_path.exists() + + def test_generated_file_content_available_in_document_after_deletion( + self, handler: _MinimalRestoreHandler, tmp_path: Path + ) -> None: + """The Document must carry the file content even after the file is removed.""" + doc = _make_doc(tmp_path) + lock_path = tmp_path / _LOCK_FILE_NAME + expected = 'important scan data' + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=_make_execute_side_effect(lock_path, expected)): + result = handler.try_restore_dependencies(doc) + + assert not lock_path.exists() + assert result is not None + assert result.content == expected