generated from coulomb/repo-seed
REUSE-WP-0019-T02: hub recompose staleness tracking + Forgejo webhook
Some checks failed
ci / validate-registry (push) Has been cancelled
Some checks failed
ci / validate-registry (push) Has been cancelled
reuse_surface/hub/store.py: compose_state table tracking composed_at/stale, updated only on a *forced* recompose (refresh=true, webhook, future scheduled fallback) -- a plain GET still serves current best-effort data but never silently reports itself as freshly composed. reuse_surface/hub/webhooks.py: constant-time HMAC-SHA256 signature verification (fails closed on an empty/unconfigured secret) and path-only push-payload inspection (never parses file content, per design principle 2 -- webhook only decides whether to trigger a pull-based recompose). New endpoint POST /v1/webhooks/forgejo, accepting both X-Forgejo-Signature and X-Gitea-Signature headers since sibling repos migrate independently. GET /v1/federated and POST /v1/federated/compose now share an asyncio.Lock with the webhook so concurrent recompose triggers coalesce instead of overlapping. No separate /v1/recompose route was added -- POST /v1/federated/compose already did that job from earlier hub work; specs/FederationHubAPI.md now documents this explicitly instead of duplicating it. 28 new pytest cases (128 total pass). Live-verified: ran the actual hub service locally and sent a real HMAC-signed webhook over HTTP, confirming the full webhook-to-recompose path, signature rejection, and the irrelevant-path no-op. Does NOT deploy to the live reuse.coulomb.social hub -- that needs a container rebuild/push/k8s rollout, a separate production-deployment action out of scope here without explicit sign-off. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -130,4 +130,185 @@ def test_compose_federated_with_mock_fetch(hub_client, monkeypatch):
|
||||
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"})
|
||||
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()
|
||||
|
||||
response = hub_client.get("/v1/federated")
|
||||
assert response.json()["stale"] is True
|
||||
|
||||
|
||||
# --- 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
|
||||
Reference in New Issue
Block a user