gabriel / musehub public
test_quorum_enforcement.py python
488 lines 15.7 KB
Raw
sha256:bb2baaabdd19320bde50cb69d447fd1c1e571467df729be1a23b5e93064b046a feat(intel): standardize headers, gauge icon, velocity card… Sonnet 4.6 minor ⚠ breaking 143 days ago
1 """TDD tests for governance quorum enforcement on proposal merges.
2
3 Design: repos with a ``governance.json`` at HEAD require ≥ threshold
4 approved reviews from declared quorum members before a proposal can merge.
5 Repos without ``governance.json`` are unaffected.
6
7 Surfaces:
8 1. ``load_governance`` — reads governance.json from repo HEAD object store
9 2. ``check_quorum`` — counts member approvals vs threshold
10 3. POST /repos/{repo_id}/proposals/{proposal_id}/merge — returns 403 when
11 quorum is not met, proceeds normally when it is
12
13 Tests are RED-first. Each assertion drives one concrete implementation
14 decision.
15 """
16 from __future__ import annotations
17
18 import json
19 import pytest
20 from datetime import datetime, timezone
21 from httpx import AsyncClient
22 from sqlalchemy.ext.asyncio import AsyncSession
23
24 from musehub.main import app
25 import hashlib as _hashlib
26
27 from musehub.core.genesis import compute_identity_id, compute_repo_id, compute_proposal_id, compute_review_id
28
29
30 def _sha256(s: str) -> str:
31 return "sha256:" + _hashlib.sha256(s.encode()).hexdigest()
32
33
34 # ---------------------------------------------------------------------------
35 # Helpers
36 # ---------------------------------------------------------------------------
37
38 _NOW = datetime.now(timezone.utc)
39 _MEMBER_FP = "sha256:" + "a" * 64
40 _NONMEMBER_FP = "sha256:" + "b" * 64
41
42 _GOVERNANCE_1OF1 = {
43 "schema": 1,
44 "quorum": {
45 "threshold": 1,
46 "policy": "1-of-1",
47 "members": [_MEMBER_FP],
48 },
49 }
50
51 _GOVERNANCE_2OF3 = {
52 "schema": 1,
53 "quorum": {
54 "threshold": 2,
55 "policy": "2-of-3",
56 "members": [
57 _MEMBER_FP,
58 "sha256:" + "c" * 64,
59 "sha256:" + "d" * 64,
60 ],
61 },
62 }
63
64
65 def _make_identity(handle: str, identity_id: str | None = None):
66 from musehub.db.musehub_models import MusehubIdentity
67 return MusehubIdentity(
68 identity_id=identity_id or compute_identity_id(handle.encode()),
69 handle=handle,
70 identity_type="human",
71 agent_capabilities=[],
72 pinned_repo_ids=[],
73 is_verified=False,
74 created_at=_NOW,
75 updated_at=_NOW,
76 )
77
78
79 def _make_auth_key(identity_id: str, fingerprint: str):
80 from musehub.db.musehub_auth_models import MusehubAuthKey
81 return MusehubAuthKey(
82 key_id=fingerprint,
83 identity_id=identity_id,
84 algorithm="ed25519",
85 public_key_b64="ed25519:" + "Z" * 43,
86 fingerprint=fingerprint,
87 label="test key",
88 created_at=_NOW,
89 )
90
91
92 def _make_repo(owner: str, slug: str, identity_id: str):
93 from musehub.db.musehub_models import MusehubRepo
94 return MusehubRepo(
95 repo_id=compute_repo_id(identity_id, slug, "muse/generic", _NOW.isoformat()),
96 name=slug,
97 owner=owner,
98 slug=slug,
99 visibility="public",
100 owner_user_id=identity_id,
101 )
102
103
104 _PROPOSAL_COUNTER: list[int] = [0]
105
106
107 def _make_proposal(repo_id: str, proposal_id: str):
108 from musehub.db.musehub_models import MusehubProposal
109 _PROPOSAL_COUNTER[0] += 1
110 return MusehubProposal(
111 proposal_id=proposal_id,
112 repo_id=repo_id,
113 proposal_number=_PROPOSAL_COUNTER[0],
114 title="Test proposal",
115 body="",
116 from_branch="feat/x",
117 to_branch="main",
118 state="open",
119 author="testuser",
120 created_at=_NOW,
121 updated_at=_NOW,
122 )
123
124
125 def _make_review(proposal_id: str, reviewer: str, state: str):
126 from musehub.db.musehub_models import MusehubProposalReview
127 review_id = compute_review_id(proposal_id, compute_identity_id(reviewer.encode()), _NOW.isoformat())
128 return MusehubProposalReview(
129 review_id=review_id,
130 proposal_id=proposal_id,
131 reviewer_username=reviewer,
132 state=state,
133 submitted_at=_NOW,
134 created_at=_NOW,
135 )
136
137
138 # ---------------------------------------------------------------------------
139 # 1. load_governance — unit tests
140 # ---------------------------------------------------------------------------
141
142
143 @pytest.mark.asyncio
144 async def test_load_governance_returns_none_when_no_file(db_session: AsyncSession) -> None:
145 """Repo with no governance.json returns None."""
146 from musehub.services.musehub_governance import load_governance
147
148 identity_id = compute_identity_id(b"govtest1")
149 human = _make_identity("govtest1", identity_id)
150 db_session.add(human)
151 repo = _make_repo("govtest1", "no-gov-repo", identity_id)
152 db_session.add(repo)
153 await db_session.commit()
154
155 result = await load_governance(db_session, repo.repo_id)
156 assert result is None
157
158
159 @pytest.mark.asyncio
160 async def test_load_governance_returns_parsed_json(db_session: AsyncSession) -> None:
161 """Repo with governance.json stored in object store returns parsed dict."""
162 from musehub.services.musehub_governance import load_governance
163 from musehub.db.musehub_models import (
164 MusehubRepo, MusehubCommit, MusehubBranch,
165 MusehubSnapshot, MusehubObject, MusehubObjectRef,
166 )
167 from musehub.core.genesis import compute_branch_id
168 import hashlib
169 import msgpack
170
171 identity_id = compute_identity_id(b"govtest2")
172 human = _make_identity("govtest2", identity_id)
173 db_session.add(human)
174 repo = _make_repo("govtest2", "gov-repo", identity_id)
175 db_session.add(repo)
176
177 # Build a minimal snapshot containing governance.json
178 content = json.dumps(_GOVERNANCE_1OF1).encode()
179 object_id = "sha256:" + hashlib.sha256(content).hexdigest()
180 snapshot_id = _sha256(f"snap:{repo.repo_id}:governance")
181 commit_id = _sha256(f"commit:{repo.repo_id}:init")
182
183 obj = MusehubObject(
184 object_id=object_id,
185 path="governance.json",
186 size_bytes=len(content),
187 disk_path="",
188 storage_uri=f"local://{object_id}",
189 content_cache=content,
190 )
191 db_session.add(obj)
192
193 obj_ref = MusehubObjectRef(
194 object_id=object_id,
195 repo_id=repo.repo_id,
196 )
197 db_session.add(obj_ref)
198
199 snap = MusehubSnapshot(
200 snapshot_id=snapshot_id,
201 repo_id=repo.repo_id,
202 directories=[],
203 manifest_blob=msgpack.packb({"governance.json": object_id}, use_bin_type=True),
204 entry_count=1,
205 created_at=_NOW,
206 )
207 db_session.add(snap)
208
209 commit = MusehubCommit(
210 commit_id=commit_id,
211 repo_id=repo.repo_id,
212 branch="main",
213 parent_ids=[],
214 message="init",
215 author=identity_id,
216 timestamp=_NOW,
217 snapshot_id=snapshot_id,
218 commit_meta={},
219 )
220 db_session.add(commit)
221
222 branch = MusehubBranch(
223 branch_id=compute_branch_id(repo.repo_id, "main"),
224 repo_id=repo.repo_id,
225 name="main",
226 head_commit_id=commit_id,
227 )
228 db_session.add(branch)
229 await db_session.commit()
230
231 result = await load_governance(db_session, repo.repo_id)
232 assert result is not None
233 assert result["quorum"]["threshold"] == 1
234 assert _MEMBER_FP in result["quorum"]["members"]
235
236
237 # ---------------------------------------------------------------------------
238 # 2. check_quorum — unit tests
239 # ---------------------------------------------------------------------------
240
241
242 @pytest.mark.asyncio
243 async def test_check_quorum_no_approvals_not_met(db_session: AsyncSession) -> None:
244 from musehub.services.musehub_governance import check_quorum
245
246 identity_id = compute_identity_id(b"qtest1")
247 human = _make_identity("qtest1", identity_id)
248 db_session.add(human)
249 repo = _make_repo("qtest1", "qtest-repo", identity_id)
250 db_session.add(repo)
251 proposal_id = compute_proposal_id(repo.repo_id, identity_id, "feat/x", "main", _NOW.isoformat())
252 proposal = _make_proposal(repo.repo_id, proposal_id)
253 db_session.add(proposal)
254 await db_session.commit()
255
256 met, found, threshold = await check_quorum(
257 db_session, repo.repo_id, proposal_id, _GOVERNANCE_1OF1
258 )
259 assert not met
260 assert found == 0
261 assert threshold == 1
262
263
264 @pytest.mark.asyncio
265 async def test_check_quorum_nonmember_approval_not_counted(db_session: AsyncSession) -> None:
266 from musehub.services.musehub_governance import check_quorum
267
268 identity_id = compute_identity_id(b"qtest2")
269 human = _make_identity("qtest2", identity_id)
270 db_session.add(human)
271 await db_session.flush()
272 auth_key = _make_auth_key(identity_id, _NONMEMBER_FP)
273 db_session.add(auth_key)
274 repo = _make_repo("qtest2", "qtest-repo2", identity_id)
275 db_session.add(repo)
276 proposal_id = compute_proposal_id(repo.repo_id, identity_id, "feat/x", "main", _NOW.isoformat())
277 proposal = _make_proposal(repo.repo_id, proposal_id)
278 db_session.add(proposal)
279 review = _make_review(proposal_id, "qtest2", "approved")
280 db_session.add(review)
281 await db_session.commit()
282
283 met, found, threshold = await check_quorum(
284 db_session, repo.repo_id, proposal_id, _GOVERNANCE_1OF1
285 )
286 assert not met
287 assert found == 0
288
289
290 @pytest.mark.asyncio
291 async def test_check_quorum_member_approval_counts(db_session: AsyncSession) -> None:
292 from musehub.services.musehub_governance import check_quorum
293
294 identity_id = compute_identity_id(b"qtest3")
295 human = _make_identity("qtest3", identity_id)
296 db_session.add(human)
297 await db_session.flush()
298 auth_key = _make_auth_key(identity_id, _MEMBER_FP)
299 db_session.add(auth_key)
300 repo = _make_repo("qtest3", "qtest-repo3", identity_id)
301 db_session.add(repo)
302 proposal_id = compute_proposal_id(repo.repo_id, identity_id, "feat/x", "main", _NOW.isoformat())
303 proposal = _make_proposal(repo.repo_id, proposal_id)
304 db_session.add(proposal)
305 review = _make_review(proposal_id, "qtest3", "approved")
306 db_session.add(review)
307 await db_session.commit()
308
309 met, found, threshold = await check_quorum(
310 db_session, repo.repo_id, proposal_id, _GOVERNANCE_1OF1
311 )
312 assert met
313 assert found == 1
314 assert threshold == 1
315
316
317 @pytest.mark.asyncio
318 async def test_check_quorum_changes_requested_not_counted(db_session: AsyncSession) -> None:
319 from musehub.services.musehub_governance import check_quorum
320
321 identity_id = compute_identity_id(b"qtest4")
322 human = _make_identity("qtest4", identity_id)
323 db_session.add(human)
324 await db_session.flush()
325 auth_key = _make_auth_key(identity_id, _MEMBER_FP)
326 db_session.add(auth_key)
327 repo = _make_repo("qtest4", "qtest-repo4", identity_id)
328 db_session.add(repo)
329 proposal_id = compute_proposal_id(repo.repo_id, identity_id, "feat/x", "main", _NOW.isoformat())
330 proposal = _make_proposal(repo.repo_id, proposal_id)
331 db_session.add(proposal)
332 review = _make_review(proposal_id, "qtest4", "changes_requested")
333 db_session.add(review)
334 await db_session.commit()
335
336 met, found, threshold = await check_quorum(
337 db_session, repo.repo_id, proposal_id, _GOVERNANCE_1OF1
338 )
339 assert not met
340 assert found == 0
341
342
343 @pytest.mark.asyncio
344 async def test_check_quorum_2of3_partial_not_met(db_session: AsyncSession) -> None:
345 from musehub.services.musehub_governance import check_quorum
346
347 identity_id = compute_identity_id(b"qtest5")
348 human = _make_identity("qtest5", identity_id)
349 db_session.add(human)
350 await db_session.flush()
351 auth_key = _make_auth_key(identity_id, _MEMBER_FP)
352 db_session.add(auth_key)
353 repo = _make_repo("qtest5", "qtest-repo5", identity_id)
354 db_session.add(repo)
355 proposal_id = compute_proposal_id(repo.repo_id, identity_id, "feat/x", "main", _NOW.isoformat())
356 proposal = _make_proposal(repo.repo_id, proposal_id)
357 db_session.add(proposal)
358 review = _make_review(proposal_id, "qtest5", "approved")
359 db_session.add(review)
360 await db_session.commit()
361
362 met, found, threshold = await check_quorum(
363 db_session, repo.repo_id, proposal_id, _GOVERNANCE_2OF3
364 )
365 assert not met
366 assert found == 1
367 assert threshold == 2
368
369
370 # ---------------------------------------------------------------------------
371 # 3. API — merge blocked when quorum not met
372 # ---------------------------------------------------------------------------
373
374
375 @pytest.mark.asyncio
376 async def test_merge_blocked_when_quorum_not_met(
377 client: AsyncClient,
378 db_session: AsyncSession,
379 auth_headers: dict[str, str],
380 monkeypatch: pytest.MonkeyPatch,
381 ) -> None:
382 """POST merge returns 403 when governance exists but quorum is not met."""
383 import musehub.services.musehub_governance as _gov
384
385 identity_id = compute_identity_id(b"testuser")
386 repo = _make_repo("testuser", "governed-repo", identity_id)
387 db_session.add(repo)
388 proposal_id = compute_proposal_id(repo.repo_id, identity_id, "feat/x", "main", _NOW.isoformat())
389 proposal = _make_proposal(repo.repo_id, proposal_id)
390 db_session.add(proposal)
391 await db_session.commit()
392
393 async def _fake_load(session, repo_id):
394 return _GOVERNANCE_1OF1
395
396 monkeypatch.setattr(_gov, "load_governance", _fake_load)
397
398 resp = await client.post(
399 f"/api/repos/{repo.repo_id}/proposals/{proposal_id}/merge",
400 json={"merge_strategy": "merge_commit"},
401 headers=auth_headers,
402 )
403 assert resp.status_code == 403, resp.text
404 body = resp.json()
405 assert "quorum" in body["detail"].lower()
406 assert "0" in body["detail"] or "0/" in body["detail"]
407
408
409 @pytest.mark.asyncio
410 async def test_merge_allowed_when_quorum_met(
411 client: AsyncClient,
412 db_session: AsyncSession,
413 auth_headers: dict[str, str],
414 monkeypatch: pytest.MonkeyPatch,
415 ) -> None:
416 """POST merge succeeds when governance quorum is satisfied."""
417 import musehub.services.musehub_governance as _gov
418
419 from musehub.db.musehub_models import MusehubBranch
420 from musehub.core.genesis import compute_branch_id
421
422 identity_id = compute_identity_id(b"testuser")
423 repo = _make_repo("testuser", "governed-repo2", identity_id)
424 db_session.add(repo)
425
426 # Branches needed for the merge to find commits
427 for bname in ("main", "feat/x"):
428 db_session.add(MusehubBranch(
429 branch_id=compute_branch_id(repo.repo_id, bname),
430 repo_id=repo.repo_id,
431 name=bname,
432 head_commit_id=None,
433 ))
434
435 proposal_id = compute_proposal_id(repo.repo_id, identity_id, "feat/x", "main", _NOW.isoformat())
436 proposal = _make_proposal(repo.repo_id, proposal_id)
437 db_session.add(proposal)
438
439 # Member approves
440 auth_key = _make_auth_key(identity_id, _MEMBER_FP)
441 db_session.add(auth_key)
442 review = _make_review(proposal_id, "testuser", "approved")
443 db_session.add(review)
444 await db_session.commit()
445
446 async def _fake_load(session, repo_id):
447 return _GOVERNANCE_1OF1
448
449 monkeypatch.setattr(_gov, "load_governance", _fake_load)
450
451 resp = await client.post(
452 f"/api/repos/{repo.repo_id}/proposals/{proposal_id}/merge",
453 json={"merge_strategy": "merge_commit"},
454 headers=auth_headers,
455 )
456 # 200 or 409 (already merged/no commits) — NOT 403
457 assert resp.status_code != 403, resp.text
458
459
460 @pytest.mark.asyncio
461 async def test_merge_unaffected_without_governance(
462 client: AsyncClient,
463 db_session: AsyncSession,
464 auth_headers: dict[str, str],
465 monkeypatch: pytest.MonkeyPatch,
466 ) -> None:
467 """POST merge proceeds normally when repo has no governance.json."""
468 import musehub.services.musehub_governance as _gov
469
470 identity_id = compute_identity_id(b"testuser")
471 repo = _make_repo("testuser", "ungoverned-repo", identity_id)
472 db_session.add(repo)
473 proposal_id = compute_proposal_id(repo.repo_id, identity_id, "feat/x", "main", _NOW.isoformat())
474 proposal = _make_proposal(repo.repo_id, proposal_id)
475 db_session.add(proposal)
476 await db_session.commit()
477
478 async def _fake_load(session, repo_id):
479 return None # no governance.json
480
481 monkeypatch.setattr(_gov, "load_governance", _fake_load)
482
483 resp = await client.post(
484 f"/api/repos/{repo.repo_id}/proposals/{proposal_id}/merge",
485 json={"merge_strategy": "merge_commit"},
486 headers=auth_headers,
487 )
488 assert resp.status_code != 403, resp.text
File History 1 commit
sha256:bb2baaabdd19320bde50cb69d447fd1c1e571467df729be1a23b5e93064b046a feat(intel): standardize headers, gauge icon, velocity card… Sonnet 4.6 minor 143 days ago