gabriel / musehub public
test_quorum_enforcement.py python
481 lines 16.0 KB
Raw
sha256:ef10830ce231e0a20efcb0e2586cb879471247e916616e6fdd0d51df459e2595 fix: typing audit — 0 violations, 0 untyped defs across all… Sonnet 4.6 minor ⚠ breaking 22 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 musehub.types.json_types import JSONObject
26 from muse.core.types import long_id, blob_id
27 from musehub.core.genesis import compute_identity_id, compute_repo_id, compute_proposal_id, compute_review_id
28
29
30 # ---------------------------------------------------------------------------
31 # Helpers
32 # ---------------------------------------------------------------------------
33
34 _NOW = datetime.now(timezone.utc)
35 _MEMBER_FP = long_id("a" * 64)
36 _NONMEMBER_FP = long_id("b" * 64)
37
38 _GOVERNANCE_1OF1 = {
39 "schema": 1,
40 "quorum": {
41 "threshold": 1,
42 "policy": "1-of-1",
43 "members": [_MEMBER_FP],
44 },
45 }
46
47 _GOVERNANCE_2OF3 = {
48 "schema": 1,
49 "quorum": {
50 "threshold": 2,
51 "policy": "2-of-3",
52 "members": [
53 _MEMBER_FP,
54 long_id("c" * 64),
55 long_id("d" * 64),
56 ],
57 },
58 }
59
60
61 def _make_identity(handle: str, identity_id: str | None = None) -> None:
62 from musehub.db.musehub_identity_models import MusehubIdentity
63 return MusehubIdentity(
64 identity_id=identity_id or compute_identity_id(handle.encode()),
65 handle=handle,
66 identity_type="human",
67 agent_capabilities=[],
68 pinned_repo_ids=[],
69 is_verified=False,
70 created_at=_NOW,
71 updated_at=_NOW,
72 )
73
74
75 def _make_auth_key(identity_id: str, fingerprint: str) -> None:
76 from musehub.db.musehub_auth_models import MusehubAuthKey
77 return MusehubAuthKey(
78 key_id=fingerprint,
79 identity_id=identity_id,
80 algorithm="ed25519",
81 public_key_b64=f"ed25519:{'Z' * 43}",
82 fingerprint=fingerprint,
83 label="test key",
84 created_at=_NOW,
85 )
86
87
88 def _make_repo(owner: str, slug: str, identity_id: str) -> None:
89 from musehub.db.musehub_repo_models import MusehubRepo
90 return MusehubRepo(
91 repo_id=compute_repo_id(identity_id, slug, "muse/generic", _NOW.isoformat()),
92 name=slug,
93 owner=owner,
94 slug=slug,
95 visibility="public",
96 owner_user_id=identity_id,
97 )
98
99
100 _PROPOSAL_COUNTER: list[int] = [0]
101
102
103 def _make_proposal(repo_id: str, proposal_id: str) -> None:
104 from musehub.db.musehub_social_models import MusehubProposal
105 _PROPOSAL_COUNTER[0] += 1
106 return MusehubProposal(
107 proposal_id=proposal_id,
108 repo_id=repo_id,
109 proposal_number=_PROPOSAL_COUNTER[0],
110 title="Test proposal",
111 body="",
112 from_branch="feat/x",
113 to_branch="main",
114 state="open",
115 author="testuser",
116 created_at=_NOW,
117 updated_at=_NOW,
118 )
119
120
121 def _make_review(proposal_id: str, reviewer: str, state: str) -> None:
122 from musehub.db.musehub_social_models import MusehubProposalReview
123 review_id = compute_review_id(proposal_id, compute_identity_id(reviewer.encode()), _NOW.isoformat())
124 return MusehubProposalReview(
125 review_id=review_id,
126 proposal_id=proposal_id,
127 reviewer_username=reviewer,
128 state=state,
129 submitted_at=_NOW,
130 created_at=_NOW,
131 )
132
133
134 # ---------------------------------------------------------------------------
135 # 1. load_governance — unit tests
136 # ---------------------------------------------------------------------------
137
138
139 @pytest.mark.asyncio
140 async def test_load_governance_returns_none_when_no_file(db_session: AsyncSession) -> None:
141 """Repo with no governance.json returns None."""
142 from musehub.services.musehub_governance import load_governance
143
144 identity_id = compute_identity_id(b"govtest1")
145 human = _make_identity("govtest1", identity_id)
146 db_session.add(human)
147 repo = _make_repo("govtest1", "no-gov-repo", identity_id)
148 db_session.add(repo)
149 await db_session.commit()
150
151 result = await load_governance(db_session, repo.repo_id)
152 assert result is None
153
154
155 @pytest.mark.asyncio
156 async def test_load_governance_returns_parsed_json(db_session: AsyncSession) -> None:
157 """Repo with governance.json stored in object store returns parsed dict."""
158 from musehub.services.musehub_governance import load_governance
159 from musehub.db.musehub_repo_models import (
160 MusehubRepo, MusehubCommit, MusehubCommitRef, MusehubBranch,
161 MusehubSnapshot, MusehubSnapshotRef, MusehubObject, MusehubObjectRef,
162 )
163 from musehub.core.genesis import compute_branch_id
164 import msgpack
165
166 identity_id = compute_identity_id(b"govtest2")
167 human = _make_identity("govtest2", identity_id)
168 db_session.add(human)
169 repo = _make_repo("govtest2", "gov-repo", identity_id)
170 db_session.add(repo)
171
172 # Build a minimal snapshot containing governance.json
173 content = json.dumps(_GOVERNANCE_1OF1).encode()
174 object_id = blob_id(content)
175 snapshot_id = blob_id(f"snap:{repo.repo_id}:governance".encode())
176 commit_id = blob_id(f"commit:{repo.repo_id}:init".encode())
177
178 obj = MusehubObject(
179 object_id=object_id,
180 path="governance.json",
181 size_bytes=len(content),
182 storage_uri=f"s3://muse-objects/objects/{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 directories=[],
196 manifest_blob=msgpack.packb({"governance.json": object_id}, use_bin_type=True),
197 entry_count=1,
198 created_at=_NOW,
199 )
200 db_session.add(snap)
201 db_session.add(MusehubSnapshotRef(repo_id=repo.repo_id, snapshot_id=snapshot_id))
202
203 commit = MusehubCommit(
204 commit_id=commit_id,
205 branch="main",
206 parent_ids=[],
207 message="init",
208 author=identity_id,
209 timestamp=_NOW,
210 snapshot_id=snapshot_id,
211 )
212 db_session.add(commit)
213 db_session.add(MusehubCommitRef(repo_id=repo.repo_id, commit_id=commit_id))
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: AsyncSession, repo_id: str) -> JSONObject | None:
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_repo_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: AsyncSession, repo_id: str) -> JSONObject | None:
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: AsyncSession, repo_id: str) -> JSONObject | None:
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:ef10830ce231e0a20efcb0e2586cb879471247e916616e6fdd0d51df459e2595 fix: typing audit — 0 violations, 0 untyped defs across all… Sonnet 4.6 minor 22 days ago