2026-08-21 23:15:48 +02:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import json
|
|
|
|
|
import subprocess
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
2026-08-22 23:26:45 +02:00
|
|
|
import httpx
|
2026-08-22 23:19:36 +02:00
|
|
|
import pytest
|
|
|
|
|
|
2026-08-22 20:22:45 +02:00
|
|
|
from repo_manager.cli import main
|
|
|
|
|
from repo_manager.sbom_client import (
|
2026-08-22 23:19:36 +02:00
|
|
|
SBOMContractError,
|
2026-08-22 23:26:45 +02:00
|
|
|
SBOMNexusClient,
|
|
|
|
|
SBOMNexusConfig,
|
|
|
|
|
SBOMServiceError,
|
2026-08-22 20:22:45 +02:00
|
|
|
licence_report_from_snapshot,
|
|
|
|
|
scan_repository_via_nexus,
|
2026-08-22 23:19:36 +02:00
|
|
|
validate_snapshot_contract,
|
2026-08-22 20:22:45 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _snapshot() -> dict:
|
|
|
|
|
return {
|
|
|
|
|
"schema": "sbom-nexus.snapshot.v1",
|
|
|
|
|
"ok": True,
|
|
|
|
|
"repo_slug": "example",
|
|
|
|
|
"source_revision": "abc123",
|
|
|
|
|
"generated_at": "2026-08-22T18:00:00Z",
|
|
|
|
|
"entry_count": 1,
|
|
|
|
|
"entries": [
|
2026-08-21 23:15:48 +02:00
|
|
|
{
|
2026-08-22 20:22:45 +02:00
|
|
|
"package_name": "pyyaml",
|
|
|
|
|
"package_version": "6.0.3",
|
|
|
|
|
"ecosystem": "python",
|
|
|
|
|
"license_spdx": "MIT",
|
|
|
|
|
"is_direct": True,
|
|
|
|
|
"is_dev": False,
|
|
|
|
|
"source_path": "uv.lock",
|
2026-08-21 23:15:48 +02:00
|
|
|
}
|
2026-08-22 20:22:45 +02:00
|
|
|
],
|
|
|
|
|
"sources": [{"path": "uv.lock", "entry_count": 1, "sha256": "abc"}],
|
|
|
|
|
"licence_report": {
|
|
|
|
|
"groups": [
|
|
|
|
|
{
|
|
|
|
|
"license_spdx": "MIT",
|
|
|
|
|
"count": 1,
|
|
|
|
|
"is_copyleft": False,
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
"copyleft_direct_prod": [],
|
|
|
|
|
"copyleft_direct_count": 0,
|
|
|
|
|
},
|
|
|
|
|
"errors": [],
|
2026-08-21 23:15:48 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-08-22 20:22:45 +02:00
|
|
|
def test_scan_delegates_to_sbom_nexus_without_shell(monkeypatch, tmp_path: Path) -> None:
|
|
|
|
|
observed = {}
|
|
|
|
|
|
|
|
|
|
def fake_run(command, **kwargs):
|
|
|
|
|
observed["command"] = command
|
|
|
|
|
observed["kwargs"] = kwargs
|
|
|
|
|
return subprocess.CompletedProcess(command, 0, json.dumps(_snapshot()), "")
|
|
|
|
|
|
|
|
|
|
monkeypatch.setenv("SBOM_NEXUS_CLI", "/opt/sbom-nexus/bin/sbom-nexus")
|
|
|
|
|
monkeypatch.setattr(subprocess, "run", fake_run)
|
2026-08-21 23:15:48 +02:00
|
|
|
|
2026-08-22 20:22:45 +02:00
|
|
|
result = scan_repository_via_nexus(tmp_path, slug="example")
|
2026-08-21 23:15:48 +02:00
|
|
|
|
2026-08-22 20:22:45 +02:00
|
|
|
assert observed["command"] == [
|
|
|
|
|
"/opt/sbom-nexus/bin/sbom-nexus",
|
|
|
|
|
"scan",
|
|
|
|
|
str(tmp_path),
|
|
|
|
|
"--slug",
|
|
|
|
|
"example",
|
2026-08-21 23:15:48 +02:00
|
|
|
]
|
2026-08-22 20:22:45 +02:00
|
|
|
assert observed["kwargs"] == {
|
|
|
|
|
"capture_output": True,
|
|
|
|
|
"text": True,
|
|
|
|
|
"check": False,
|
|
|
|
|
}
|
|
|
|
|
assert result["schema"] == "sbom-nexus.snapshot.v1"
|
|
|
|
|
assert result["product_owner"] == "sbom-nexus"
|
|
|
|
|
assert result["delegated_by"] == "repo-manager"
|
2026-08-22 23:19:36 +02:00
|
|
|
assert result["repo_manager_context"] == {
|
|
|
|
|
"mode": "local-preview",
|
|
|
|
|
"authoritative": False,
|
|
|
|
|
"persisted": False,
|
|
|
|
|
"advances_last_attempt_at": False,
|
|
|
|
|
"advances_last_success_at": False,
|
|
|
|
|
"creates_snapshot_history": False,
|
|
|
|
|
}
|
2026-08-21 23:15:48 +02:00
|
|
|
|
|
|
|
|
|
2026-08-22 20:22:45 +02:00
|
|
|
def test_missing_nexus_cli_returns_actionable_error(monkeypatch, tmp_path: Path) -> None:
|
|
|
|
|
monkeypatch.delenv("SBOM_NEXUS_CLI", raising=False)
|
|
|
|
|
monkeypatch.setattr("repo_manager.sbom_client.shutil.which", lambda _name: None)
|
2026-08-21 23:15:48 +02:00
|
|
|
|
2026-08-22 20:22:45 +02:00
|
|
|
result = scan_repository_via_nexus(tmp_path)
|
2026-08-21 23:15:48 +02:00
|
|
|
|
|
|
|
|
assert result["ok"] is False
|
2026-08-22 20:22:45 +02:00
|
|
|
assert result["schema"] == "sbom-nexus.snapshot.v1"
|
|
|
|
|
assert "SBOM_NEXUS_CLI" in result["errors"][0]["detail"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_licence_report_alias_preserves_shape() -> None:
|
|
|
|
|
result = licence_report_from_snapshot(_snapshot())
|
|
|
|
|
|
|
|
|
|
assert set(result) == {
|
|
|
|
|
"ok",
|
|
|
|
|
"repo_slug",
|
|
|
|
|
"source_revision",
|
|
|
|
|
"generated_at",
|
|
|
|
|
"entry_count",
|
|
|
|
|
"licence_report",
|
|
|
|
|
"errors",
|
2026-08-22 23:19:36 +02:00
|
|
|
"repo_manager_context",
|
2026-08-22 20:22:45 +02:00
|
|
|
"delegated_by",
|
|
|
|
|
"product_owner",
|
|
|
|
|
}
|
|
|
|
|
assert result["licence_report"]["copyleft_direct_count"] == 0
|
2026-08-22 23:19:36 +02:00
|
|
|
assert result["repo_manager_context"]["authoritative"] is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_snapshot_contract_allows_additive_fields_and_rejects_unknown_schema() -> None:
|
|
|
|
|
validate_snapshot_contract({**_snapshot(), "future_addition": {"accepted": True}})
|
|
|
|
|
|
|
|
|
|
with pytest.raises(SBOMContractError, match="unsupported SBOM Nexus schema"):
|
|
|
|
|
validate_snapshot_contract({**_snapshot(), "schema": "sbom-nexus.snapshot.v2"})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_scan_turns_unknown_schema_into_deterministic_contract_error(
|
|
|
|
|
monkeypatch, tmp_path: Path
|
|
|
|
|
) -> None:
|
|
|
|
|
monkeypatch.setenv("SBOM_NEXUS_CLI", "/opt/sbom-nexus/bin/sbom-nexus")
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
subprocess,
|
|
|
|
|
"run",
|
|
|
|
|
lambda command, **kwargs: subprocess.CompletedProcess(
|
|
|
|
|
command,
|
|
|
|
|
0,
|
|
|
|
|
json.dumps({**_snapshot(), "schema": "future.snapshot.v9"}),
|
|
|
|
|
"",
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
result = scan_repository_via_nexus(tmp_path)
|
|
|
|
|
|
|
|
|
|
assert result["ok"] is False
|
|
|
|
|
assert result["errors"][0]["reason"] == "sbom-nexus-contract"
|
|
|
|
|
assert result["repo_manager_context"]["persisted"] is False
|
2026-08-22 20:22:45 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_cli_scan_preserves_output_file_behavior(monkeypatch, tmp_path: Path, capsys) -> None:
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"repo_manager.sbom_client.scan_repository_via_nexus",
|
|
|
|
|
lambda path, slug=None: {
|
|
|
|
|
**_snapshot(),
|
|
|
|
|
"delegated_by": "repo-manager",
|
|
|
|
|
"product_owner": "sbom-nexus",
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
output = tmp_path / "snapshot.json"
|
|
|
|
|
|
|
|
|
|
exit_code = main(
|
|
|
|
|
[
|
|
|
|
|
"sbom",
|
|
|
|
|
"scan",
|
|
|
|
|
"--path",
|
|
|
|
|
str(tmp_path),
|
|
|
|
|
"--slug",
|
|
|
|
|
"example",
|
|
|
|
|
"--output",
|
|
|
|
|
str(output),
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert exit_code == 0
|
|
|
|
|
assert json.loads(output.read_text())["schema"] == "sbom-nexus.snapshot.v1"
|
|
|
|
|
assert json.loads(capsys.readouterr().out)["product_owner"] == "sbom-nexus"
|
2026-08-22 23:26:45 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_service_config_is_explicit_bounded_and_redacts_token(monkeypatch) -> None:
|
|
|
|
|
monkeypatch.setenv("SBOM_NEXUS_URL", "https://nexus.example.test/")
|
|
|
|
|
monkeypatch.setenv("SBOM_NEXUS_TIMEOUT_SECONDS", "12.5")
|
|
|
|
|
monkeypatch.setenv("SBOM_NEXUS_TOKEN", "do-not-print-this")
|
|
|
|
|
|
|
|
|
|
config = SBOMNexusConfig.from_environment()
|
|
|
|
|
|
|
|
|
|
assert config.base_url == "https://nexus.example.test"
|
|
|
|
|
assert config.timeout_seconds == 12.5
|
|
|
|
|
assert "do-not-print-this" not in repr(config)
|
|
|
|
|
with pytest.raises(ValueError, match="must not contain credentials"):
|
|
|
|
|
SBOMNexusConfig("https://user:secret@nexus.example.test")
|
|
|
|
|
with pytest.raises(ValueError, match="between 0.1 and 300"):
|
|
|
|
|
SBOMNexusConfig("https://nexus.example.test", timeout_seconds=301)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_authoritative_client_calls_pinned_routes_and_marks_service_context() -> None:
|
|
|
|
|
calls = []
|
|
|
|
|
|
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
|
|
|
calls.append(request)
|
|
|
|
|
if request.url.path.startswith("/repositories/"):
|
|
|
|
|
return httpx.Response(200, json={"slug": "demo", "active": True})
|
|
|
|
|
if request.method == "POST":
|
|
|
|
|
return httpx.Response(
|
|
|
|
|
200,
|
|
|
|
|
json={
|
|
|
|
|
"repo_slug": "demo",
|
|
|
|
|
"snapshot_id": "snapshot-1",
|
|
|
|
|
"status": "ingested",
|
|
|
|
|
"entry_count": 2,
|
|
|
|
|
"snapshot_at": "2026-08-22T20:00:00Z",
|
|
|
|
|
"source_revision": "abc123",
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
if request.url.path == "/sbom/report/licences/":
|
|
|
|
|
return httpx.Response(
|
|
|
|
|
200,
|
|
|
|
|
json={
|
|
|
|
|
"groups": [],
|
|
|
|
|
"copyleft_direct_prod": [],
|
|
|
|
|
"copyleft_direct_count": 0,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
return httpx.Response(
|
|
|
|
|
200,
|
|
|
|
|
json={
|
|
|
|
|
"repo_slug": "demo",
|
|
|
|
|
"last_attempt_at": "2026-08-22T20:00:00Z",
|
|
|
|
|
"last_success_at": "2026-08-22T20:00:00Z",
|
|
|
|
|
"last_status": "ingested",
|
|
|
|
|
"entry_count": 2,
|
|
|
|
|
"snapshot_id": "snapshot-1",
|
|
|
|
|
"entries": [],
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
client = SBOMNexusClient(
|
|
|
|
|
SBOMNexusConfig("https://nexus.example.test", bearer_token="runtime-secret"),
|
|
|
|
|
transport=httpx.MockTransport(handler),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
projected = client.upsert_repository(
|
|
|
|
|
"demo",
|
|
|
|
|
nexus_checkout_path="/srv/controlled/demo/abc123",
|
|
|
|
|
)
|
|
|
|
|
ingested = client.ingest_repository(
|
|
|
|
|
"demo",
|
|
|
|
|
expected_source_revision="abc123",
|
|
|
|
|
operation_id="operation-1",
|
|
|
|
|
)
|
|
|
|
|
latest = client.latest_snapshot("demo")
|
|
|
|
|
report = client.licence_report()
|
|
|
|
|
|
|
|
|
|
assert [request.url.path for request in calls] == [
|
|
|
|
|
"/repositories/demo",
|
|
|
|
|
"/sbom/demo/ingest",
|
|
|
|
|
"/sbom/demo",
|
|
|
|
|
"/sbom/report/licences/",
|
|
|
|
|
]
|
|
|
|
|
assert all(request.headers["Authorization"] == "Bearer runtime-secret" for request in calls)
|
|
|
|
|
assert calls[1].headers["Idempotency-Key"] == "operation-1"
|
|
|
|
|
assert projected["repo_manager_context"]["writes_state"] is True
|
|
|
|
|
assert ingested["repo_manager_context"]["mode"] == "authoritative-service"
|
|
|
|
|
assert latest["repo_manager_context"]["authoritative"] is True
|
|
|
|
|
assert report["repo_manager_context"]["operation"] == "licence-report"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_authoritative_client_fails_closed_on_source_revision_mismatch() -> None:
|
|
|
|
|
transport = httpx.MockTransport(
|
|
|
|
|
lambda request: httpx.Response(
|
|
|
|
|
200,
|
|
|
|
|
json={
|
|
|
|
|
"repo_slug": "demo",
|
|
|
|
|
"snapshot_id": "snapshot-1",
|
|
|
|
|
"status": "ingested",
|
|
|
|
|
"entry_count": 1,
|
|
|
|
|
"snapshot_at": "2026-08-22T20:00:00Z",
|
|
|
|
|
"source_revision": "different",
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
client = SBOMNexusClient(SBOMNexusConfig("https://nexus.example.test"), transport=transport)
|
|
|
|
|
|
|
|
|
|
with pytest.raises(SBOMServiceError) as raised:
|
|
|
|
|
client.ingest_repository("demo", expected_source_revision="expected")
|
|
|
|
|
|
|
|
|
|
assert raised.value.code == "source_revision_mismatch"
|
|
|
|
|
assert raised.value.mutation_may_have_committed is True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_authoritative_client_has_sanitized_timeout_and_http_failures() -> None:
|
|
|
|
|
secret = "never-echo-this-token"
|
|
|
|
|
|
|
|
|
|
def timeout_handler(request: httpx.Request) -> httpx.Response:
|
|
|
|
|
raise httpx.ReadTimeout("upstream timed out", request=request)
|
|
|
|
|
|
|
|
|
|
timeout_client = SBOMNexusClient(
|
|
|
|
|
SBOMNexusConfig("https://nexus.example.test", bearer_token=secret),
|
|
|
|
|
transport=httpx.MockTransport(timeout_handler),
|
|
|
|
|
)
|
|
|
|
|
with pytest.raises(SBOMServiceError) as timeout:
|
|
|
|
|
timeout_client.latest_snapshot("demo")
|
|
|
|
|
assert timeout.value.to_dict()["code"] == "timeout"
|
|
|
|
|
assert secret not in str(timeout.value)
|
|
|
|
|
assert secret not in json.dumps(timeout.value.to_dict())
|
|
|
|
|
|
|
|
|
|
http_client = SBOMNexusClient(
|
|
|
|
|
SBOMNexusConfig("https://nexus.example.test", bearer_token=secret),
|
|
|
|
|
transport=httpx.MockTransport(lambda request: httpx.Response(503, json={"detail": secret})),
|
|
|
|
|
)
|
|
|
|
|
with pytest.raises(SBOMServiceError) as failed:
|
|
|
|
|
http_client.ingest_repository("demo")
|
|
|
|
|
assert failed.value.status_code == 503
|
|
|
|
|
assert failed.value.mutation_may_have_committed is True
|
|
|
|
|
assert secret not in str(failed.value)
|
|
|
|
|
assert secret not in json.dumps(failed.value.to_dict())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_authoritative_client_rejects_malformed_success_without_exposing_body() -> None:
|
|
|
|
|
client = SBOMNexusClient(
|
|
|
|
|
SBOMNexusConfig("https://nexus.example.test"),
|
|
|
|
|
transport=httpx.MockTransport(
|
|
|
|
|
lambda request: httpx.Response(200, json={"unexpected": "sensitive body"})
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
with pytest.raises(SBOMServiceError) as failed:
|
|
|
|
|
client.latest_snapshot("demo")
|
|
|
|
|
|
|
|
|
|
assert failed.value.code == "contract_error"
|
|
|
|
|
assert "sensitive body" not in str(failed.value)
|