Fix repo_sbom_status resolver — close ADHOC-2026-06-01-T01

The state-hub resolver was calling GET /sbom/status?repo={slug}, which State
Hub does not expose. Real SBOM routes are /sbom/, /sbom/{slug},
/sbom/snapshots/, /sbom/snapshots/{id}, /sbom/ingest/, /sbom/report/licences/.
The weekly-sbom-staleness ActivityDefinition was passing params {repos: all}
and the resolver was reading params.get("repo_slug", ""), so the URL
collapsed to /sbom/status?repo= and 404'd. _fetch_json swallowed the error,
the rule context.repos.sbom_age_days > 30 evaluated against {} and never
matched, and the weekly SBOM check has been a silent no-op for as long as
the route mismatch has existed.

Resolver now supports two modes selected by params:
- single-repo: {repo_slug: foo} → GET /sbom/{foo}, returns
  {repo_slug, last_sbom_at, sbom_age_days, has_sbom}
- bulk: {repos: all} → GET /repos/, computes per-repo age, returns the
  worst repo's fields hoisted to the top of the result alongside
  stale_count, total_count, worst_* fields, and the full per-repo list

Never-scanned repos get a 99999 sentinel age so threshold rules treat
them as very stale without forcing the rule to special-case None.

Hoisting the worst entry to the top preserves the existing rule
expression context.repos.sbom_age_days > 30 (and target_repo:
context.repos.repo_slug, though that field is a separate interpolation
gap tracked as ADHOC-2026-06-01-T02). The integration tests'
aspirational per-repo iteration model is left intact.

Live validation against State Hub on 2026-06-01:
- single: activity-core → 36 days since 2026-04-26 ingest
- bulk: 48 repos total, 46 stale (>30d), worst is info-tech-canon (never
  scanned), rule expression evaluates True

Tests: 120 passed, 1 skipped.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 03:31:56 +02:00
parent 5d3fb33c6b
commit a8d3cc2782
4 changed files with 270 additions and 16 deletions

View File

@@ -82,27 +82,79 @@ def test_existing_queries_still_resolve(monkeypatch) -> None:
def fake_get(url: str, **kwargs: Any) -> DummyResponse:
calls.append({"url": url, **kwargs})
return DummyResponse({"ok": True})
if url.endswith("/state/domain/custodian"):
return DummyResponse({"ok": True})
if url.endswith("/sbom/activity-core"):
return DummyResponse({
"repo_slug": "activity-core",
"last_sbom_at": "2026-04-26T11:37:56+00:00",
"entry_count": 38,
"entries": [],
})
raise AssertionError(f"unexpected url {url}")
monkeypatch.setenv("STATE_HUB_URL", "http://state-hub.test")
monkeypatch.setattr(httpx, "get", fake_get)
resolver = StateHubContextResolver()
assert resolver.resolve("domain_summary", None, {"domain": "custodian"}) == {"ok": True}
assert resolver.resolve("repo_sbom_status", None, {"repo_slug": "activity-core"}) == {"ok": True}
sbom = resolver.resolve("repo_sbom_status", None, {"repo_slug": "activity-core"})
assert sbom["repo_slug"] == "activity-core"
assert sbom["has_sbom"] is True
assert sbom["last_sbom_at"] == "2026-04-26T11:37:56+00:00"
assert isinstance(sbom["sbom_age_days"], int) and sbom["sbom_age_days"] >= 0
assert [c["url"] for c in calls] == [
"http://state-hub.test/state/domain/custodian",
"http://state-hub.test/sbom/activity-core",
]
def test_repo_sbom_status_bulk_returns_worst_repo(monkeypatch) -> None:
calls: list[dict[str, Any]] = []
def fake_get(url: str, **kwargs: Any) -> DummyResponse:
calls.append({"url": url, **kwargs})
return DummyResponse([
{"slug": "fresh-repo", "last_sbom_at": "2099-01-01T00:00:00+00:00"},
{"slug": "stale-repo", "last_sbom_at": "2024-01-01T00:00:00+00:00"},
{"slug": "never-scanned", "last_sbom_at": None},
])
monkeypatch.setenv("STATE_HUB_URL", "http://state-hub.test")
monkeypatch.setattr(httpx, "get", fake_get)
result = StateHubContextResolver().resolve(
"repo_sbom_status", None, {"repos": "all"}
)
assert calls == [
{
"url": "http://state-hub.test/state/domain/custodian",
"params": None,
"timeout": 10.0,
},
{
"url": "http://state-hub.test/sbom/status",
"params": {"repo": "activity-core"},
"timeout": 10.0,
},
{"url": "http://state-hub.test/repos/", "params": None, "timeout": 10.0},
]
assert result["total_count"] == 3
# both stale-repo and never-scanned exceed the 30-day staleness threshold
assert result["stale_count"] == 2
assert result["worst_repo_slug"] == "never-scanned"
assert result["worst_age_days"] == 99999
by_slug = {entry["repo_slug"]: entry for entry in result["repos"]}
assert by_slug["fresh-repo"]["has_sbom"] is True
assert by_slug["fresh-repo"]["sbom_age_days"] == 0
assert by_slug["never-scanned"]["has_sbom"] is False
assert by_slug["never-scanned"]["last_sbom_at"] is None
def test_repo_sbom_status_returns_empty_on_failure(monkeypatch) -> None:
def fake_get(url: str, **kwargs: Any) -> DummyResponse:
return DummyResponse(None, status_error=httpx.HTTPError("boom"))
monkeypatch.setenv("STATE_HUB_URL", "http://state-hub.test")
monkeypatch.setattr(httpx, "get", fake_get)
resolver = StateHubContextResolver()
assert resolver.resolve("repo_sbom_status", None, {"repo_slug": "x"}) == {}
assert resolver.resolve("repo_sbom_status", None, {"repos": "all"}) == {}
def test_resolver_failure_returns_empty(monkeypatch) -> None: