gabriel / musehub public
test_quorum_enforcement.py python
480 lines 15.8 KB
Raw
sha256:ef10830ce231e0a20efcb0e2586cb879471247e916616e6fdd0d51df459e2595 fix: typing audit — 0 violations, 0 untyped defs across all… Sonnet 4.6 minor ⚠ breaking 57 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_identity_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_repo_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_social_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_social_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_repo_models import (
159 MusehubRepo, MusehubCommit, MusehubCommitRef, MusehubBranch,
160 MusehubSnapshot, MusehubSnapshotRef, 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 storage_uri=f"s3://muse-objects/objects/{object_id}",
182 content_cache=content,
183 )
184 db_session.add(obj)
185
186 obj_ref = MusehubObjectRef(
187 object_id=object_id,
188 repo_id=repo.repo_id,
189 )
190 db_session.add(obj_ref)
191
192 snap = MusehubSnapshot(
193 snapshot_id=snapshot_id,
194 directories=[],
195 manifest_blob=msgpack.packb({"governance.json": object_id}, use_bin_type=True),
196 entry_count=1,
197 created_at=_NOW,
198 )
199 db_session.add(snap)
200 db_session.add(MusehubSnapshotRef(repo_id=repo.repo_id, snapshot_id=snapshot_id))
201
202 commit = MusehubCommit(
203 commit_id=commit_id,
204 branch="main",
205 parent_ids=[],
206 message="init",
207 author=identity_id,
208 timestamp=_NOW,
209 snapshot_id=snapshot_id,
210 )
211 db_session.add(commit)
212 db_session.add(MusehubCommitRef(repo_id=repo.repo_id, commit_id=commit_id))
213
214 branch = MusehubBranch(
215 branch_id=compute_branch_id(repo.repo_id, "main"),
216 repo_id=repo.repo_id,
217 name="main",
218 head_commit_id=commit_id,
219 )
220 db_session.add(branch)
221 await db_session.commit()
222
223 result = await load_governance(db_session, repo.repo_id)
224 assert result is not None
225 assert result["quorum"]["threshold"] == 1
226 assert _MEMBER_FP in result["quorum"]["members"]
227
228
229 # ---------------------------------------------------------------------------
230 # 2. check_quorum — unit tests
231 # ---------------------------------------------------------------------------
232
233
234 @pytest.mark.asyncio
235 async def test_check_quorum_no_approvals_not_met(db_session: AsyncSession) -> None:
236 from musehub.services.musehub_governance import check_quorum
237
238 identity_id = compute_identity_id(b"qtest1")
239 human = _make_identity("qtest1", identity_id)
240 db_session.add(human)
241 repo = _make_repo("qtest1", "qtest-repo", identity_id)
242 db_session.add(repo)
243 proposal_id = compute_proposal_id(repo.repo_id, identity_id, "feat/x", "main", _NOW.isoformat())
244 proposal = _make_proposal(repo.repo_id, proposal_id)
245 db_session.add(proposal)
246 await db_session.commit()
247
248 met, found, threshold = await check_quorum(
249 db_session, repo.repo_id, proposal_id, _GOVERNANCE_1OF1
250 )
251 assert not met
252 assert found == 0
253 assert threshold == 1
254
255
256 @pytest.mark.asyncio
257 async def test_check_quorum_nonmember_approval_not_counted(db_session: AsyncSession) -> None:
258 from musehub.services.musehub_governance import check_quorum
259
260 identity_id = compute_identity_id(b"qtest2")
261 human = _make_identity("qtest2", identity_id)
262 db_session.add(human)
263 await db_session.flush()
264 auth_key = _make_auth_key(identity_id, _NONMEMBER_FP)
265 db_session.add(auth_key)
266 repo = _make_repo("qtest2", "qtest-repo2", identity_id)
267 db_session.add(repo)
268 proposal_id = compute_proposal_id(repo.repo_id, identity_id, "feat/x", "main", _NOW.isoformat())
269 proposal = _make_proposal(repo.repo_id, proposal_id)
270 db_session.add(proposal)
271 review = _make_review(proposal_id, "qtest2", "approved")
272 db_session.add(review)
273 await db_session.commit()
274
275 met, found, threshold = await check_quorum(
276 db_session, repo.repo_id, proposal_id, _GOVERNANCE_1OF1
277 )
278 assert not met
279 assert found == 0
280
281
282 @pytest.mark.asyncio
283 async def test_check_quorum_member_approval_counts(db_session: AsyncSession) -> None:
284 from musehub.services.musehub_governance import check_quorum
285
286 identity_id = compute_identity_id(b"qtest3")
287 human = _make_identity("qtest3", identity_id)
288 db_session.add(human)
289 await db_session.flush()
290 auth_key = _make_auth_key(identity_id, _MEMBER_FP)
291 db_session.add(auth_key)
292 repo = _make_repo("qtest3", "qtest-repo3", identity_id)
293 db_session.add(repo)
294 proposal_id = compute_proposal_id(repo.repo_id, identity_id, "feat/x", "main", _NOW.isoformat())
295 proposal = _make_proposal(repo.repo_id, proposal_id)
296 db_session.add(proposal)
297 review = _make_review(proposal_id, "qtest3", "approved")
298 db_session.add(review)
299 await db_session.commit()
300
301 met, found, threshold = await check_quorum(
302 db_session, repo.repo_id, proposal_id, _GOVERNANCE_1OF1
303 )
304 assert met
305 assert found == 1
306 assert threshold == 1
307
308
309 @pytest.mark.asyncio
310 async def test_check_quorum_changes_requested_not_counted(db_session: AsyncSession) -> None:
311 from musehub.services.musehub_governance import check_quorum
312
313 identity_id = compute_identity_id(b"qtest4")
314 human = _make_identity("qtest4", identity_id)
315 db_session.add(human)
316 await db_session.flush()
317 auth_key = _make_auth_key(identity_id, _MEMBER_FP)
318 db_session.add(auth_key)
319 repo = _make_repo("qtest4", "qtest-repo4", identity_id)
320 db_session.add(repo)
321 proposal_id = compute_proposal_id(repo.repo_id, identity_id, "feat/x", "main", _NOW.isoformat())
322 proposal = _make_proposal(repo.repo_id, proposal_id)
323 db_session.add(proposal)
324 review = _make_review(proposal_id, "qtest4", "changes_requested")
325 db_session.add(review)
326 await db_session.commit()
327
328 met, found, threshold = await check_quorum(
329 db_session, repo.repo_id, proposal_id, _GOVERNANCE_1OF1
330 )
331 assert not met
332 assert found == 0
333
334
335 @pytest.mark.asyncio
336 async def test_check_quorum_2of3_partial_not_met(db_session: AsyncSession) -> None:
337 from musehub.services.musehub_governance import check_quorum
338
339 identity_id = compute_identity_id(b"qtest5")
340 human = _make_identity("qtest5", identity_id)
341 db_session.add(human)
342 await db_session.flush()
343 auth_key = _make_auth_key(identity_id, _MEMBER_FP)
344 db_session.add(auth_key)
345 repo = _make_repo("qtest5", "qtest-repo5", identity_id)
346 db_session.add(repo)
347 proposal_id = compute_proposal_id(repo.repo_id, identity_id, "feat/x", "main", _NOW.isoformat())
348 proposal = _make_proposal(repo.repo_id, proposal_id)
349 db_session.add(proposal)
350 review = _make_review(proposal_id, "qtest5", "approved")
351 db_session.add(review)
352 await db_session.commit()
353
354 met, found, threshold = await check_quorum(
355 db_session, repo.repo_id, proposal_id, _GOVERNANCE_2OF3
356 )
357 assert not met
358 assert found == 1
359 assert threshold == 2
360
361
362 # ---------------------------------------------------------------------------
363 # 3. API — merge blocked when quorum not met
364 # ---------------------------------------------------------------------------
365
366
367 @pytest.mark.asyncio
368 async def test_merge_blocked_when_quorum_not_met(
369 client: AsyncClient,
370 db_session: AsyncSession,
371 auth_headers: dict[str, str],
372 monkeypatch: pytest.MonkeyPatch,
373 ) -> None:
374 """POST merge returns 403 when governance exists but quorum is not met."""
375 import musehub.services.musehub_governance as _gov
376
377 identity_id = compute_identity_id(b"testuser")
378 repo = _make_repo("testuser", "governed-repo", identity_id)
379 db_session.add(repo)
380 proposal_id = compute_proposal_id(repo.repo_id, identity_id, "feat/x", "main", _NOW.isoformat())
381 proposal = _make_proposal(repo.repo_id, proposal_id)
382 db_session.add(proposal)
383 await db_session.commit()
384
385 async def _fake_load(session, repo_id):
386 return _GOVERNANCE_1OF1
387
388 monkeypatch.setattr(_gov, "load_governance", _fake_load)
389
390 resp = await client.post(
391 f"/api/repos/{repo.repo_id}/proposals/{proposal_id}/merge",
392 json={"merge_strategy": "merge_commit"},
393 headers=auth_headers,
394 )
395 assert resp.status_code == 403, resp.text
396 body = resp.json()
397 assert "quorum" in body["detail"].lower()
398 assert "0" in body["detail"] or "0/" in body["detail"]
399
400
401 @pytest.mark.asyncio
402 async def test_merge_allowed_when_quorum_met(
403 client: AsyncClient,
404 db_session: AsyncSession,
405 auth_headers: dict[str, str],
406 monkeypatch: pytest.MonkeyPatch,
407 ) -> None:
408 """POST merge succeeds when governance quorum is satisfied."""
409 import musehub.services.musehub_governance as _gov
410
411 from musehub.db.musehub_repo_models import MusehubBranch
412 from musehub.core.genesis import compute_branch_id
413
414 identity_id = compute_identity_id(b"testuser")
415 repo = _make_repo("testuser", "governed-repo2", identity_id)
416 db_session.add(repo)
417
418 # Branches needed for the merge to find commits
419 for bname in ("main", "feat/x"):
420 db_session.add(MusehubBranch(
421 branch_id=compute_branch_id(repo.repo_id, bname),
422 repo_id=repo.repo_id,
423 name=bname,
424 head_commit_id=None,
425 ))
426
427 proposal_id = compute_proposal_id(repo.repo_id, identity_id, "feat/x", "main", _NOW.isoformat())
428 proposal = _make_proposal(repo.repo_id, proposal_id)
429 db_session.add(proposal)
430
431 # Member approves
432 auth_key = _make_auth_key(identity_id, _MEMBER_FP)
433 db_session.add(auth_key)
434 review = _make_review(proposal_id, "testuser", "approved")
435 db_session.add(review)
436 await db_session.commit()
437
438 async def _fake_load(session, repo_id):
439 return _GOVERNANCE_1OF1
440
441 monkeypatch.setattr(_gov, "load_governance", _fake_load)
442
443 resp = await client.post(
444 f"/api/repos/{repo.repo_id}/proposals/{proposal_id}/merge",
445 json={"merge_strategy": "merge_commit"},
446 headers=auth_headers,
447 )
448 # 200 or 409 (already merged/no commits) — NOT 403
449 assert resp.status_code != 403, resp.text
450
451
452 @pytest.mark.asyncio
453 async def test_merge_unaffected_without_governance(
454 client: AsyncClient,
455 db_session: AsyncSession,
456 auth_headers: dict[str, str],
457 monkeypatch: pytest.MonkeyPatch,
458 ) -> None:
459 """POST merge proceeds normally when repo has no governance.json."""
460 import musehub.services.musehub_governance as _gov
461
462 identity_id = compute_identity_id(b"testuser")
463 repo = _make_repo("testuser", "ungoverned-repo", identity_id)
464 db_session.add(repo)
465 proposal_id = compute_proposal_id(repo.repo_id, identity_id, "feat/x", "main", _NOW.isoformat())
466 proposal = _make_proposal(repo.repo_id, proposal_id)
467 db_session.add(proposal)
468 await db_session.commit()
469
470 async def _fake_load(session, repo_id):
471 return None # no governance.json
472
473 monkeypatch.setattr(_gov, "load_governance", _fake_load)
474
475 resp = await client.post(
476 f"/api/repos/{repo.repo_id}/proposals/{proposal_id}/merge",
477 json={"merge_strategy": "merge_commit"},
478 headers=auth_headers,
479 )
480 assert resp.status_code != 403, resp.text
File History 1 commit
sha256:ef10830ce231e0a20efcb0e2586cb879471247e916616e6fdd0d51df459e2595 fix: typing audit — 0 violations, 0 untyped defs across all… Sonnet 4.6 minor 57 days ago