REUSE-WP-0019-T02: hub recompose staleness tracking + Forgejo webhook
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:
2026-07-07 18:24:55 +02:00
parent 443510276b
commit e0a4de3310
7 changed files with 520 additions and 25 deletions

82
tests/test_webhooks.py Normal file
View File

@@ -0,0 +1,82 @@
from __future__ import annotations
import hashlib
import hmac
from reuse_surface.hub.webhooks import push_touches_registry_index, verify_signature
SECRET = "shh"
def _sign(body: bytes, secret: str = SECRET) -> str:
return hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest()
def test_verify_signature_valid():
body = b'{"commits": []}'
assert verify_signature(SECRET, body, _sign(body)) is True
def test_verify_signature_wrong_secret():
body = b'{"commits": []}'
assert verify_signature("different-secret", body, _sign(body)) is False
def test_verify_signature_tampered_body():
body = b'{"commits": []}'
signature = _sign(body)
assert verify_signature(SECRET, b'{"commits": [1]}', signature) is False
def test_verify_signature_missing_signature():
assert verify_signature(SECRET, b"{}", None) is False
def test_verify_signature_empty_secret_rejects():
body = b"{}"
# Even if a caller accidentally computed a signature with an empty
# secret, verify_signature must not accept it -- an unconfigured
# secret is not the same as "verification not needed".
assert verify_signature("", body, _sign(body, secret="")) is False
def test_push_touches_registry_index_added():
payload = {"commits": [{"added": ["registry/indexes/capabilities.yaml"], "modified": [], "removed": []}]}
assert push_touches_registry_index(payload) is True
def test_push_touches_registry_index_modified():
payload = {"commits": [{"added": [], "modified": ["registry/indexes/federated.yaml"], "removed": []}]}
assert push_touches_registry_index(payload) is True
def test_push_touches_registry_index_removed():
payload = {"commits": [{"added": [], "modified": [], "removed": ["registry/indexes/capabilities.yaml"]}]}
assert push_touches_registry_index(payload) is True
def test_push_does_not_touch_registry_index():
payload = {"commits": [{"added": ["README.md"], "modified": ["src/main.py"], "removed": []}]}
assert push_touches_registry_index(payload) is False
def test_push_touches_registry_index_across_multiple_commits():
payload = {
"commits": [
{"added": ["README.md"], "modified": [], "removed": []},
{"added": [], "modified": ["registry/indexes/capabilities.yaml"], "removed": []},
]
}
assert push_touches_registry_index(payload) is True
def test_push_touches_registry_index_empty_commits():
assert push_touches_registry_index({"commits": []}) is False
assert push_touches_registry_index({}) is False
def test_push_touches_registry_index_ignores_similarly_named_paths():
# a path that merely starts with "registry" but isn't under
# registry/indexes/ must not match
payload = {"commits": [{"added": ["registry/capabilities/capability.foo.md"], "modified": [], "removed": []}]}
assert push_touches_registry_index(payload) is False