From e4d642dc7252c47e72d65a7e1715dd14be03b172 Mon Sep 17 00:00:00 2001 From: Martin Torp Date: Wed, 3 Jun 2026 15:03:06 +0200 Subject: [PATCH] feat(reach): align reachability flags and coana env with Node CLI Bring the Python CLI's reachability surface to parity with the Node CLI: - --reach-disable-external-tool-checks -> coana --disable-external-tool-checks - forward SOCKET_CLI_VERSION + SOCKET_CALLER_USER_AGENT to coana (proxy is left to coana, which reads/inherits HTTPS_PROXY/HTTP_PROXY itself) - omit SOCKET_REPO_NAME/SOCKET_BRANCH_NAME for the default repo/branch sentinels - Node-style --reach-analysis-timeout/--reach-analysis-memory-limit as primary names, --reach-timeout/--reach-memory-limit kept as hidden aliases - --reach-debug -> coana --debug (global --enable-debug -> -d unchanged) - retry tier1 finalize with exponential backoff (3 attempts), never raising Memory-limit and concurrency are intentionally NOT hardcoded: coana already defaults to 8192 MB and concurrency 1, so the CLI omits the flags and lets coana apply them (and still forwards an explicit value when the user sets one). Splitting stays explicitly disabled (--disable-analysis-splitting) because coana defaults it ON. Removes stray always-on WARNING logging in the reachability runner. Adds a CHANGELOG 2.4.2 entry and tests for the flags/aliases, the coana command/env builder, and finalize retry. --- CHANGELOG.md | 20 ++++ pyproject.toml | 2 +- socketsecurity/__init__.py | 2 +- socketsecurity/config.py | 40 +++++++- socketsecurity/core/__init__.py | 52 ++++++++--- socketsecurity/core/tools/reachability.py | 46 ++++++++-- socketsecurity/socketcli.py | 23 ++++- tests/unit/test_config.py | 49 ++++++++++ tests/unit/test_reachability.py | 106 ++++++++++++++++++++++ tests/unit/test_tier1_finalize.py | 70 ++++++++++++++ uv.lock | 2 +- 11 files changed, 382 insertions(+), 30 deletions(-) create mode 100644 tests/unit/test_reachability.py create mode 100644 tests/unit/test_tier1_finalize.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f8636da..f47b76d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## 2.4.2 + +### Added: reachability flag and Coana environment alignment with the Node CLI + +- New `--reach-disable-external-tool-checks` flag (passes `--disable-external-tool-checks` + to the Coana CLI). +- New `--reach-debug` flag to enable Coana debug output (`--debug`) independently of the + global `--enable-debug`. +- Node-style `--reach-analysis-timeout` and `--reach-analysis-memory-limit` are now the + primary flag names; the previous `--reach-timeout` / `--reach-memory-limit` continue to + work as hidden aliases. +- The Coana subprocess now receives `SOCKET_CLI_VERSION` and `SOCKET_CALLER_USER_AGENT` so + calls are attributed to the Python CLI. Proxies continue to work via the inherited + `HTTPS_PROXY` / `HTTP_PROXY` environment variables, which Coana reads itself. +- `SOCKET_REPO_NAME` / `SOCKET_BRANCH_NAME` are no longer forwarded to Coana when the repo + and branch are the default sentinels, avoiding cross-run reachability cache-bucket + collisions. +- Tier 1 reachability finalize now retries with exponential backoff instead of giving up on + the first transient error. + ## 2.4.1 ### Added: pyenv in the Docker image diff --git a/pyproject.toml b/pyproject.toml index 4d5f098..d4af492 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.4.1" +version = "2.4.2" requires-python = ">= 3.11" license = {"file" = "LICENSE"} dependencies = [ diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index 4c6871c..e98e031 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,3 +1,3 @@ __author__ = 'socket.dev' -__version__ = '2.4.1' +__version__ = '2.4.2' USER_AGENT = f'SocketPythonCLI/{__version__}' diff --git a/socketsecurity/config.py b/socketsecurity/config.py index 5a3cce7..58af7e5 100644 --- a/socketsecurity/config.py +++ b/socketsecurity/config.py @@ -139,6 +139,8 @@ class CliConfig: reach_continue_on_install_errors: bool = False reach_continue_on_missing_lock_files: bool = False reach_continue_on_no_source_files: bool = False + reach_debug: bool = False + reach_disable_external_tool_checks: bool = False max_purl_batch_size: int = 5000 enable_commit_status: bool = False legal: bool = False @@ -267,6 +269,8 @@ def from_args(cls, args_list: Optional[List[str]] = None) -> 'CliConfig': 'reach_continue_on_install_errors': args.reach_continue_on_install_errors, 'reach_continue_on_missing_lock_files': args.reach_continue_on_missing_lock_files, 'reach_continue_on_no_source_files': args.reach_continue_on_no_source_files, + 'reach_debug': args.reach_debug, + 'reach_disable_external_tool_checks': args.reach_disable_external_tool_checks, 'max_purl_batch_size': args.max_purl_batch_size, 'enable_commit_status': args.enable_commit_status, 'legal': args.legal or args.legal_format == "fossa", @@ -878,18 +882,32 @@ def create_argument_parser() -> argparse.ArgumentParser: help="Specific version of @coana-tech/cli to use (e.g., '1.2.3')" ) reachability_group.add_argument( - "--reach-timeout", + "--reach-analysis-timeout", dest="reach_analysis_timeout", type=int, metavar="", help="Timeout for reachability analysis in seconds" ) + # Backwards-compatible alias for the pre-alignment name. Kept working, hidden from help. reachability_group.add_argument( - "--reach-memory-limit", + "--reach-timeout", + dest="reach_analysis_timeout", + type=int, + help=argparse.SUPPRESS + ) + reachability_group.add_argument( + "--reach-analysis-memory-limit", dest="reach_analysis_memory_limit", type=int, metavar="", - help="Memory limit for reachability analysis in MB" + help="Memory limit for reachability analysis in MB (defaults to the coana CLI's own default, currently 8192)" + ) + # Backwards-compatible alias for the pre-alignment name. Kept working, hidden from help. + reachability_group.add_argument( + "--reach-memory-limit", + dest="reach_analysis_memory_limit", + type=int, + help=argparse.SUPPRESS ) reachability_group.add_argument( "--reach-ecosystems", @@ -957,7 +975,7 @@ def create_argument_parser() -> argparse.ArgumentParser: dest="reach_concurrency", type=int, metavar="", - help="Concurrency level for reachability analysis (must be >= 1)" + help="Concurrency level for reachability analysis (must be >= 1; defaults to the coana CLI's own default, currently 1)" ) reachability_group.add_argument( "--reach-additional-params", @@ -1002,6 +1020,20 @@ def create_argument_parser() -> argparse.ArgumentParser: action="store_true", help=argparse.SUPPRESS ) + reachability_group.add_argument( + "--reach-debug", + dest="reach_debug", + action="store_true", + help="Enable debug output for the reachability analysis (passes --debug to the coana CLI). " + "Independent of the global --enable-debug flag." + ) + reachability_group.add_argument( + "--reach-disable-external-tool-checks", + dest="reach_disable_external_tool_checks", + action="store_true", + help="Disable coana's external tool availability checks during reachability analysis " + "(passes --disable-external-tool-checks to the coana CLI)." + ) parser.add_argument( '--version', diff --git a/socketsecurity/core/__init__.py b/socketsecurity/core/__init__.py index 0e98323..005dbae 100644 --- a/socketsecurity/core/__init__.py +++ b/socketsecurity/core/__init__.py @@ -71,6 +71,11 @@ # Stream the facts file in 1 MiB chunks so large files aren't held fully in memory. SOCKET_FACTS_BROTLI_CHUNK_SIZE = 1024 * 1024 +# Tier 1 reachability finalize retry policy. The finalize call links the tier1 scan to the +# full scan and can fail transiently (network/API blips); a few backoff retries make it robust. +TIER1_FINALIZE_MAX_ATTEMPTS = 3 +TIER1_FINALIZE_BACKOFF_SECONDS = 1.0 + def _humanize_alert_type(alert_type: str) -> str: """Convert a camelCase/PascalCase alert type into a Title-Cased label. @@ -549,20 +554,43 @@ def finalize_tier1_scan(self, full_scan_id: str, facts_file_path: str) -> bool: log.debug(f"Failed to read tier1ReachabilityScanId from {facts_file_path}: {e}") return False - # Call the SDK to finalize the tier 1 scan - try: - success = self.sdk.fullscans.finalize_tier1( - full_scan_id=full_scan_id, - tier1_reachability_scan_id=tier1_scan_id, - ) + # Call the SDK to finalize the tier 1 scan, retrying transient failures with backoff. + last_error: Optional[Exception] = None + for attempt in range(1, TIER1_FINALIZE_MAX_ATTEMPTS + 1): + try: + success = self.sdk.fullscans.finalize_tier1( + full_scan_id=full_scan_id, + tier1_reachability_scan_id=tier1_scan_id, + ) - if success: - log.debug(f"Successfully finalized tier 1 scan {tier1_scan_id} for full scan {full_scan_id}") - return success + if success: + log.debug(f"Successfully finalized tier 1 scan {tier1_scan_id} for full scan {full_scan_id}") + return True - except Exception as e: - log.debug(f"Unable to finalize tier 1 scan: {e}") - return False + log.debug( + f"finalize_tier1 returned a falsy result for scan {tier1_scan_id} " + f"(attempt {attempt}/{TIER1_FINALIZE_MAX_ATTEMPTS})" + ) + except Exception as e: + last_error = e + log.debug( + f"Unable to finalize tier 1 scan (attempt {attempt}/{TIER1_FINALIZE_MAX_ATTEMPTS}): {e}" + ) + + if attempt < TIER1_FINALIZE_MAX_ATTEMPTS: + time.sleep(TIER1_FINALIZE_BACKOFF_SECONDS * (2 ** (attempt - 1))) + + if last_error is not None: + log.debug( + f"Giving up finalizing tier 1 scan {tier1_scan_id} after " + f"{TIER1_FINALIZE_MAX_ATTEMPTS} attempts: {last_error}" + ) + else: + log.debug( + f"Giving up finalizing tier 1 scan {tier1_scan_id} after " + f"{TIER1_FINALIZE_MAX_ATTEMPTS} attempts" + ) + return False @staticmethod def _compress_facts_file(source_path: str) -> str: diff --git a/socketsecurity/core/tools/reachability.py b/socketsecurity/core/tools/reachability.py index 27593c8..008bd65 100644 --- a/socketsecurity/core/tools/reachability.py +++ b/socketsecurity/core/tools/reachability.py @@ -1,15 +1,31 @@ from socketdev import socketdev from typing import List, Optional, Dict, Any import os +import platform import subprocess import json import pathlib import logging import sys +from socketsecurity import __version__ + log = logging.getLogger(__name__) +def _build_caller_user_agent() -> str: + """Build the SOCKET_CALLER_USER_AGENT string forwarded to the coana CLI. + + Mirrors the Node CLI's ``/ / /`` + shape so the backend can attribute reachability calls to the Python CLI. + """ + return ( + f"socket/{__version__} " + f"python/{platform.python_version()} " + f"{platform.system().lower()}/{platform.machine().lower()}" + ) + + class ReachabilityAnalyzer: def __init__(self, sdk: socketdev, api_token: str): self.sdk = sdk @@ -108,6 +124,8 @@ def run_reachability_analysis( continue_on_install_errors: bool = False, continue_on_missing_lock_files: bool = False, continue_on_no_source_files: bool = False, + reach_debug: bool = False, + disable_external_tool_checks: bool = False, ) -> Dict[str, Any]: """ Run reachability analysis. @@ -147,8 +165,7 @@ def run_reachability_analysis( # Add required arguments output_dir = str(pathlib.Path(output_path).parent) - log.warning(f"output_dir: {output_dir}") - log.warning(f"output_path: {output_path}") + log.debug(f"output_dir: {output_dir}, output_path: {output_path}") cmd.extend([ "--output-dir", output_dir, "--socket-mode", output_path, @@ -197,6 +214,12 @@ def run_reachability_analysis( if enable_debug: cmd.append("-d") + if reach_debug: + cmd.append("--debug") + + if disable_external_tool_checks: + cmd.append("--disable-external-tool-checks") + if use_only_pregenerated_sboms: cmd.append("--use-only-pregenerated-sboms") @@ -222,14 +245,25 @@ def run_reachability_analysis( # Required environment variables for Coana CLI env["SOCKET_ORG_SLUG"] = org_slug env["SOCKET_CLI_API_TOKEN"] = self.api_token - - # Optional environment variables + + # Identify the calling CLI to the coana tool / backend (parity with the Node CLI). + env["SOCKET_CLI_VERSION"] = __version__ + env["SOCKET_CALLER_USER_AGENT"] = _build_caller_user_agent() + + # NOTE: no proxy env is set here. coana already reads HTTPS_PROXY/HTTP_PROXY itself, and + # we pass the full parent env above, so it inherits them. A SOCKET_CLI_API_PROXY override + # should only be set from an explicit --proxy flag (not yet implemented), since seeding it + # from HTTPS_PROXY would be a no-op (it's the same value coana already resolves). + + # Optional environment variables. + # NOTE: repo/branch are intentionally omitted by the caller (passed as None) when they + # are the default sentinels, to avoid polluting coana's per-repo/branch cache buckets. if repo_name: env["SOCKET_REPO_NAME"] = repo_name - + if branch_name: env["SOCKET_BRANCH_NAME"] = branch_name - + # Set NODE_TLS_REJECT_UNAUTHORIZED=0 if allow_unverified is True if allow_unverified: env["NODE_TLS_REJECT_UNAUTHORIZED"] = "0" diff --git a/socketsecurity/socketcli.py b/socketsecurity/socketcli.py index 1849239..95a6284 100644 --- a/socketsecurity/socketcli.py +++ b/socketsecurity/socketcli.py @@ -95,6 +95,12 @@ def _write_attribution_file(config, payload: dict) -> None: DEFAULT_API_TIMEOUT = 1200 +# Sentinel repo/branch names used when none can be detected from git or supplied via flags. +# When the repo/branch are these defaults we skip forwarding SOCKET_REPO_NAME/SOCKET_BRANCH_NAME +# to the coana CLI so unrelated default-named runs don't share reachability cache buckets. +DEFAULT_REPO_NAME = "socket-default-repo" +DEFAULT_BRANCH_NAME = "socket-default-branch" + def get_api_request_timeout(config: CliConfig) -> int: return config.timeout if config.timeout is not None else DEFAULT_API_TIMEOUT @@ -288,16 +294,21 @@ def main_code(): except NoSuchPathError: raise Exception(f"Unable to find path {config.target_path}") + # Track whether repo/branch fell back to the default sentinels so reachability can skip + # forwarding them as coana cache-bucket keys (computed before any workspace suffixing). + repo_defaulted = not config.repo + branch_defaulted = not config.branch + if not config.repo: - base_repo_name = "socket-default-repo" + base_repo_name = DEFAULT_REPO_NAME if config.workspace_name: config.repo = f"{base_repo_name}-{config.workspace_name}" else: config.repo = base_repo_name log.debug(f"Using default repository name: {config.repo}") - + if not config.branch: - config.branch = "socket-default-branch" + config.branch = DEFAULT_BRANCH_NAME log.debug(f"Using default branch name: {config.branch}") # Calculate the scan paths - combine target_path with sub_paths if provided @@ -384,8 +395,8 @@ def main_code(): enable_analysis_splitting=config.reach_enable_analysis_splitting or False, detailed_analysis_log_file=config.reach_detailed_analysis_log_file or False, lazy_mode=config.reach_lazy_mode or False, - repo_name=config.repo, - branch_name=config.branch, + repo_name=None if repo_defaulted else config.repo, + branch_name=None if branch_defaulted else config.branch, version=config.reach_version, concurrency=config.reach_concurrency, additional_params=config.reach_additional_params, @@ -396,6 +407,8 @@ def main_code(): continue_on_install_errors=config.reach_continue_on_install_errors, continue_on_missing_lock_files=config.reach_continue_on_missing_lock_files, continue_on_no_source_files=config.reach_continue_on_no_source_files, + reach_debug=config.reach_debug, + disable_external_tool_checks=config.reach_disable_external_tool_checks, ) log.info("Reachability analysis completed successfully") diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 4666b71..7403005 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -166,6 +166,55 @@ def test_config_file_json_sets_defaults(self, tmp_path): assert config.sarif_reachability == "reachable" +class TestReachAlignmentFlags: + """Tests for the reachability flag/default alignment with the Node CLI.""" + + BASE_ARGS = ["--api-token", "test-token", "--repo", "test-repo"] + + def test_reach_defaults_are_unset_and_delegated_to_coana(self): + """memory-limit/concurrency/timeout are not hardcoded; omitted so coana applies its + own defaults (8192 MB / concurrency 1 / 600s), which already match what we'd set.""" + config = CliConfig.from_args(self.BASE_ARGS + ["--reach"]) + assert config.reach_analysis_memory_limit is None + assert config.reach_concurrency is None + assert config.reach_analysis_timeout is None + + def test_reach_node_style_name_aliases(self): + """G8: Node-style primary names map to the same dests.""" + config = CliConfig.from_args( + self.BASE_ARGS + + ["--reach", "--reach-analysis-timeout", "300", "--reach-analysis-memory-limit", "2048"] + ) + assert config.reach_analysis_timeout == 300 + assert config.reach_analysis_memory_limit == 2048 + + def test_reach_legacy_name_aliases_still_work(self): + """G8: pre-alignment names keep working (hidden aliases).""" + config = CliConfig.from_args( + self.BASE_ARGS + ["--reach", "--reach-timeout", "111", "--reach-memory-limit", "512"] + ) + assert config.reach_analysis_timeout == 111 + assert config.reach_analysis_memory_limit == 512 + + def test_reach_debug_flag(self): + """G9: dedicated --reach-debug flag, independent of --enable-debug.""" + config = CliConfig.from_args(self.BASE_ARGS + ["--reach", "--reach-debug"]) + assert config.reach_debug is True + assert config.enable_debug is False + + def test_reach_disable_external_tool_checks_flag(self): + """G1: --reach-disable-external-tool-checks parses to its dest.""" + config = CliConfig.from_args( + self.BASE_ARGS + ["--reach", "--reach-disable-external-tool-checks"] + ) + assert config.reach_disable_external_tool_checks is True + + def test_reach_new_flags_default_false(self): + config = CliConfig.from_args(self.BASE_ARGS + ["--reach"]) + assert config.reach_debug is False + assert config.reach_disable_external_tool_checks is False + + def test_pyproject_requires_python_matches_tomllib_usage(): pyproject = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8")) requires_python = pyproject["project"]["requires-python"] diff --git a/tests/unit/test_reachability.py b/tests/unit/test_reachability.py new file mode 100644 index 0000000..10b6d54 --- /dev/null +++ b/tests/unit/test_reachability.py @@ -0,0 +1,106 @@ +"""Tests for the reachability coana-CLI command/env construction (Node alignment). + +These cover the arg-builder and environment wiring in +``socketsecurity.core.tools.reachability.ReachabilityAnalyzer`` without actually +invoking npm/npx/coana: ``_ensure_coana_cli_installed`` and ``subprocess.run`` are mocked. +""" +from unittest.mock import MagicMock + +import pytest + +from socketsecurity import __version__ +from socketsecurity.core.tools import reachability +from socketsecurity.core.tools.reachability import ( + ReachabilityAnalyzer, + _build_caller_user_agent, +) + + +@pytest.fixture +def analyzer(): + return ReachabilityAnalyzer(MagicMock(), "test-api-token") + + +def _run(analyzer, mocker, **kwargs): + """Invoke run_reachability_analysis with npm/npx/coana mocked; return (cmd, env).""" + mocker.patch.object(analyzer, "_ensure_coana_cli_installed", return_value="@coana-tech/cli") + mocker.patch.object(analyzer, "_extract_scan_id", return_value="scan-123") + completed = MagicMock() + completed.returncode = 0 + run_mock = mocker.patch.object(reachability.subprocess, "run", return_value=completed) + + analyzer.run_reachability_analysis(org_slug="my-org", target_directory=".", **kwargs) + + cmd = run_mock.call_args.args[0] + env = run_mock.call_args.kwargs["env"] + return cmd, env + + +def test_build_caller_user_agent_shape(): + ua = _build_caller_user_agent() + parts = ua.split(" ") + assert parts[0] == f"socket/{__version__}" + assert parts[1].startswith("python/") + assert "/" in parts[2] # platform/arch + + +def test_reach_debug_appends_debug_long_flag(analyzer, mocker): + """G9: --reach-debug -> coana --debug; does not emit the global -d.""" + cmd, _ = _run(analyzer, mocker, reach_debug=True) + assert "--debug" in cmd + assert "-d" not in cmd + + +def test_enable_debug_still_emits_short_d(analyzer, mocker): + """G9: existing global --enable-debug -> -d behavior is unchanged.""" + cmd, _ = _run(analyzer, mocker, enable_debug=True) + assert "-d" in cmd + assert "--debug" not in cmd + + +def test_disable_external_tool_checks(analyzer, mocker): + """G1: --reach-disable-external-tool-checks -> coana --disable-external-tool-checks.""" + cmd, _ = _run(analyzer, mocker, disable_external_tool_checks=True) + assert "--disable-external-tool-checks" in cmd + + cmd2, _ = _run(analyzer, mocker) + assert "--disable-external-tool-checks" not in cmd2 + + +def test_concurrency_and_memory_args(analyzer, mocker): + """G7: explicit concurrency/memory propagate as coana args.""" + cmd, _ = _run(analyzer, mocker, concurrency=1, memory_limit=8192) + assert "--concurrency" in cmd and cmd[cmd.index("--concurrency") + 1] == "1" + assert "--memory-limit" in cmd and cmd[cmd.index("--memory-limit") + 1] == "8192" + + +def test_env_identifies_python_cli(analyzer, mocker): + """G5: SOCKET_CLI_VERSION + SOCKET_CALLER_USER_AGENT forwarded to coana.""" + _, env = _run(analyzer, mocker) + assert env["SOCKET_CLI_VERSION"] == __version__ + assert env["SOCKET_CALLER_USER_AGENT"].startswith("socket/") + assert env["SOCKET_ORG_SLUG"] == "my-org" + assert env["SOCKET_CLI_API_TOKEN"] == "test-api-token" + + +def test_no_proxy_env_set_by_default(analyzer, mocker, monkeypatch): + """coana inherits HTTPS_PROXY/HTTP_PROXY from the passed env; we don't set + SOCKET_CLI_API_PROXY ourselves (that's reserved for a future explicit --proxy flag).""" + monkeypatch.delenv("SOCKET_CLI_API_PROXY", raising=False) + monkeypatch.setenv("HTTPS_PROXY", "http://envproxy:3128") + _, env = _run(analyzer, mocker) + # Even with HTTPS_PROXY set, we don't copy it into SOCKET_CLI_API_PROXY (coana reads it itself). + assert "SOCKET_CLI_API_PROXY" not in env + + +def test_repo_branch_env_present_when_supplied(analyzer, mocker): + _, env = _run(analyzer, mocker, repo_name="acme/widget", branch_name="main") + assert env["SOCKET_REPO_NAME"] == "acme/widget" + assert env["SOCKET_BRANCH_NAME"] == "main" + + +def test_repo_branch_env_absent_when_none(analyzer, mocker): + """G6: caller passes None for default sentinels -> env keys omitted (cache hygiene).""" + _, env = _run(analyzer, mocker, repo_name=None, branch_name=None) + assert "SOCKET_REPO_NAME" not in env + assert "SOCKET_BRANCH_NAME" not in env diff --git a/tests/unit/test_tier1_finalize.py b/tests/unit/test_tier1_finalize.py new file mode 100644 index 0000000..2577643 --- /dev/null +++ b/tests/unit/test_tier1_finalize.py @@ -0,0 +1,70 @@ +"""Tests for tier1 reachability finalize retry/backoff (G11, Node parity).""" +import json +from unittest.mock import MagicMock + +import pytest + +from socketsecurity.core import TIER1_FINALIZE_MAX_ATTEMPTS, Core + + +@pytest.fixture +def core_with_mock_sdk(): + # Build a Core without running org setup; we only exercise finalize_tier1_scan. + core = Core.__new__(Core) + core.sdk = MagicMock() + return core + + +@pytest.fixture +def facts_file(tmp_path): + path = tmp_path / ".socket.facts.json" + path.write_text(json.dumps({"tier1ReachabilityScanId": "tier1-abc"}), encoding="utf-8") + return str(path) + + +@pytest.fixture(autouse=True) +def no_sleep(mocker): + return mocker.patch("socketsecurity.core.time.sleep") + + +def test_finalize_succeeds_first_try(core_with_mock_sdk, facts_file, no_sleep): + core_with_mock_sdk.sdk.fullscans.finalize_tier1.return_value = True + + assert core_with_mock_sdk.finalize_tier1_scan("full-1", facts_file) is True + assert core_with_mock_sdk.sdk.fullscans.finalize_tier1.call_count == 1 + no_sleep.assert_not_called() + + +def test_finalize_retries_then_succeeds(core_with_mock_sdk, facts_file, no_sleep): + core_with_mock_sdk.sdk.fullscans.finalize_tier1.side_effect = [ + Exception("transient"), + Exception("transient"), + True, + ] + + assert core_with_mock_sdk.finalize_tier1_scan("full-1", facts_file) is True + assert core_with_mock_sdk.sdk.fullscans.finalize_tier1.call_count == 3 + assert no_sleep.call_count == 2 # backoff between the 3 attempts + + +def test_finalize_exhausts_on_persistent_exception(core_with_mock_sdk, facts_file, no_sleep): + core_with_mock_sdk.sdk.fullscans.finalize_tier1.side_effect = Exception("down") + + # Never raises; returns False after exhausting attempts. + assert core_with_mock_sdk.finalize_tier1_scan("full-1", facts_file) is False + assert core_with_mock_sdk.sdk.fullscans.finalize_tier1.call_count == TIER1_FINALIZE_MAX_ATTEMPTS + + +def test_finalize_exhausts_on_persistent_falsy(core_with_mock_sdk, facts_file, no_sleep): + core_with_mock_sdk.sdk.fullscans.finalize_tier1.return_value = False + + assert core_with_mock_sdk.finalize_tier1_scan("full-1", facts_file) is False + assert core_with_mock_sdk.sdk.fullscans.finalize_tier1.call_count == TIER1_FINALIZE_MAX_ATTEMPTS + + +def test_finalize_returns_false_when_no_scan_id(core_with_mock_sdk, tmp_path): + path = tmp_path / ".socket.facts.json" + path.write_text(json.dumps({"components": []}), encoding="utf-8") + + assert core_with_mock_sdk.finalize_tier1_scan("full-1", str(path)) is False + core_with_mock_sdk.sdk.fullscans.finalize_tier1.assert_not_called() diff --git a/uv.lock b/uv.lock index 06d1578..599ee37 100644 --- a/uv.lock +++ b/uv.lock @@ -1270,7 +1270,7 @@ wheels = [ [[package]] name = "socketsecurity" -version = "2.4.1" +version = "2.4.2" source = { editable = "." } dependencies = [ { name = "brotli", marker = "platform_python_implementation == 'CPython'" },