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>
550 lines
17 KiB
Python
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
|