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