reuse-surface/tests/test_hub.py
tegwick 6cbc862371
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
ci / validate-registry (push) Successful in 1m18s
Build and Publish Container Image / build-and-push (push) Successful in 23s
Invalidate the composed index when a registration changes (REUSE-WP-0020-T09)
Enabling a federation source left /v1/federated serving its cached compose and
reporting stale: false while doing it, so a repo could be correctly registered
and silently invisible for as long as its cached index survived. That is how
evidence-binder stayed missing after re-enabling until a manual
POST /v1/federated/compose was issued.

Registration writes now mark the composed index stale, and a plain GET
recomposes when the flag is set. Clearing it there is not a silent clear: that
pass really did refetch. A PATCH touching only a description does not
invalidate anything.

This changes a contract documented in specs/FederationHubAPI.md, so the
staleness section is rewritten rather than left to drift, including the two
triggers that now set the flag.

The first two tests written for this were worthless -- they passed with the fix
removed, because a newly registered repo has no cache entry and gets fetched
regardless. The real failure needs a populated cache holding stale content
inside its 24h TTL. test_re_enabled_source_refetches_a_stale_cache models that
and fails on pre-fix code; verified by reverting the mark_stale calls.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 13:51:58 +02:00

550 lines
17 KiB
Python

from __future__ import annotations
import json
import os
from pathlib import Path
from unittest.mock import patch
import pytest
from fastapi.testclient import TestClient
from reuse_surface.hub.app import create_app
from reuse_surface.hub.store import HubStore
REMOTE_INDEX = """
version: 1
domain: helix_forge
updated: "2026-06-15"
capabilities:
- id: capability.remote.sample
name: Remote Sample
domain: helix_forge
vector: D2/A0/C0/R0
owner: example
path: registry/capabilities/capability.remote.sample.md
summary: Sample capability from a remote index
tags: [sample]
consumption_modes: [planning]
"""
@pytest.fixture
def hub_client(tmp_path, monkeypatch):
db_path = tmp_path / "hub.db"
cache_dir = tmp_path / "cache"
monkeypatch.setenv("REUSE_SURFACE_TOKEN", "test-token")
monkeypatch.setenv("REUSE_SURFACE_DB", str(db_path))
monkeypatch.setenv("REUSE_SURFACE_CACHE_DIR", str(cache_dir))
app = create_app()
with TestClient(app) as client:
yield client
def test_health(hub_client):
response = hub_client.get("/health")
assert response.status_code == 200
assert response.json()["status"] == "ok"
def test_register_requires_auth(hub_client):
response = hub_client.post(
"/v1/repos",
json={
"repo": "demo",
"url": "https://example.com/capabilities.yaml",
"domain": "helix_forge",
},
)
assert response.status_code == 401
def test_register_and_list(hub_client):
payload = {
"repo": "demo",
"url": "https://example.com/capabilities.yaml",
"domain": "helix_forge",
"description": "test",
}
response = hub_client.post(
"/v1/repos",
json=payload,
headers={"Authorization": "Bearer test-token"},
)
assert response.status_code == 201
listed = hub_client.get("/v1/repos")
assert listed.status_code == 200
assert listed.json()["count"] == 1
assert "auth_env" not in listed.json()["repos"][0]
def test_update_registration(hub_client):
hub_client.post(
"/v1/repos",
json={
"repo": "demo",
"url": "https://example.com/capabilities.yaml",
"domain": "helix_forge",
},
headers={"Authorization": "Bearer test-token"},
)
response = hub_client.patch(
"/v1/repos/demo",
json={"enabled": False},
headers={"Authorization": "Bearer test-token"},
)
assert response.status_code == 200
assert response.json()["enabled"] is False
def test_compose_federated_with_mock_fetch(hub_client, monkeypatch):
hub_client.post(
"/v1/repos",
json={
"repo": "remote-repo",
"url": "https://example.com/capabilities.yaml",
"domain": "helix_forge",
"enabled": True,
},
headers={"Authorization": "Bearer test-token"},
)
payload = REMOTE_INDEX.encode("utf-8")
class FakeResponse:
def __enter__(self):
return self
def __exit__(self, *args):
return False
def read(self):
return payload
with patch("urllib.request.urlopen", return_value=FakeResponse()):
response = hub_client.get("/v1/federated?refresh=true")
assert response.status_code == 200
data = response.json()
ids = {item["id"] for item in data["capabilities"]}
assert "capability.remote.sample" in ids
def test_store_validation(tmp_path):
store = HubStore(tmp_path / "hub.db")
with pytest.raises(ValueError):
store.create_repo({"repo": "BAD", "url": "ftp://x", "domain": "helix_forge"})
# --- T02: composed_at / stale tracking ---
def test_compose_state_starts_unset(tmp_path):
store = HubStore(tmp_path / "hub.db")
state = store.get_compose_state()
assert state == {"composed_at": None, "stale": False}
def test_record_compose_sets_timestamp_and_clears_stale(tmp_path):
store = HubStore(tmp_path / "hub.db")
store.mark_stale()
assert store.get_compose_state()["stale"] is True
composed_at = store.record_compose()
state = store.get_compose_state()
assert state["composed_at"] == composed_at
assert state["stale"] is False
def test_plain_get_does_not_clear_stale(hub_client, monkeypatch):
hub_client.post(
"/v1/repos",
json={"repo": "remote-repo", "url": "https://example.com/capabilities.yaml", "domain": "helix_forge"},
headers={"Authorization": "Bearer test-token"},
)
payload = REMOTE_INDEX.encode("utf-8")
class FakeResponse:
def __enter__(self):
return self
def __exit__(self, *args):
return False
def read(self):
return payload
with patch("urllib.request.urlopen", return_value=FakeResponse()):
# force a real compose so composed_at/stale are established
first = hub_client.get("/v1/federated?refresh=true")
assert first.json()["stale"] is False
composed_at_1 = first.json()["composed_at"]
# a plain GET (no refresh) must not report itself as freshly composed
second = hub_client.get("/v1/federated")
assert second.json()["composed_at"] == composed_at_1
assert second.json()["stale"] is False
def test_get_federated_reports_stale_after_mark(hub_client, monkeypatch):
hub_client.post(
"/v1/repos",
json={"repo": "remote-repo", "url": "https://example.com/capabilities.yaml", "domain": "helix_forge"},
headers={"Authorization": "Bearer test-token"},
)
payload = REMOTE_INDEX.encode("utf-8")
class FakeResponse:
def __enter__(self):
return self
def __exit__(self, *args):
return False
def read(self):
return payload
with patch("urllib.request.urlopen", return_value=FakeResponse()):
hub_client.get("/v1/federated?refresh=true")
from reuse_surface.hub.store import HubStore as _HubStore
db_path = os.environ["REUSE_SURFACE_DB"]
_HubStore(Path(db_path)).mark_stale()
# A stale index recomposes on the next plain GET and the flag clears --
# not a silent clear, since that pass refetched (REUSE-WP-0020-T09).
with patch("urllib.request.urlopen", return_value=FakeResponse()):
response = hub_client.get("/v1/federated")
assert response.json()["stale"] is False
assert _HubStore(Path(db_path)).get_compose_state()["stale"] is False
# --- T02: Forgejo webhook receiver ---
WEBHOOK_SECRET = "test-webhook-secret"
def _sign(body: bytes, secret: str = WEBHOOK_SECRET) -> str:
import hashlib
import hmac as hmac_module
return hmac_module.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest()
@pytest.fixture
def webhook_client(hub_client, monkeypatch):
monkeypatch.setenv("REUSE_SURFACE_FORGEJO_WEBHOOK_SECRET", WEBHOOK_SECRET)
return hub_client
def test_webhook_rejects_missing_signature(webhook_client):
response = webhook_client.post("/v1/webhooks/forgejo", json={"commits": []})
assert response.status_code == 401
def test_webhook_rejects_wrong_signature(webhook_client):
body = json.dumps({"commits": []}).encode("utf-8")
response = webhook_client.post(
"/v1/webhooks/forgejo",
content=body,
headers={"X-Forgejo-Signature": "0" * 64, "Content-Type": "application/json"},
)
assert response.status_code == 401
def test_webhook_rejects_when_secret_not_configured(hub_client):
body = json.dumps({"commits": []}).encode("utf-8")
response = hub_client.post(
"/v1/webhooks/forgejo",
content=body,
headers={"X-Forgejo-Signature": _sign(body), "Content-Type": "application/json"},
)
assert response.status_code == 503
def test_webhook_ignores_push_without_registry_change(webhook_client):
body = json.dumps({"commits": [{"added": ["README.md"], "modified": [], "removed": []}]}).encode("utf-8")
response = webhook_client.post(
"/v1/webhooks/forgejo",
content=body,
headers={"X-Forgejo-Signature": _sign(body), "Content-Type": "application/json"},
)
assert response.status_code == 200
assert response.json()["accepted"] is False
def test_webhook_triggers_recompose_on_registry_change(webhook_client, monkeypatch):
webhook_client.post(
"/v1/repos",
json={"repo": "remote-repo", "url": "https://example.com/capabilities.yaml", "domain": "helix_forge"},
headers={"Authorization": "Bearer test-token"},
)
payload_bytes = REMOTE_INDEX.encode("utf-8")
class FakeResponse:
def __enter__(self):
return self
def __exit__(self, *args):
return False
def read(self):
return payload_bytes
body = json.dumps(
{"commits": [{"added": ["registry/indexes/capabilities.yaml"], "modified": [], "removed": []}]}
).encode("utf-8")
with patch("urllib.request.urlopen", return_value=FakeResponse()):
response = webhook_client.post(
"/v1/webhooks/forgejo",
content=body,
headers={"X-Forgejo-Signature": _sign(body), "Content-Type": "application/json"},
)
assert response.status_code == 200
assert response.json()["accepted"] is True
assert response.json()["composed_at"]
# federated index should now report the newly composed data, not stale
follow_up = webhook_client.get("/v1/federated")
assert follow_up.json()["stale"] is False
ids = {item["id"] for item in follow_up.json()["capabilities"]}
assert "capability.remote.sample" in ids
def test_webhook_accepts_gitea_signature_header(webhook_client):
body = json.dumps({"commits": [{"added": ["README.md"], "modified": [], "removed": []}]}).encode("utf-8")
response = webhook_client.post(
"/v1/webhooks/forgejo",
content=body,
headers={"X-Gitea-Signature": _sign(body), "Content-Type": "application/json"},
)
assert response.status_code == 200
# --- T04: reuse telemetry store ---
VALID_REUSE_EVENT = {
"ts": "2026-07-08T00:00:00Z",
"consumer_repo": "some-repo",
"capability_id": "capability.infotech.issue-tracking",
"verdict": "reuse",
"outcome": "reused",
"source": "plan-check",
}
def test_record_reuse_event_requires_auth(hub_client):
response = hub_client.post("/v1/reuse-events", json=VALID_REUSE_EVENT)
assert response.status_code == 401
def test_record_reuse_event_and_list(hub_client):
response = hub_client.post(
"/v1/reuse-events",
json=VALID_REUSE_EVENT,
headers={"Authorization": "Bearer test-token"},
)
assert response.status_code == 201
listed = hub_client.get("/v1/reuse-events")
assert listed.status_code == 200
assert listed.json()["count"] == 1
assert listed.json()["events"][0]["consumer_repo"] == "some-repo"
def test_record_reuse_event_rejects_invalid_verdict(hub_client):
bad = {**VALID_REUSE_EVENT, "verdict": "not-a-verdict"}
response = hub_client.post(
"/v1/reuse-events",
json=bad,
headers={"Authorization": "Bearer test-token"},
)
assert response.status_code == 400
def test_record_reuse_event_rejects_extra_fields(hub_client):
bad = {**VALID_REUSE_EVENT, "unexpected_field": "nope"}
response = hub_client.post(
"/v1/reuse-events",
json=bad,
headers={"Authorization": "Bearer test-token"},
)
assert response.status_code == 400
def test_list_reuse_events_filters_by_capability_id(hub_client):
other = {**VALID_REUSE_EVENT, "capability_id": "capability.infotech.other"}
hub_client.post("/v1/reuse-events", json=VALID_REUSE_EVENT, headers={"Authorization": "Bearer test-token"})
hub_client.post("/v1/reuse-events", json=other, headers={"Authorization": "Bearer test-token"})
listed = hub_client.get("/v1/reuse-events?capability_id=capability.infotech.other")
assert listed.status_code == 200
assert listed.json()["count"] == 1
assert listed.json()["events"][0]["capability_id"] == "capability.infotech.other"
def test_list_reuse_events_empty_by_default(hub_client):
listed = hub_client.get("/v1/reuse-events")
assert listed.status_code == 200
assert listed.json() == {"count": 0, "events": []}
def test_store_record_reuse_event_and_list(tmp_path):
store = HubStore(tmp_path / "hub.db")
store.record_reuse_event(VALID_REUSE_EVENT)
events = store.list_reuse_events()
assert len(events) == 1
assert events[0]["consumer_repo"] == "some-repo"
def test_store_record_reuse_event_rejects_invalid(tmp_path):
store = HubStore(tmp_path / "hub.db")
with pytest.raises(ValueError):
store.record_reuse_event({**VALID_REUSE_EVENT, "verdict": "nope"})
# --- T09: a registration change invalidates the composed index ---
SECOND_INDEX_BEFORE = """
version: 1
domain: helix_forge
updated: "2026-08-20"
capabilities: []
"""
SECOND_INDEX_AFTER = """
version: 1
domain: helix_forge
updated: "2026-08-21"
capabilities:
- id: capability.remote.second
name: Second Remote
domain: helix_forge
vector: D3/A2/C3/R3
owner: example
path: registry/capabilities/capability.remote.second.md
summary: Capability a member repo published after its index was repaired
tags: [sample]
consumption_modes: [planning]
"""
SECOND_URL = "https://example.com/second.yaml"
class _Forge:
"""Serves a per-URL index body that the test can change mid-flight."""
def __init__(self):
self.bodies = {
"https://example.com/capabilities.yaml": REMOTE_INDEX,
SECOND_URL: SECOND_INDEX_BEFORE,
}
def publish(self, url, body):
self.bodies[url] = body
def open(self, request, *args, **kwargs):
url = request.full_url if hasattr(request, "full_url") else str(request)
body = self.bodies[url].encode("utf-8")
class FakeResponse:
def __enter__(self_inner):
return self_inner
def __exit__(self_inner, *args):
return False
def read(self_inner):
return body
return FakeResponse()
def _register(hub_client, repo, url):
return hub_client.post(
"/v1/repos",
json={"repo": repo, "url": url, "domain": "helix_forge"},
headers={"Authorization": "Bearer test-token"},
)
def _set_enabled(hub_client, repo, value):
return hub_client.patch(
f"/v1/repos/{repo}",
json={"enabled": value},
headers={"Authorization": "Bearer test-token"},
)
def test_re_enabled_source_refetches_a_stale_cache(hub_client):
"""The evidence-binder incident, reproduced.
A member's cached index is the broken version. The member repairs and
republishes, and the source is re-enabled -- but the per-source cache is
still inside its 24h TTL, so a plain GET would keep serving the old empty
copy and report stale: false while doing it. The registration change must
force a refetch.
"""
forge = _Forge()
_register(hub_client, "remote-repo", "https://example.com/capabilities.yaml")
_register(hub_client, "second-repo", SECOND_URL)
with patch("urllib.request.urlopen", side_effect=forge.open):
# cache both sources while second-repo still publishes nothing usable
hub_client.get("/v1/federated?refresh=true")
_set_enabled(hub_client, "second-repo", False)
hub_client.get("/v1/federated?refresh=true")
# the member repairs its index, then asks to be re-enabled
forge.publish(SECOND_URL, SECOND_INDEX_AFTER)
_set_enabled(hub_client, "second-repo", True)
response = hub_client.get("/v1/federated")
ids = {c["id"] for c in response.json()["capabilities"]}
assert "capability.remote.second" in ids, (
"re-enabled source served from a stale cache -- registered but invisible"
)
assert response.json()["stale"] is False
def test_registration_write_marks_the_index_stale(hub_client):
"""A registration write must flag the composed index as no longer current.
Asserted on the flag rather than on composed_at, which has second
resolution and ties when two composes land in the same second.
"""
from reuse_surface.hub.store import HubStore as _HubStore
store = _HubStore(Path(os.environ["REUSE_SURFACE_DB"]))
forge = _Forge()
_register(hub_client, "remote-repo", "https://example.com/capabilities.yaml")
with patch("urllib.request.urlopen", side_effect=forge.open):
hub_client.get("/v1/federated?refresh=true")
assert store.get_compose_state()["stale"] is False
_register(hub_client, "second-repo", SECOND_URL)
assert store.get_compose_state()["stale"] is True, (
"registering a repo left the composed index looking current"
)
hub_client.get("/v1/federated")
assert store.get_compose_state()["stale"] is False
def test_cosmetic_patch_does_not_force_a_recompose(hub_client):
"""A description edit changes nothing about composition; leave the cache alone."""
forge = _Forge()
_register(hub_client, "remote-repo", "https://example.com/capabilities.yaml")
with patch("urllib.request.urlopen", side_effect=forge.open):
first = hub_client.get("/v1/federated?refresh=true")
composed_at = first.json()["composed_at"]
hub_client.patch(
"/v1/repos/remote-repo",
json={"description": "just a label change"},
headers={"Authorization": "Bearer test-token"},
)
after = hub_client.get("/v1/federated")
assert after.json()["composed_at"] == composed_at
assert after.json()["stale"] is False