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>
This commit is contained in:
parent
d1de320743
commit
6cbc862371
5 changed files with 235 additions and 26 deletions
|
|
@ -208,8 +208,12 @@ def test_get_federated_reports_stale_after_mark(hub_client, monkeypatch):
|
|||
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
|
||||
# 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 ---
|
||||
|
|
@ -393,4 +397,154 @@ def test_store_record_reuse_event_and_list(tmp_path):
|
|||
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"})
|
||||
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue