diff --git a/pyproject.toml b/pyproject.toml index 36d7e47..0b5c1ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.10.11" +version = "2.10.12" requires-python = ">= 3.11" license = {"file" = "LICENSE"} dependencies = [ diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index a057239..025b61a 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,3 +1,3 @@ __author__ = 'socket.dev' -__version__ = '2.10.11' +__version__ = '2.10.12' USER_AGENT = f'SocketPythonCLI/{__version__}' 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/socketsecurity/core/report_output.py b/socketsecurity/core/report_output.py new file mode 100644 index 0000000..5095865 --- /dev/null +++ b/socketsecurity/core/report_output.py @@ -0,0 +1,140 @@ +from dataclasses import dataclass +from typing import Optional + +from socketsecurity.config import CliConfig +from socketsecurity.core.alert_selection import ( + clone_diff_with_selected_alerts, + select_diff_alerts, +) +from socketsecurity.core.classes import Diff +from socketsecurity.core.messages import Messages + + +@dataclass(frozen=True) +class ReportReferences: + full_scan_id: str + report_url: Optional[str] + diff_url: Optional[str] + is_full_scan: bool + + def console_link(self) -> Optional[tuple[str, str]]: + if self.is_full_scan: + if self.report_url: + return "Report Url", self.report_url + if self.diff_url: + return "Report Url", self.diff_url + else: + if self.diff_url: + return "Diff Url", self.diff_url + if self.report_url: + return "Report Url", self.report_url + return None + + +def build_report_references(diff_report: Diff) -> ReportReferences: + return ReportReferences( + full_scan_id=getattr(diff_report, "id", ""), + report_url=getattr(diff_report, "report_url", None), + diff_url=getattr(diff_report, "diff_url", ""), + is_full_scan=getattr(diff_report, "is_full_scan", False), + ) + + +def build_console_summary(diff_report: Diff, config: CliConfig) -> str: + references = build_report_references(diff_report) + is_full_scan = references.is_full_scan + link = references.console_link() + + if is_full_scan and not getattr(diff_report, "alerts_fetched", False): + lines = ["Full scan completed. Findings were not fetched for console output."] + if link: + lines.append(f"{link[0]}: {link[1]}") + return "\n".join(lines) + + selected_alerts = select_diff_alerts( + diff_report, + strict_blocking=config.strict_blocking, + ) + has_new_alerts = len(selected_alerts) > 0 + has_unchanged_alerts = ( + config.strict_blocking + and hasattr(diff_report, "unchanged_alerts") + and len(diff_report.unchanged_alerts) > 0 + ) + + if not has_new_alerts and not has_unchanged_alerts: + if link: + return f"No issues found\n{link[0]}: {link[1]}" + return "No issues found" + + new_blocking = sum(1 for issue in diff_report.new_alerts if issue.error) + new_warning = sum(1 for issue in diff_report.new_alerts if issue.warn) + + unchanged_blocking = 0 + unchanged_warning = 0 + if has_unchanged_alerts: + unchanged_blocking = sum( + 1 for issue in diff_report.unchanged_alerts if issue.error + ) + unchanged_warning = sum( + 1 for issue in diff_report.unchanged_alerts if issue.warn + ) + + selected_diff = clone_diff_with_selected_alerts(diff_report, selected_alerts) + console_security_comment = Messages.create_console_security_alert_table( + selected_diff + ) + + blocking_label = "Blocking issues" if is_full_scan else "NEW blocking issues" + warning_label = "Warning issues" if is_full_scan else "NEW warning issues" + + lines = ["Security issues detected by Socket Security:"] + if new_blocking > 0: + lines.append(f" - {blocking_label}: {new_blocking}") + if new_warning > 0: + lines.append(f" - {warning_label}: {new_warning}") + if unchanged_blocking > 0: + lines.append( + f" - EXISTING blocking issues: {unchanged_blocking} " + "(causing failure due to --strict-blocking)" + ) + if unchanged_warning > 0: + lines.append(f" - EXISTING warning issues: {unchanged_warning}") + if is_full_scan: + lines.append( + " Reported against the whole repository, with no baseline to compare " + "against, so these do not affect the exit code." + ) + + if link: + lines.append(f"{link[0]}: {link[1]}") + lines.append("") + lines.append(str(console_security_comment)) + return "\n".join(lines) + + +def build_socket_json_report( + diff_report: Diff, + config: CliConfig, +) -> dict[str, object]: + references = build_report_references(diff_report) + selected_alerts = select_diff_alerts( + diff_report, + strict_blocking=config.strict_blocking, + ) + selected_diff = clone_diff_with_selected_alerts(diff_report, selected_alerts) + report = Messages.create_security_comment_json(selected_diff) + legal_flag = getattr(config, "legal", False) + repo = getattr(config, "repo", None) + branch = getattr(config, "branch", None) + commit_sha = getattr(config, "commit_sha", None) + report["full_scan_id"] = references.full_scan_id + report["diff_url"] = references.diff_url + report["report_url"] = references.report_url + report["repo"] = repo if isinstance(repo, str) or repo is None else None + report["branch"] = branch if isinstance(branch, str) or branch is None else None + report["commit_sha"] = ( + commit_sha if isinstance(commit_sha, str) or commit_sha is None else None + ) + report["legal_mode"] = legal_flag if isinstance(legal_flag, bool) else False + return report diff --git a/socketsecurity/output.py b/socketsecurity/output.py index 566e4d3..3e88f89 100644 --- a/socketsecurity/output.py +++ b/socketsecurity/output.py @@ -16,6 +16,7 @@ from .config import CliConfig from .core.classes import Diff, Issue from .core.messages import Messages +from .core.report_output import build_console_summary, build_socket_json_report from .fossa_compat import build_fossa_report_payload @@ -229,88 +230,13 @@ def save_sbom_file(self, diff_report: Diff, sbom_file_name: Optional[str] = None def build_summary_text(self, diff_report: Diff) -> str: """Render the console summary text for stdout and file output.""" - is_full_scan = getattr(diff_report, "is_full_scan", False) - if is_full_scan and not getattr(diff_report, "alerts_fetched", False): - lines = ["Full scan completed. Findings were not fetched for console output."] - report_link = getattr(diff_report, "report_url", "") or getattr( - diff_report, "diff_url", "" - ) - if report_link: - lines.append(f"Report Url: {report_link}") - return "\n".join(lines) - - selected_alerts = select_diff_alerts(diff_report, strict_blocking=self.config.strict_blocking) - has_new_alerts = len(selected_alerts) > 0 - has_unchanged_alerts = ( - self.config.strict_blocking and - hasattr(diff_report, 'unchanged_alerts') and - len(diff_report.unchanged_alerts) > 0 - ) - - if not has_new_alerts and not has_unchanged_alerts: - return "No issues found" - - new_blocking = sum(1 for issue in diff_report.new_alerts if issue.error) - new_warning = sum(1 for issue in diff_report.new_alerts if issue.warn) - - unchanged_blocking = 0 - unchanged_warning = 0 - if has_unchanged_alerts: - unchanged_blocking = sum(1 for issue in diff_report.unchanged_alerts if issue.error) - unchanged_warning = sum(1 for issue in diff_report.unchanged_alerts if issue.warn) - - selected_diff = clone_diff_with_selected_alerts(diff_report, selected_alerts) - console_security_comment = Messages.create_console_security_alert_table(selected_diff) - - # A full scan has no baseline, so everything it carries is a finding in the - # repository rather than one the change introduced. Calling those NEW would - # also contradict the exit code, which stays 0 because no finding can be - # attributed to the change. - blocking_label = "Blocking issues" if is_full_scan else "NEW blocking issues" - warning_label = "Warning issues" if is_full_scan else "NEW warning issues" - link_label = "Report Url" if is_full_scan else "Diff Url" - - lines = ["Security issues detected by Socket Security:"] - if new_blocking > 0: - lines.append(f" - {blocking_label}: {new_blocking}") - if new_warning > 0: - lines.append(f" - {warning_label}: {new_warning}") - if unchanged_blocking > 0: - lines.append( - f" - EXISTING blocking issues: {unchanged_blocking} (causing failure due to --strict-blocking)" - ) - if unchanged_warning > 0: - lines.append(f" - EXISTING warning issues: {unchanged_warning}") - if is_full_scan: - lines.append( - " Reported against the whole repository, with no baseline to compare " - "against, so these do not affect the exit code." - ) - - report_link = getattr(diff_report, "report_url", "") or getattr(diff_report, "diff_url", "") - lines.append(f"{link_label}: {report_link}") - lines.append("") - lines.append(str(console_security_comment)) - return "\n".join(lines) + return build_console_summary(diff_report, self.config) def build_json_report(self, diff_report: Diff) -> dict: """Build the JSON report payload for stdout and file output.""" if getattr(self.config, "legal_format", "socket") == "fossa": return build_fossa_report_payload(diff_report, self.config) - - selected_alerts = select_diff_alerts(diff_report, strict_blocking=self.config.strict_blocking) - selected_diff = clone_diff_with_selected_alerts(diff_report, selected_alerts) - report = Messages.create_security_comment_json(selected_diff) - legal_flag = getattr(self.config, "legal", False) - repo = getattr(self.config, "repo", None) - branch = getattr(self.config, "branch", None) - commit_sha = getattr(self.config, "commit_sha", None) - report["report_url"] = getattr(diff_report, "report_url", None) - report["repo"] = repo if isinstance(repo, str) or repo is None else None - report["branch"] = branch if isinstance(branch, str) or branch is None else None - report["commit_sha"] = commit_sha if isinstance(commit_sha, str) or commit_sha is None else None - report["legal_mode"] = legal_flag if isinstance(legal_flag, bool) else False - return report + return build_socket_json_report(diff_report, self.config) def save_json_file(self, diff_report: Diff, json_file_name: Optional[str] = None) -> None: if not json_file_name: 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_report_output.py b/tests/unit/test_report_output.py new file mode 100644 index 0000000..6e3db22 --- /dev/null +++ b/tests/unit/test_report_output.py @@ -0,0 +1,271 @@ +import copy +import json + +from socketsecurity.config import CliConfig +from socketsecurity.core.classes import Diff, Issue +from socketsecurity.core.report_output import ( + build_console_summary, + build_report_references, + build_socket_json_report, +) + + +def _config(*, strict_blocking: bool = False) -> CliConfig: + return CliConfig( + api_token="", + repo="fictional-repo", + strict_blocking=strict_blocking, + ) + + +def _issue( + title: str, + *, + error: bool = False, + warn: bool = False, +) -> Issue: + return Issue( + pkg_type="pypi", + pkg_name="fictional-package", + pkg_version="1.2.3", + category="supply-chain", + type=title, + severity="high" if error else "medium", + pkg_id="pypi/fictional-package@1.2.3", + props={}, + key=title, + error=error, + warn=warn, + ignore=False, + monitor=False, + description=f"Description for {title}", + title=title, + emoji="!", + next_step_title="Review the package", + suggestion="Use a safe version", + introduced_by=[("fictional-root", "requirements.txt")], + manifests="requirements.txt", + url="https://socket.dev/pypi/package/fictional-package/alerts/1.2.3", + purl="pkg:pypi/fictional-package@1.2.3", + ) + + +def _diff( + *, + new_alerts: list[Issue] | None = None, + unchanged_alerts: list[Issue] | None = None, + report_url: str | None = "https://socket.dev/orgs/fictional/reports/full-123", + diff_url: str | None = "https://socket.dev/orgs/fictional/diffs/diff-456", + is_full_scan: bool = False, + alerts_fetched: bool = True, +) -> Diff: + return Diff( + id="full-123", + new_scan_id="full-123", + report_url=report_url, + diff_url=diff_url, + is_full_scan=is_full_scan, + alerts_fetched=alerts_fetched, + new_alerts=list(new_alerts or []), + unchanged_alerts=list(unchanged_alerts or []), + ) + + +def test_comparison_prefers_diff_url_and_json_preserves_both_references(): + diff = _diff( + new_alerts=[_issue("New warning", warn=True)], + report_url="https://reports.example.test/full?scan=one", + diff_url="https://compare.example.test/pairs/a%2Fb?view=full", + ) + original = copy.deepcopy(diff.__dict__) + + summary = build_console_summary(diff, _config()) + report = build_socket_json_report(diff, _config()) + + assert "Diff Url: https://compare.example.test/pairs/a%2Fb?view=full" in summary + assert "Report Url:" not in summary + assert report["full_scan_id"] == "full-123" + assert report["diff_url"] == "https://compare.example.test/pairs/a%2Fb?view=full" + assert report["report_url"] == "https://reports.example.test/full?scan=one" + assert diff.__dict__ == original + + +def test_clean_comparison_keeps_message_first_and_includes_diff_link(): + diff = _diff(new_alerts=[]) + + summary = build_console_summary(diff, _config()) + report = build_socket_json_report(diff, _config()) + + assert summary.splitlines()[0] == "No issues found" + assert "Diff Url: https://socket.dev/orgs/fictional/diffs/diff-456" in summary + assert report["new_alerts"] == [] + assert report["scan_failed"] is False + + +def test_warning_and_blocking_summary_counts_and_json_failure_are_preserved(): + diff = _diff( + new_alerts=[ + _issue("Blocking alert", error=True), + _issue("Warning alert", warn=True), + ] + ) + + summary = build_console_summary(diff, _config()) + report = build_socket_json_report(diff, _config()) + decoded_alerts = [json.loads(json.dumps(alert)) for alert in report["new_alerts"]] + + assert "NEW blocking issues: 1" in summary + assert "NEW warning issues: 1" in summary + assert "Blocking alert" in summary + assert "Warning alert" in summary + assert report["scan_failed"] is True + assert [alert["title"] for alert in decoded_alerts] == [ + "Blocking alert", + "Warning alert", + ] + + +def test_strict_blocking_adds_unchanged_alerts_without_changing_references(): + unchanged = _issue("Existing blocking alert", error=True) + diff = _diff(new_alerts=[], unchanged_alerts=[unchanged]) + + ordinary = build_console_summary(diff, _config()) + strict = build_console_summary(diff, _config(strict_blocking=True)) + ordinary_json = build_socket_json_report(diff, _config()) + strict_json = build_socket_json_report(diff, _config(strict_blocking=True)) + + assert "Existing blocking alert" not in ordinary + assert "Existing blocking alert" in strict + assert ordinary_json["new_alerts"] == [] + assert [alert["title"] for alert in strict_json["new_alerts"]] == [ + "Existing blocking alert" + ] + assert ordinary_json["diff_url"] == strict_json["diff_url"] + assert ordinary_json["report_url"] == strict_json["report_url"] + + +def test_full_scan_uses_report_url_and_non_new_finding_labels(): + diff = _diff( + new_alerts=[_issue("Full scan warning", warn=True)], + is_full_scan=True, + ) + + summary = build_console_summary(diff, _config()) + + assert "Blocking issues:" not in summary + assert "Warning issues: 1" in summary + assert "NEW warning issues" not in summary + assert "whole repository, with no baseline" in summary + assert "Report Url: https://socket.dev/orgs/fictional/reports/full-123" in summary + assert "Diff Url:" not in summary + + +def test_clean_full_scan_uses_diff_url_as_report_url_fallback(): + diff = _diff( + new_alerts=[], + report_url="", + diff_url="https://reports.example.test/full-scan-fallback", + is_full_scan=True, + ) + + summary = build_console_summary(diff, _config()) + + assert summary.splitlines()[0] == "No issues found" + assert "Report Url: https://reports.example.test/full-scan-fallback" in summary + assert "Diff Url:" not in summary + + +def test_full_scan_without_fetched_findings_retains_explicit_state_and_report_link(): + diff = _diff(is_full_scan=True, alerts_fetched=False) + + summary = build_console_summary(diff, _config()) + + assert summary.startswith( + "Full scan completed. Findings were not fetched for console output." + ) + assert "No issues found" not in summary + assert "Report Url: https://socket.dev/orgs/fictional/reports/full-123" in summary + + +def test_comparison_report_url_fallback_and_missing_links(): + with_report = _diff( + new_alerts=[_issue("Warning", warn=True)], + diff_url="", + report_url="https://reports.example.test/alternate", + ) + without_urls = _diff( + new_alerts=[_issue("Warning", warn=True)], + diff_url="", + report_url="", + ) + + report_fallback_summary = build_console_summary(with_report, _config()) + no_link_summary = build_console_summary(without_urls, _config()) + + assert ( + "Report Url: https://reports.example.test/alternate" in report_fallback_summary + ) + assert "Diff Url:" not in report_fallback_summary + assert "Report Url:" not in no_link_summary + assert "Diff Url:" not in no_link_summary + assert "None" not in no_link_summary + assert "undefined" not in no_link_summary + + +def test_console_link_preserves_alternate_server_url_exactly(): + custom_url = "https://reports.example.test:9443/tenant/a%2Fb?view=x%2Fy&lang=en" + diff = _diff(report_url=custom_url, diff_url="") + + summary = build_console_summary(diff, _config()) + references = build_report_references(diff) + + assert summary.endswith(f"Report Url: {custom_url}") + assert references.console_link() == ("Report Url", custom_url) + + +def test_default_socket_json_key_set_and_metadata_remain_unchanged(): + diff = _diff(new_alerts=[]) + config = CliConfig( + api_token="", + repo="fictional-repo", + branch="feature/example", + commit_sha="abc123", + legal=True, + ) + + report = build_socket_json_report(diff, config) + + assert set(report) == { + "scan_failed", + "new_alerts", + "full_scan_id", + "diff_url", + "report_url", + "repo", + "branch", + "commit_sha", + "legal_mode", + } + assert report == { + "scan_failed": False, + "new_alerts": [], + "full_scan_id": "full-123", + "diff_url": "https://socket.dev/orgs/fictional/diffs/diff-456", + "report_url": "https://socket.dev/orgs/fictional/reports/full-123", + "repo": "fictional-repo", + "branch": "feature/example", + "commit_sha": "abc123", + "legal_mode": True, + } + + +def test_missing_reference_fields_keep_existing_json_defaults(): + diff = Diff(new_alerts=[]) + + summary = build_console_summary(diff, _config()) + report = build_socket_json_report(diff, _config()) + + assert summary == "No issues found" + assert report["full_scan_id"] == "" + assert report["diff_url"] == "" + assert report["report_url"] is None 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"], + } diff --git a/uv.lock b/uv.lock index 6df942c..7aeecc9 100644 --- a/uv.lock +++ b/uv.lock @@ -1292,7 +1292,7 @@ wheels = [ [[package]] name = "socketsecurity" -version = "2.10.11" +version = "2.10.12" source = { editable = "." } dependencies = [ { name = "beautifulsoup4" },