"""§9 hardening — the registry reconcile prunes projects the registry no longer declares (was additive-only). A re-pin to a new registry used to strand the old projects' rows (PPE's stale-ecomm 1238-row entry cache starving writes). `prune_absent_projects` now deletes a removed project together with its collections and every project-scoped row, at whole-project granularity, with a `foreign_key_check` backstop. The safety guard is structural: `refresh_registry` raises on a read/transport error and `parse_registry` rejects an empty project list, so the prune is reached only after a real, non-empty registry parse — a transient failure can't wipe data. """ from __future__ import annotations import tempfile from pathlib import Path import pytest import app.db as db from app import projects, registry class _Cfg: def __init__(self, path, default_id): self.database_path = path self.default_project_id = default_id def _two_project_db(monkeypatch, default_id="ohm"): """A multi-project DB: default project 'ohm' (collection 'default') + a second project 'ecomm' (collection 'ecomm'), each with a rich set of project- and collection-keyed rows, plus the non-keyed `thread_messages` descendant.""" path = str(Path(tempfile.mkdtemp()) / "t.db") cfg = _Cfg(path, default_id) db.run_migrations(cfg) monkeypatch.setattr(db, "_CONN", db.connect(path)) conn = db.conn() conn.execute("DELETE FROM collections") conn.execute("DELETE FROM projects") conn.execute("INSERT INTO projects (id,name,content_repo,visibility) VALUES ('ohm','OHM','ohm-content','public')") conn.execute("INSERT INTO projects (id,name,content_repo,visibility) VALUES ('ecomm','Ecomm','ecomm-content','public')") conn.execute("INSERT INTO collections (id,project_id,type,subfolder,initial_state,visibility,name) " "VALUES ('default','ohm','document','','super-draft','public','OHM')") conn.execute("INSERT INTO collections (id,project_id,type,subfolder,initial_state,visibility,name) " "VALUES ('ecomm','ecomm','bdd','ecomm','super-draft','public','Ecomm')") conn.execute("INSERT INTO users (id,gitea_login,display_name,role) VALUES (1,'a','A','contributor')") def seed(proj, coll, slug): conn.execute("INSERT INTO cached_rfcs (slug,title,state,collection_id) VALUES (?,?,'active',?)", (slug, slug.title(), coll)) conn.execute("INSERT INTO cached_branches (rfc_slug,branch_name,collection_id) VALUES (?, 'main', ?)", (slug, coll)) conn.execute("INSERT INTO cached_prs (rfc_slug,pr_kind,repo,pr_number,title,state,project_id) " "VALUES (?, 'rfc_branch', ?, 1, 't', 'open', ?)", (slug, proj, proj)) conn.execute("INSERT INTO stars (user_id,rfc_slug,collection_id) VALUES (1,?,?)", (slug, coll)) conn.execute("INSERT INTO watches (user_id,rfc_slug,state,set_by,collection_id) VALUES (1,?,'watching','explicit',?)", (slug, coll)) conn.execute("INSERT INTO changes (rfc_slug,branch_name,kind,original,proposed,project_id) " "VALUES (?, 'main', 'ai', 'o', 'p', ?)", (slug, proj)) conn.execute("INSERT INTO notifications (recipient_user_id,event_kind,project_id) VALUES (1,'x',?)", (proj,)) cur = conn.execute("INSERT INTO threads (rfc_slug,anchor_kind,thread_kind,project_id) " "VALUES (?, 'whole-doc', 'chat', ?)", (slug, proj)) conn.execute("INSERT INTO thread_messages (thread_id,role,text) VALUES (?, 'user', 'hi')", (cur.lastrowid,)) seed("ohm", "default", "human") seed("ecomm", "ecomm", "cart") assert conn.execute("PRAGMA foreign_key_check").fetchall() == [] # seed is FK-clean return cfg, conn _PRUNED_TABLES = [ "cached_rfcs", "cached_branches", "cached_prs", "stars", "watches", "changes", "notifications", "threads", ] def test_prune_removes_absent_project_and_all_its_data(monkeypatch): cfg, conn = _two_project_db(monkeypatch, default_id="ohm") pruned = projects.prune_absent_projects(cfg, {"ohm"}) # ecomm absent assert pruned == ["ecomm"] # ecomm: project, collection, and every keyed row gone. assert conn.execute("SELECT 1 FROM projects WHERE id='ecomm'").fetchone() is None assert conn.execute("SELECT 1 FROM collections WHERE id='ecomm'").fetchone() is None for t in _PRUNED_TABLES: col = "collection_id" if t in ("cached_rfcs", "cached_branches", "stars", "watches") else None if col: assert conn.execute(f"SELECT COUNT(*) n FROM {t} WHERE collection_id='ecomm'").fetchone()["n"] == 0, t else: assert conn.execute(f"SELECT COUNT(*) n FROM {t} WHERE project_id='ecomm'").fetchone()["n"] == 0, t # The non-keyed descendant (thread_messages on ecomm's thread) is gone too. assert conn.execute("SELECT COUNT(*) n FROM thread_messages").fetchone()["n"] == 1 # only ohm's remains # ohm (the default) is fully intact. assert conn.execute("SELECT 1 FROM projects WHERE id='ohm'").fetchone() is not None assert conn.execute("SELECT 1 FROM collections WHERE id='default'").fetchone() is not None assert conn.execute("SELECT COUNT(*) n FROM cached_rfcs WHERE slug='human'").fetchone()["n"] == 1 assert conn.execute("SELECT COUNT(*) n FROM threads WHERE rfc_slug='human'").fetchone()["n"] == 1 # FK integrity intact after the prune. assert conn.execute("PRAGMA foreign_key_check").fetchall() == [] def test_prune_is_noop_when_all_projects_present(monkeypatch): cfg, conn = _two_project_db(monkeypatch, default_id="ohm") assert projects.prune_absent_projects(cfg, {"ohm", "ecomm"}) == [] assert conn.execute("SELECT COUNT(*) n FROM projects").fetchone()["n"] == 2 assert conn.execute("SELECT COUNT(*) n FROM cached_rfcs").fetchone()["n"] == 2 def test_prune_never_removes_the_default_project(monkeypatch): # Even with an EMPTY present set (which the real caller can't produce — # parse_registry rejects an empty registry), the default project survives. cfg, conn = _two_project_db(monkeypatch, default_id="ohm") pruned = projects.prune_absent_projects(cfg, set()) assert "ohm" not in pruned and "ecomm" in pruned assert conn.execute("SELECT 1 FROM projects WHERE id='ohm'").fetchone() is not None assert conn.execute("SELECT 1 FROM collections WHERE id='default'").fetchone() is not None assert conn.execute("PRAGMA foreign_key_check").fetchall() == [] def test_empty_registry_is_rejected_so_prune_is_never_reached(): # The structural safety guard: a parse that would yield no projects raises, # so refresh_registry never reaches the prune with an empty present set. with pytest.raises(registry.RegistryError): registry.parse_registry("deployment:\n name: x\nprojects: []\n") def test_incidental_refresh_does_not_prune(app_with_fake_gitea): # noqa: F811 """An incidental re-mirror (the registry webhook, prune=False) must NOT prune a project absent from projects.yaml — only the full-reconcile triggers (startup + periodic sweep) prune. Guards the scoping decision so an in-app refresh can't wipe a project.""" import hashlib import hmac import json as _json from fastapi.testclient import TestClient from app import db app, _fake = app_with_fake_gitea with TestClient(app) as client: # A project present in the DB but not in the fake registry's projects.yaml. db.conn().execute( "INSERT INTO projects (id,name,content_repo,visibility) " "VALUES ('ghost','Ghost','ghost-content','public')" ) body = _json.dumps({"repository": {"full_name": "wiggleverse/registry"}}).encode() secret = "test-webhook-secret-for-signature-verification" sig = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest() r = client.post( "/api/webhooks/gitea", content=body, headers={"X-Gitea-Event": "push", "X-Gitea-Signature": sig, "Content-Type": "application/json"}, ) assert r.status_code == 200 # The incidental re-mirror left the ghost in place (prune is off here). assert db.conn().execute("SELECT 1 FROM projects WHERE id='ghost'").fetchone() is not None # app_with_fake_gitea fixture import for the integration test above. from test_propose_vertical import app_with_fake_gitea, tmp_env # noqa: E402,F401