diff --git a/socketsecurity/core/__init__.py b/socketsecurity/core/__init__.py index 06c037e..d8a11ee 100644 --- a/socketsecurity/core/__init__.py +++ b/socketsecurity/core/__init__.py @@ -27,6 +27,11 @@ from socketsecurity import USER_AGENT, __version__ from socketsecurity.core.classes import Alert, Diff, FullScan, Issue, Package, Purl +from socketsecurity.core.diff_result import ( + DiffScanResult, + PackageComparisonResult, + create_diff_scan_result, +) from socketsecurity.core.exceptions import APIResourceNotFound from .resource_utils import check_file_count_against_ulimit @@ -1313,7 +1318,7 @@ def create_full_scan_with_report_url( base_socket = "https://socket.dev/dashboard/org" diff.report_url = f"{base_socket}/{self.config.org_slug}/sbom/{new_full_scan.id}" diff.diff_url = diff.report_url - diff.id = new_full_scan.id + diff.set_scan_ids(new_full_scan.id) diff.is_full_scan = True needs_alerts = ( @@ -1957,12 +1962,12 @@ def get_license_text_via_purl(self, packages: dict[str, Package], batch_size: in return packages - def get_diff_scan_artifacts( + def _get_diff_scan_result( self, head_full_scan_id: str, new_full_scan_id: str, external_href: Optional[str] = None - ) -> DiffArtifacts: + ) -> DiffScanResult: """Compare two full scans via the diff-scans endpoints, polling for the result. Creates a diff-scan resource from the two full scan IDs, then polls @@ -2110,10 +2115,19 @@ def get_diff_scan_artifacts( last_interval = interval interval = min(interval * DIFF_SCAN_POLL_BACKOFF_MULTIPLIER, DIFF_SCAN_POLL_MAX_INTERVAL_SECONDS) - return DiffArtifacts.from_dict({ - key: artifacts_dict.get(key) or [] - for key in ("added", "removed", "unchanged", "replaced", "updated") - }) + return create_diff_scan_result(artifacts_dict, diff_scan_id) + + def get_diff_scan_artifacts( + self, + head_full_scan_id: str, + new_full_scan_id: str, + external_href: Optional[str] = None + ) -> DiffArtifacts: + return self._get_diff_scan_result( + head_full_scan_id, + new_full_scan_id, + external_href=external_href, + ).artifacts def _requires_unchanged_artifacts(self) -> bool: """Whether any enabled output reads the unchanged half of a comparison. @@ -2148,13 +2162,13 @@ def _requires_unchanged_artifacts(self) -> bool: or getattr(config, "legal_format", "socket") == "fossa" ) - def get_added_and_removed_packages( + def _get_package_comparison( self, head_full_scan_id: str, new_full_scan_id: str, include_license_details: bool = False, external_href: Optional[str] = None - ) -> Tuple[Dict[str, Package], Dict[str, Package], Dict[str, Package]]: + ) -> PackageComparisonResult: """ Get packages that were added and removed between scans. @@ -2190,14 +2204,14 @@ def get_added_and_removed_packages( with the primary diff-scan resource Returns: - Tuple of (added_packages, removed_packages) dictionaries + Added, removed, and all-package maps plus the completed diff ID """ log.info(f"Comparing scans - Head scan ID: {head_full_scan_id}, New scan ID: {new_full_scan_id}") diff_start = time.time() - diff_artifacts = None + comparison = None try: - diff_artifacts = self.get_diff_scan_artifacts( + comparison = self._get_diff_scan_result( head_full_scan_id, new_full_scan_id, external_href=external_href, @@ -2215,7 +2229,7 @@ def get_added_and_removed_packages( f"reason={type(error).__name__}" ) - if diff_artifacts is None: + if comparison is None: try: diff_artifacts = ( self.sdk.fullscans.stream_diff( @@ -2226,6 +2240,10 @@ def get_added_and_removed_packages( include_license_details=str(include_license_details).lower() ).data.artifacts ) + comparison = DiffScanResult( + artifacts=diff_artifacts, + diff_scan_id=None, + ) except APIFailure as e: log.error(f"API Error: {e}") # API failures are mapped to the configured infrastructure exit code by cli(). @@ -2245,7 +2263,7 @@ def get_added_and_removed_packages( marker_found = False buckets: Dict[str, List] = {} for name in ("added", "removed", "unchanged", "replaced", "updated"): - bucket = getattr(diff_artifacts, name) + bucket = getattr(comparison.artifacts, name) buckets[name] = [a for a in bucket if not Core.is_invalid_facts_marker(a)] marker_found = marker_found or len(buckets[name]) != len(bucket) Core.warn_if_invalid_facts_marker(marker_found) @@ -2304,7 +2322,27 @@ def get_added_and_removed_packages( else: log.debug("Skipping PURL endpoint call (--generate-license not set)") - return added_packages, removed_packages, packages + return PackageComparisonResult( + added_packages=added_packages, + removed_packages=removed_packages, + packages=packages, + diff_scan_id=comparison.diff_scan_id, + ) + + def get_added_and_removed_packages( + self, + head_full_scan_id: str, + new_full_scan_id: str, + include_license_details: bool = False, + external_href: Optional[str] = None + ) -> Tuple[Dict[str, Package], Dict[str, Package], Dict[str, Package]]: + result = self._get_package_comparison( + head_full_scan_id, + new_full_scan_id, + include_license_details=include_license_details, + external_href=external_href, + ) + return result.added_packages, result.removed_packages, result.packages def create_new_diff( self, @@ -2456,16 +2494,15 @@ def create_new_diff( # the response and risks the truncation crash on large repos. The # user flag still controls the dashboard report URL below; it just no # longer gates this internal diff payload. - ( - added_packages, - removed_packages, - packages - ) = self.get_added_and_removed_packages( + comparison = self._get_package_comparison( head_full_scan_id, new_full_scan.id, include_license_details=False, external_href=external_href, ) + added_packages = comparison.added_packages + removed_packages = comparison.removed_packages + packages = comparison.packages # Separate unchanged packages from added/removed for --strict-blocking support unchanged_packages = { @@ -2477,13 +2514,16 @@ def create_new_diff( diff.packages = packages base_socket = "https://socket.dev/dashboard/org" - diff.id = new_full_scan.id + diff.set_scan_ids( + new_full_scan.id, + old_scan_id=head_full_scan_id, + diff_scan_id=comparison.diff_scan_id, + ) report_url = f"{base_socket}/{self.config.org_slug}/sbom/{diff.id}" if not params.include_license_details: report_url += "?include_license_details=false" diff.report_url = report_url - diff.new_scan_id = new_full_scan.id if head_full_scan_id is not None: diff.diff_url = f"{base_socket}/{self.config.org_slug}/diff/{head_full_scan_id}/{diff.id}" diff --git a/socketsecurity/core/alert_selection.py b/socketsecurity/core/alert_selection.py index 132be29..bc30370 100644 --- a/socketsecurity/core/alert_selection.py +++ b/socketsecurity/core/alert_selection.py @@ -38,7 +38,9 @@ def clone_diff_with_selected_alerts(diff: Diff, selected_alerts: List[Issue]) -> ) selected_diff.id = getattr(diff, "id", "") selected_diff.report_url = getattr(diff, "report_url", "") - selected_diff.new_scan_id = getattr(diff, "new_scan_id", "") + selected_diff.new_scan_id = getattr(diff, "new_scan_id", None) + selected_diff.old_scan_id = getattr(diff, "old_scan_id", None) + selected_diff.diff_scan_id = getattr(diff, "diff_scan_id", None) return selected_diff diff --git a/socketsecurity/core/classes.py b/socketsecurity/core/classes.py index fd8fabc..4504e1a 100644 --- a/socketsecurity/core/classes.py +++ b/socketsecurity/core/classes.py @@ -571,7 +571,9 @@ class Diff: sbom: str report_url: str diff_url: str - new_scan_id: str + new_scan_id: Optional[str] + old_scan_id: Optional[str] + diff_scan_id: Optional[str] is_full_scan: bool alerts_fetched: bool @@ -599,6 +601,24 @@ def __init__(self, **kwargs): self.is_full_scan = False if not hasattr(self, "alerts_fetched"): self.alerts_fetched = False + if not hasattr(self, "new_scan_id"): + self.new_scan_id = None + if not hasattr(self, "old_scan_id"): + self.old_scan_id = None + if not hasattr(self, "diff_scan_id"): + self.diff_scan_id = None + + def set_scan_ids( + self, + new_scan_id: str, + *, + old_scan_id: Optional[str] = None, + diff_scan_id: Optional[str] = None, + ) -> None: + self.id = new_scan_id + self.new_scan_id = new_scan_id + self.old_scan_id = old_scan_id + self.diff_scan_id = diff_scan_id def __str__(self): return json.dumps(self.__dict__) diff --git a/socketsecurity/core/diff_result.py b/socketsecurity/core/diff_result.py new file mode 100644 index 0000000..16b2fb7 --- /dev/null +++ b/socketsecurity/core/diff_result.py @@ -0,0 +1,32 @@ +from dataclasses import dataclass +from typing import Dict, Optional + +from socketdev.fullscans import DiffArtifacts + +from socketsecurity.core.classes import Package + + +@dataclass +class DiffScanResult: + artifacts: DiffArtifacts + diff_scan_id: Optional[str] + + +@dataclass +class PackageComparisonResult: + added_packages: Dict[str, Package] + removed_packages: Dict[str, Package] + packages: Dict[str, Package] + diff_scan_id: Optional[str] + + +def create_diff_scan_result( + artifacts: Dict[str, object], diff_scan_id: Optional[str] +) -> DiffScanResult: + return DiffScanResult( + artifacts=DiffArtifacts.from_dict({ + key: artifacts.get(key) or [] + for key in ("added", "removed", "unchanged", "replaced", "updated") + }), + diff_scan_id=diff_scan_id, + ) diff --git a/tests/core/test_diff_identity_transport.py b/tests/core/test_diff_identity_transport.py new file mode 100644 index 0000000..3915897 --- /dev/null +++ b/tests/core/test_diff_identity_transport.py @@ -0,0 +1,415 @@ +"""Diff identity tests across the real Core, SDK, and HTTP boundaries.""" + +from __future__ import annotations + +import json +from collections.abc import Iterator + +import pytest +from socketdev import socketdev +from socketdev.fullscans import FullScanParams, SocketArtifact + +from socketsecurity.config import CliConfig +from socketsecurity.core import Core +from socketsecurity.core.socket_config import SocketConfig +from tests.support.socket_api_fixture import ( + SocketApiFixture, + SocketRequest, + SocketResponse, + socket_api_fixture, +) + + +@pytest.fixture +def socket_api() -> Iterator[SocketApiFixture]: + fixture = socket_api_fixture() + try: + yield fixture + finally: + fixture.close() + + +@pytest.fixture +def core(socket_api: SocketApiFixture) -> Core: + sdk = socketdev(token="fictional-local-only", timeout=3) + sdk.api.api_url = socket_api.base_url + return Core(config=SocketConfig(api_key="fictional-local-only"), sdk=sdk) + + +def empty_artifacts() -> dict[str, list[object]]: + return { + "added": [], + "removed": [], + "unchanged": [], + "replaced": [], + "updated": [], + } + + +def diff_response(diff_id: str) -> dict[str, object]: + return {"diff_scan": {"id": diff_id}} + + +def completion_response() -> dict[str, object]: + return {"diff_scan": {"artifacts": empty_artifacts()}} + + +def stream_diff_response() -> dict[str, object]: + commit = { + "repository_id": "repo-fixture", + "branch": "main", + "id": "scan-fixture", + "organization_id": "org-fixture", + "committers": [], + } + return { + "before": commit, + "after": commit, + "diff_report_url": "https://socket.dev/fixture", + "artifacts": empty_artifacts(), + } + + +def install_primary_routes(api: SocketApiFixture, diff_id: str) -> None: + api.route( + "POST", + "orgs/fixture-org/diff-scans/from-ids", + SocketResponse(201, diff_response(diff_id)), + ) + api.route( + "GET", + f"orgs/fixture-org/diff-scans/{diff_id}", + SocketResponse(200, completion_response()), + ) + + +def test_primary_diff_scan_uses_server_resource_id_and_real_cached_get( + core: Core, + socket_api: SocketApiFixture, +) -> None: + install_primary_routes(socket_api, "server-diff-id") + + result = core._get_diff_scan_result("baseline-scan-id", "new-scan-id") + + assert result.diff_scan_id == "server-diff-id" + assert result.artifacts.added == [] + assert [request.path for request in socket_api.requests] == [ + "organizations", + "orgs/fixture-org/diff-scans/from-ids", + "orgs/fixture-org/diff-scans/server-diff-id", + ] + create = socket_api.requests[1] + assert create.query["before"] == ["baseline-scan-id"] + assert create.query["after"] == ["new-scan-id"] + assert socket_api.requests[2].query == {"cached": ["true"]} + + +def test_duplicate_conflict_uses_listed_id_and_cached_get( + core: Core, + socket_api: SocketApiFixture, +) -> None: + def duplicate(_request: SocketRequest) -> SocketResponse: + return SocketResponse(409, {"error": {"message": "duplicate"}}) + + socket_api.route("POST", "orgs/fixture-org/diff-scans/from-ids", duplicate) + socket_api.route( + "GET", + "orgs/fixture-org/diff-scans", + SocketResponse(200, {"results": [{"id": "listed-diff-id"}]}), + ) + socket_api.route( + "GET", + "orgs/fixture-org/diff-scans/listed-diff-id", + SocketResponse(200, completion_response()), + ) + + result = core._get_diff_scan_result("baseline-scan-id", "new-scan-id") + + assert result.diff_scan_id == "listed-diff-id" + assert [request.path for request in socket_api.requests] == [ + "organizations", + "orgs/fixture-org/diff-scans/from-ids", + "orgs/fixture-org/diff-scans", + "orgs/fixture-org/diff-scans/listed-diff-id", + ] + assert socket_api.requests[2].query == { + "before_full_scan_id": ["baseline-scan-id"], + "after_full_scan_id": ["new-scan-id"], + "per_page": ["1"], + } + assert socket_api.requests[3].query == {"cached": ["true"]} + + +@pytest.mark.parametrize("failure_status", [403, 200]) +def test_streaming_fallback_does_not_return_attempted_diff_id( + core: Core, + socket_api: SocketApiFixture, + failure_status: int, +) -> None: + if failure_status == 403: + socket_api.route( + "POST", + "orgs/fixture-org/diff-scans/from-ids", + SocketResponse(403, {"error": {"message": "forbidden"}}), + ) + else: + socket_api.route( + "POST", + "orgs/fixture-org/diff-scans/from-ids", + SocketResponse(201, diff_response("abandoned-diff-id")), + ) + socket_api.route( + "GET", + "orgs/fixture-org/diff-scans/abandoned-diff-id", + SocketResponse(200, {"diff_scan": {"artifacts": None}}), + ) + socket_api.route( + "GET", + "orgs/fixture-org/full-scans/diff", + SocketResponse(200, stream_diff_response()), + ) + + result = core._get_package_comparison( + "baseline-scan-id", "new-scan-id", include_license_details=False + ) + + assert result.diff_scan_id is None + assert result.added_packages == {} + assert result.removed_packages == {} + assert result.packages == {} + fallback = socket_api.requests[-1] + assert fallback.path == "orgs/fixture-org/full-scans/diff" + assert fallback.query == { + "before": ["baseline-scan-id"], + "after": ["new-scan-id"], + "include_license_details": ["false"], + } + + +def test_compatibility_wrappers_preserve_legacy_return_shapes( + core: Core, + socket_api: SocketApiFixture, +) -> None: + install_primary_routes(socket_api, "wrapper-diff-id") + + artifacts = core.get_diff_scan_artifacts("baseline-scan-id", "new-scan-id") + added, removed, packages = core.get_added_and_removed_packages( + "baseline-scan-id", "new-scan-id" + ) + + assert (artifacts.added, artifacts.removed) == ([], []) + assert added == {} + assert removed == {} + assert packages == {} + comparison_requests = socket_api.requests[1:] + assert len(comparison_requests) == 4 + assert [request.path for request in comparison_requests] == [ + "orgs/fixture-org/diff-scans/from-ids", + "orgs/fixture-org/diff-scans/wrapper-diff-id", + "orgs/fixture-org/diff-scans/from-ids", + "orgs/fixture-org/diff-scans/wrapper-diff-id", + ] + + +@pytest.mark.parametrize("comparison_succeeds", [True, False]) +def test_create_new_diff_retains_real_empty_baseline_id( + core: Core, + socket_api: SocketApiFixture, + tmp_path, + comparison_succeeds: bool, +) -> None: + socket_api.route( + "GET", + "orgs/fixture-org/repos/fixture-repo", + SocketResponse( + 200, + { + "id": "repository-id", + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + "head_full_scan_id": "", + "name": "fixture-repo", + "slug": "fixture-repo", + "description": "", + "homepage": "https://example.invalid/fixture-repo", + "visibility": "private", + "archived": False, + "default_branch": "main", + }, + ), + ) + created_scan_ids = iter(("temporary-baseline-id", "new-full-scan-id")) + + def create_full_scan(_request: SocketRequest) -> SocketResponse: + scan_id = next(created_scan_ids) + return SocketResponse( + 201, + { + "id": scan_id, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + "organization_id": "org-fixture", + "repository_id": "repository-id", + "branch": "feature", + "html_report_url": f"https://socket.dev/scan/{scan_id}", + }, + ) + + socket_api.route("POST", "orgs/fixture-org/full-scans", create_full_scan) + if comparison_succeeds: + install_primary_routes(socket_api, "new-comparison-id") + else: + socket_api.route( + "POST", + "orgs/fixture-org/diff-scans/from-ids", + SocketResponse(403, {"error": {"message": "forbidden"}}), + ) + socket_api.route( + "GET", + "orgs/fixture-org/full-scans/diff", + SocketResponse(200, stream_diff_response()), + ) + manifest = tmp_path / "requirements.txt" + manifest.write_text("sample-package==1.0.0\n") + params = FullScanParams( + repo="fixture-repo", + org_slug="fixture-org", + branch="feature", + commit_hash="new-commit", + ) + params.include_license_details = True + + diff = core.create_new_diff( + [str(tmp_path)], + params, + explicit_files=[str(manifest)], + base_paths=[str(tmp_path)], + ) + + assert diff.id == "new-full-scan-id" + assert diff.new_scan_id == "new-full-scan-id" + assert diff.old_scan_id == "temporary-baseline-id" + assert diff.diff_scan_id == ("new-comparison-id" if comparison_succeeds else None) + full_scan_requests = [ + request + for request in socket_api.requests + if request.path == "orgs/fixture-org/full-scans" + ] + assert len(full_scan_requests) == 2 + assert all( + "multipart/form-data" in request.headers["Content-Type"] + for request in full_scan_requests + ) + assert b"sample-package==1.0.0" in full_scan_requests[1].body + assert full_scan_requests[0].query["tmp"] == ["True"] + assert full_scan_requests[1].query["commit_hash"] == ["new-commit"] + comparison_create = next( + request + for request in socket_api.requests + if request.path == "orgs/fixture-org/diff-scans/from-ids" + ) + assert comparison_create.query["before"] == ["temporary-baseline-id"] + assert comparison_create.query["after"] == ["new-full-scan-id"] + + +@pytest.mark.parametrize("enable_json", [False, True]) +def test_full_scan_identity_is_preserved_with_findings_disabled_or_enabled( + socket_api: SocketApiFixture, + tmp_path, + enable_json: bool, +) -> None: + cli_config = None + if enable_json: + cli_config = CliConfig( + api_token="fictional-local-only", + repo="fixture-repo", + enable_json=True, + ) + sdk = socketdev(token="fictional-local-only", timeout=3) + sdk.api.api_url = socket_api.base_url + core = Core( + config=SocketConfig(api_key="fictional-local-only"), + sdk=sdk, + cli_config=cli_config, + ) + socket_api.route( + "POST", + "orgs/fixture-org/full-scans", + SocketResponse( + 201, + { + "id": "full-scan-id", + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + "organization_id": "org-fixture", + "repository_id": "repository-fixture", + "branch": "feature", + "html_report_url": "https://socket.dev/scan/full-scan-id", + }, + ), + ) + if enable_json: + artifact = SocketArtifact.from_dict( + { + "id": "artifact-fixture-id", + "type": "npm", + "name": "sample-package", + "version": "1.0.0", + "alerts": [], + "topLevelAncestors": [], + "direct": True, + } + ) + socket_api.route( + "GET", + "orgs/fixture-org/full-scans/full-scan-id", + SocketResponse( + 200, + {}, + raw_body=json.dumps(artifact.to_dict()).encode("utf-8"), + ), + ) + manifest = tmp_path / "requirements.txt" + manifest.write_text("sample-package==1.0.0\n") + params = FullScanParams( + repo="fixture-repo", + org_slug="fixture-org", + branch="feature", + ) + + result = core.create_full_scan_with_report_url( + [str(tmp_path)], params, explicit_files=[str(manifest)] + ) + + assert result.id == result.new_scan_id == "full-scan-id" + assert result.old_scan_id is None + assert result.diff_scan_id is None + assert result.is_full_scan is True + assert result.alerts_fetched is enable_json + if enable_json: + assert result.packages["artifact-fixture-id"].purl == "npm/sample-package@1.0.0" + findings_requests = [ + request + for request in socket_api.requests + if request.path == "orgs/fixture-org/full-scans/full-scan-id" + ] + assert len(findings_requests) == int(enable_json) + + +def test_no_change_paths_keep_sentinels_without_transport_calls( + core: Core, + socket_api: SocketApiFixture, +) -> None: + request_count_after_setup = len(socket_api.requests) + params = FullScanParams(repo="fixture-repo") + + no_diff = core.create_new_diff([], params, no_change=True) + no_scan = core.create_full_scan_with_report_url([], params, no_change=True) + + assert no_diff.id == "NO_DIFF_RAN" + assert no_scan.id == "NO_SCAN_RAN" + for result in (no_diff, no_scan): + assert result.new_scan_id is None + assert result.old_scan_id is None + assert result.diff_scan_id is None + assert len(socket_api.requests) == request_count_after_setup diff --git a/tests/core/test_diff_scan_polling.py b/tests/core/test_diff_scan_polling.py index 8cd28c7..c06f61c 100644 --- a/tests/core/test_diff_scan_polling.py +++ b/tests/core/test_diff_scan_polling.py @@ -216,24 +216,6 @@ def test_link_falls_back_to_resolving_the_duplicate_on_older_deployments(core): assert len(artifacts.added) > 0 -def test_fallback_to_streaming_diff_on_failure(core): - """If the diff-scans flow fails (e.g. token missing the diff-scans scopes), - the comparison falls back to the legacy streaming diff transparently.""" - core.sdk.diffscans.create_from_ids.side_effect = APIFailure("forbidden", status_code=403) - - added, removed, all_packages = core.get_added_and_removed_packages("head", "new") - - core.sdk.fullscans.stream_diff.assert_called_once_with( - core.config.org_slug, - "head", - "new", - use_types=True, - include_license_details="false", - ) - assert "dp3" in added - assert "dp2" in removed - - def test_completion_log_reports_id_polls_and_final_wait( core, diff_scan_get_response, no_sleep, caplog, monkeypatch ): diff --git a/tests/support/__init__.py b/tests/support/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/support/socket_api_fixture.py b/tests/support/socket_api_fixture.py new file mode 100644 index 0000000..d884842 --- /dev/null +++ b/tests/support/socket_api_fixture.py @@ -0,0 +1,121 @@ +"""Reusable localhost HTTP fixture for real Socket SDK transport tests.""" + +from __future__ import annotations + +import json +import threading +from dataclasses import dataclass +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Callable, Dict, List, Optional, Tuple +from urllib.parse import parse_qs, urlsplit + + +@dataclass +class SocketRequest: + method: str + path: str + query: Dict[str, List[str]] + headers: Dict[str, str] + body: bytes + + +@dataclass +class SocketResponse: + status: int + payload: object + raw_body: Optional[bytes] = None + + +Route = Callable[[SocketRequest], SocketResponse] + + +class SocketApiFixture: + def __init__(self) -> None: + self.requests: List[SocketRequest] = [] + self._routes: Dict[Tuple[str, str], Route] = {} + self._server: Optional[ThreadingHTTPServer] = None + self._thread: Optional[threading.Thread] = None + + @property + def base_url(self) -> str: + if self._server is None: + raise RuntimeError("Socket API fixture is not running") + host, port = self._server.server_address + return f"http://{host}:{port}/v0" + + def route( + self, + method: str, + path: str, + response: SocketResponse | Route, + ) -> None: + if callable(response): + handler = response + else: + + def handler(_request: SocketRequest) -> SocketResponse: + return response + + self._routes[(method.upper(), path)] = handler + + def start(self) -> None: + fixture = self + + class Handler(BaseHTTPRequestHandler): + def do_GET(self) -> None: + self._dispatch() + + def do_POST(self) -> None: + self._dispatch() + + def _dispatch(self) -> None: + parsed = urlsplit(self.path) + length = int(self.headers.get("Content-Length", "0")) + request = SocketRequest( + method=self.command, + path=parsed.path.removeprefix("/v0/"), + query=parse_qs(parsed.query, keep_blank_values=True), + headers=dict(self.headers.items()), + body=self.rfile.read(length), + ) + fixture.requests.append(request) + route = fixture._routes.get((request.method, request.path)) + if route is None and request.path == "organizations": + response = SocketResponse( + 200, + {"organizations": {"org-fixture": {"slug": "fixture-org"}}}, + ) + elif route is None: + response = SocketResponse(404, {"error": {"message": "Not found"}}) + else: + response = route(request) + body = response.raw_body + if body is None: + body = json.dumps(response.payload).encode("utf-8") + self.send_response(response.status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, _format: str, *args: object) -> None: + return + + self._server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + self._thread = threading.Thread(target=self._server.serve_forever, daemon=True) + self._thread.start() + + def close(self) -> None: + if self._server is not None: + self._server.shutdown() + self._server.server_close() + self._server = None + if self._thread is not None: + self._thread.join(timeout=2) + self._thread = None + + +def socket_api_fixture() -> SocketApiFixture: + fixture = SocketApiFixture() + fixture.start() + return fixture diff --git a/tests/unit/test_diff_identity.py b/tests/unit/test_diff_identity.py new file mode 100644 index 0000000..7dd60cf --- /dev/null +++ b/tests/unit/test_diff_identity.py @@ -0,0 +1,205 @@ +import json + +from socketdev.fullscans import DiffArtifacts + +from socketsecurity.core.alert_selection import clone_diff_with_selected_alerts +from socketsecurity.core.classes import Diff, Issue, Package +from socketsecurity.core.diff_result import ( + DiffScanResult, + PackageComparisonResult, + create_diff_scan_result, +) +from socketsecurity.core.messages import Messages + + +def test_diff_scan_ids_default_and_assignment(): + diff = Diff(id="NO_DIFF_RAN", report_url="", diff_url="") + + assert diff.new_scan_id is None + assert diff.old_scan_id is None + assert diff.diff_scan_id is None + + diff.set_scan_ids("new-scan", old_scan_id="old-scan", diff_scan_id="diff-resource") + + assert diff.id == "new-scan" + assert diff.new_scan_id == "new-scan" + assert diff.old_scan_id == "old-scan" + assert diff.diff_scan_id == "diff-resource" + + no_scan = Diff(id="NO_SCAN_RAN", report_url="", diff_url="") + assert no_scan.id == "NO_SCAN_RAN" + assert no_scan.new_scan_id is None + assert no_scan.old_scan_id is None + assert no_scan.diff_scan_id is None + + +def test_streaming_and_temporary_baseline_assignments(): + streaming_diff = Diff() + streaming_diff.set_scan_ids("new-scan", old_scan_id="old-scan") + + temporary_baseline_diff = Diff() + temporary_baseline_diff.set_scan_ids( + "new-scan", + old_scan_id="actual-temporary-baseline-scan", + diff_scan_id="server-diff", + ) + + assert streaming_diff.id == streaming_diff.new_scan_id == "new-scan" + assert streaming_diff.old_scan_id == "old-scan" + assert streaming_diff.diff_scan_id is None + assert temporary_baseline_diff.old_scan_id == "actual-temporary-baseline-scan" + assert temporary_baseline_diff.diff_scan_id == "server-diff" + + +def test_full_scan_assignment_clears_comparison_ids(): + diff = Diff() + diff.set_scan_ids("comparison-new", old_scan_id="old", diff_scan_id="server-diff") + + diff.set_scan_ids("full-scan") + + assert diff.id == "full-scan" + assert diff.new_scan_id == "full-scan" + assert diff.old_scan_id is None + assert diff.diff_scan_id is None + + +def test_artifact_result_normalizes_sdk_buckets_and_preserves_identity(): + def artifact(bucket: str) -> dict[str, str]: + return { + "id": f"{bucket}-artifact", + "type": "npm", + "name": f"{bucket}-sample", + "version": "1.0.0", + "diffType": bucket, + } + + result = create_diff_scan_result( + { + "added": [artifact("added")], + "removed": [artifact("removed")], + "unchanged": [artifact("unchanged")], + "replaced": [artifact("replaced")], + "updated": [artifact("updated")], + }, + "server-diff-id", + ) + + assert isinstance(result, DiffScanResult) + assert result.diff_scan_id == "server-diff-id" + assert isinstance(result.artifacts, DiffArtifacts) + for bucket in ("added", "removed", "unchanged", "replaced", "updated"): + assert [item.id for item in getattr(result.artifacts, bucket)] == [ + f"{bucket}-artifact" + ] + + missing_bucket = create_diff_scan_result( + {"added": [artifact("added")]}, "server-diff-id" + ) + assert [item.id for item in missing_bucket.artifacts.added] == ["added-artifact"] + assert missing_bucket.artifacts.removed == [] + assert missing_bucket.artifacts.unchanged == [] + assert missing_bucket.artifacts.replaced == [] + assert missing_bucket.artifacts.updated == [] + + null_buckets = create_diff_scan_result( + { + "added": [artifact("added")], + "removed": None, + "unchanged": None, + "replaced": None, + "updated": None, + }, + "server-diff-id", + ) + assert null_buckets.artifacts.removed == [] + assert null_buckets.artifacts.unchanged == [] + assert null_buckets.artifacts.replaced == [] + assert null_buckets.artifacts.updated == [] + + +def test_package_comparison_result_retains_maps_and_identity(): + package = Package( + type="npm", + name="sample", + version="1.0.0", + id="sample-id", + score=None, + alerts=[], + ) + added = {"added": package} + removed = {"removed": package} + packages = {"all": package} + + result = PackageComparisonResult(added, removed, packages, None) + + assert result.added_packages is added + assert result.removed_packages is removed + assert result.packages is packages + assert result.diff_scan_id is None + + +def test_alert_clone_preserves_scan_identity_and_no_run_absence(): + issue = Issue(title="selected") + diff = Diff( + id="new-scan", + report_url="report", + diff_url="comparison", + new_alerts=[issue], + ) + diff.set_scan_ids("new-scan", old_scan_id="old-scan", diff_scan_id="diff-resource") + + clone = clone_diff_with_selected_alerts(diff, [issue]) + no_run = Diff(id="NO_DIFF_RAN", report_url="", diff_url="") + no_run_clone = clone_diff_with_selected_alerts(no_run, []) + fallback = Diff( + id="fallback-new-scan", + report_url="fallback-report", + diff_url="fallback-diff", + ) + fallback.set_scan_ids("fallback-new-scan", old_scan_id="fallback-old-scan") + fallback_clone = clone_diff_with_selected_alerts(fallback, []) + + assert clone.id == "new-scan" + assert clone.new_scan_id == "new-scan" + assert clone.old_scan_id == "old-scan" + assert clone.diff_scan_id == "diff-resource" + assert diff.new_alerts == [issue] + assert fallback_clone.new_scan_id == "fallback-new-scan" + assert fallback_clone.old_scan_id == "fallback-old-scan" + assert fallback_clone.diff_scan_id is None + assert no_run_clone.new_scan_id is None + assert no_run_clone.old_scan_id is None + assert no_run_clone.diff_scan_id is None + + +def test_identity_does_not_change_existing_serialized_output(): + diff = Diff( + id="new-scan", + report_url="report", + diff_url="comparison", + packages={}, + ) + diff.set_scan_ids("new-scan", old_scan_id="old-scan", diff_scan_id="server-diff") + + assert set(diff.to_dict()) == { + "new_packages", + "updated_packages", + "new_capabilities", + "removed_packages", + "replaced_packages", + "new_alerts", + "unchanged_alerts", + "removed_alerts", + "id", + "sbom", + "packages", + "report_url", + "diff_url", + } + output = Messages.create_security_comment_json(diff) + assert json.loads(json.dumps(output)) == { + "scan_failed": False, + "new_alerts": [], + "full_scan_id": "new-scan", + "diff_url": "comparison", + } diff --git a/tests/unit/test_scan_scope.py b/tests/unit/test_scan_scope.py index bedd2c9..6ee6ca7 100644 --- a/tests/unit/test_scan_scope.py +++ b/tests/unit/test_scan_scope.py @@ -1,67 +1,86 @@ -from types import SimpleNamespace -from unittest.mock import MagicMock +from __future__ import annotations + +from collections.abc import Iterator import pytest +from socketdev import socketdev from socketdev.exceptions import APIFailure from socketdev.fullscans import FullScanParams +from socketsecurity.config import CliConfig from socketsecurity.core import Core -from socketsecurity.core.classes import Diff - - -def _core() -> Core: - core = Core.__new__(Core) - core.config = SimpleNamespace(org_slug="example") - core.cli_config = SimpleNamespace( - disable_blocking=False, - exit_code_on_api_error=0, - generate_license=False, - ) - core.sdk = MagicMock() - return core - - -def test_multiple_scan_paths_are_uploaded_as_one_combined_full_scan(caplog): - """Repeated --sub-path roots feed one graph, not independent scans.""" - core = _core() - core.find_files = MagicMock( - side_effect=[ - ["/repo/frontend/package.json"], - ["/repo/backend/requirements.txt"], - ] - ) - core.resolve_base_full_scan_id = MagicMock(return_value="base-scan") - core.create_full_scan = MagicMock(return_value=SimpleNamespace(id="new-scan")) - core.get_added_and_removed_packages = MagicMock(return_value=({}, {}, {})) - core.create_diff_report = MagicMock(return_value=Diff()) - params = FullScanParams(repo="repo-combined", branch="feature", scan_type="socket") - params.include_license_details = True +from socketsecurity.core.socket_config import SocketConfig +from tests.support.socket_api_fixture import ( + SocketApiFixture, + SocketResponse, + socket_api_fixture, +) - with caplog.at_level("INFO", logger="socketdev"): - result = core.create_new_diff( - ["/repo/frontend", "/repo/backend"], - params, - base_paths=["/repo"], - ) - assert core.find_files.call_args_list == [ - (("/repo/frontend",),), - (("/repo/backend",),), - ] - core.create_full_scan.assert_called_once_with( - ["/repo/frontend/package.json", "/repo/backend/requirements.txt"], - params, - base_paths=["/repo"], +@pytest.fixture +def socket_api() -> Iterator[SocketApiFixture]: + fixture = socket_api_fixture() + try: + yield fixture + finally: + fixture.close() + + +def create_core( + api: SocketApiFixture, + cli_config: CliConfig | None = None, +) -> Core: + sdk = socketdev(token="fictional-local-only", timeout=3) + sdk.api.api_url = api.base_url + return Core( + config=SocketConfig(api_key="fictional-local-only"), + sdk=sdk, + cli_config=cli_config, ) - assert result.id == "new-scan" - assert ( - 'Scan configuration: repo="repo-combined" workspace=null ' - 'scan_type="socket" roots=["frontend","backend"] manifests=2 ' - "manifest_source=discovered" - ) in caplog.messages -def test_scan_configuration_omits_absolute_and_manifest_paths(caplog): +def full_scan_response(scan_id: str) -> SocketResponse: + return SocketResponse( + 201, + { + "id": scan_id, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + "organization_id": "org-fixture", + "repository_id": "repository-fixture", + "branch": "feature", + "html_report_url": f"https://socket.dev/scan/{scan_id}", + }, + ) + + +def install_diff_routes(api: SocketApiFixture) -> None: + api.route( + "POST", + "orgs/fixture-org/diff-scans/from-ids", + SocketResponse(201, {"diff_scan": {"id": "diff-resource-id"}}), + ) + api.route( + "GET", + "orgs/fixture-org/diff-scans/diff-resource-id", + SocketResponse( + 200, + { + "diff_scan": { + "artifacts": { + "added": [], + "removed": [], + "unchanged": [], + "replaced": [], + "updated": [], + } + } + }, + ), + ) + + +def test_scan_configuration_omits_absolute_and_manifest_paths(caplog) -> None: params = FullScanParams( repo="repo-service", branch="feature", @@ -85,56 +104,210 @@ def test_scan_configuration_omits_absolute_and_manifest_paths(caplog): assert "requirements.txt" not in message -def test_empty_baseline_logs_created_scan_id(caplog): - core = _core() - core.resolve_base_full_scan_id = MagicMock(return_value=None) - core.create_full_scan = MagicMock( - side_effect=[ - SimpleNamespace(id="empty-base"), - SimpleNamespace(id="new-scan"), - ] +def test_multiple_scan_paths_are_uploaded_as_one_combined_full_scan( + caplog, + socket_api: SocketApiFixture, + tmp_path, +) -> None: + cli_config = CliConfig( + api_token="fictional-local-only", + repo="fixture-repo", + base_scan_id="baseline-scan-id", + ) + core = create_core(socket_api, cli_config) + socket_api.route( + "POST", + "orgs/fixture-org/full-scans", + full_scan_response("new-full-scan-id"), + ) + install_diff_routes(socket_api) + frontend = tmp_path / "frontend" / "package.json" + backend = tmp_path / "backend" / "requirements.txt" + frontend.parent.mkdir() + backend.parent.mkdir() + frontend.write_text('{"dependencies":{"sample":"1.0.0"}}\n') + backend.write_text("sample-package==1.0.0\n") + params = FullScanParams( + repo="fixture-repo", + org_slug="fixture-org", + branch="feature", + commit_hash="new-commit", ) - core.get_added_and_removed_packages = MagicMock(return_value=({}, {}, {})) - core.create_diff_report = MagicMock(return_value=Diff()) - params = FullScanParams(repo="repo-service", branch="feature") params.include_license_details = True with caplog.at_level("INFO", logger="socketdev"): - core.create_new_diff( - ["/repo/service"], + result = core.create_new_diff( + [str(frontend.parent), str(backend.parent)], params, - base_paths=["/repo"], - explicit_files=["/repo/service/requirements.txt"], + explicit_files=[str(frontend), str(backend)], + base_paths=[str(tmp_path)], ) - assert 'Baseline selected: source=empty scan_id="empty-base"' in caplog.messages + full_scan_requests = [ + request + for request in socket_api.requests + if request.path == "orgs/fixture-org/full-scans" + ] + assert len(full_scan_requests) == 1 + assert result.id == result.new_scan_id == "new-full-scan-id" + assert result.old_scan_id == "baseline-scan-id" + assert result.diff_scan_id == "diff-resource-id" + assert ( + 'Scan configuration: repo="fixture-repo" workspace=null ' + 'scan_type=null roots=["frontend","backend"] manifests=2 ' + "manifest_source=provided" + ) in caplog.messages + + +def test_empty_baseline_creation_keeps_its_actual_id( + socket_api: SocketApiFixture, + tmp_path, +) -> None: + core = create_core(socket_api) + socket_api.route( + "GET", + "orgs/fixture-org/repos/fixture-repo", + SocketResponse( + 200, + { + "id": "repository-id", + "head_full_scan_id": "", + "name": "fixture-repo", + "slug": "fixture-repo", + "description": "", + "homepage": "", + "visibility": "private", + "archived": False, + "default_branch": "main", + }, + ), + ) + created_scan_ids: Iterator[str] = iter(("empty-baseline-id", "new-scan-id")) + socket_api.route( + "POST", + "orgs/fixture-org/full-scans", + lambda _request: full_scan_response(next(created_scan_ids)), + ) + install_diff_routes(socket_api) + manifest = tmp_path / "requirements.txt" + manifest.write_text("sample-package==1.0.0\n") + params = FullScanParams( + repo="fixture-repo", + org_slug="fixture-org", + branch="feature", + commit_hash="new-commit", + ) + params.include_license_details = True + + result = core.create_new_diff( + [str(tmp_path)], + params, + explicit_files=[str(manifest)], + base_paths=[str(tmp_path)], + ) + + assert result.old_scan_id == "empty-baseline-id" + assert result.new_scan_id == "new-scan-id" + assert result.diff_scan_id == "diff-resource-id" + comparison_create = next( + request + for request in socket_api.requests + if request.path == "orgs/fixture-org/diff-scans/from-ids" + ) + assert comparison_create.query["before"] == ["empty-baseline-id"] + assert comparison_create.query["after"] == ["new-scan-id"] -def test_full_scan_api_failure_propagates_for_cli_exit_code_mapping(): - core = _core() - core.resolve_base_full_scan_id = MagicMock(return_value="base-scan") - core.create_full_scan = MagicMock(side_effect=APIFailure("upload failed")) - params = FullScanParams(repo="repo", branch="feature") +def test_full_scan_upload_failure_comes_from_real_http_response( + socket_api: SocketApiFixture, + tmp_path, +) -> None: + cli_config = CliConfig( + api_token="fictional-local-only", + repo="fixture-repo", + base_scan_id="baseline-scan-id", + ) + core = create_core(socket_api, cli_config) + socket_api.route( + "POST", + "orgs/fixture-org/full-scans", + SocketResponse(500, {"error": {"message": "local fixture failure"}}), + ) + manifest = tmp_path / "requirements.txt" + manifest.write_text("sample-package==1.0.0\n") + params = FullScanParams( + repo="fixture-repo", + org_slug="fixture-org", + branch="feature", + ) params.include_license_details = True - with pytest.raises(APIFailure, match="upload failed"): - core.create_new_diff( - ["/repo/workspace"], - params, - explicit_files=["/repo/workspace/package.json"], - ) + with pytest.raises(APIFailure, match="local fixture failure"): + core.create_new_diff([str(tmp_path)], params, explicit_files=[str(manifest)]) + assert [request.path for request in socket_api.requests] == [ + "organizations", + "orgs/fixture-org/full-scans", + ] -def test_diff_api_failure_propagates_for_cli_exit_code_mapping(caplog): - core = _core() - core.get_diff_scan_artifacts = MagicMock(side_effect=RuntimeError("poll failed")) - core.sdk.fullscans.stream_diff.side_effect = APIFailure("comparison failed") + +def test_diff_fallback_uses_real_streaming_endpoint_and_logs_mode( + caplog, + socket_api: SocketApiFixture, +) -> None: + core = create_core(socket_api) + socket_api.route( + "POST", + "orgs/fixture-org/diff-scans/from-ids", + SocketResponse(403, {"error": {"message": "forbidden"}}), + ) + socket_api.route( + "GET", + "orgs/fixture-org/full-scans/diff", + SocketResponse( + 200, + { + "before": { + "repository_id": "repo-id", + "branch": "main", + "id": "baseline-scan", + "organization_id": "org-fixture", + "committers": [], + }, + "after": { + "repository_id": "repo-id", + "branch": "feature", + "id": "new-scan", + "organization_id": "org-fixture", + "committers": [], + }, + "diff_report_url": "https://socket.dev/fixture/diff", + "artifacts": { + "added": [], + "removed": [], + "unchanged": [], + "replaced": [], + "updated": [], + }, + }, + ), + ) with caplog.at_level("INFO", logger="socketdev"): - with pytest.raises(APIFailure, match="comparison failed"): - core.get_added_and_removed_packages("base-scan", "new-scan") + result = core._get_package_comparison( + "baseline-scan-id", + "new-scan-id", + include_license_details=True, + ) + assert result.diff_scan_id is None assert ( - "Diff comparison mode: requested=diff-scan effective=streaming " - "reason=RuntimeError" - ) in caplog.messages + "Diff comparison mode: requested=diff-scan effective=streaming reason=APIAccessDenied" + in caplog.messages + ) + stream_request = socket_api.requests[-1] + assert stream_request.query == { + "before": ["baseline-scan-id"], + "after": ["new-scan-id"], + "include_license_details": ["true"], + }