gabriel / musehub public
test_identity_repo_phase5.py python
498 lines 18.0 KB
Raw
sha256:ef10830ce231e0a20efcb0e2586cb879471247e916616e6fdd0d51df459e2595 fix: typing audit — 0 violations, 0 untyped defs across all… Sonnet 4.6 minor ⚠ breaking 20 days ago
1 """Phase 5 — Quorum resolution via identity handles.
2
3 TDD regression suite: every test starts RED and turns GREEN as the feature
4 is implemented. Their permanent role is to prevent regressions.
5
6 What this phase covers:
7 - check_quorum resolves handle-based members via identity repo HEAD pubkey
8 - Backward compat: sha256:... entries in members still match fingerprints directly
9 - Key rotation propagates: after rotation the handle still resolves to new key
10 - Handle with no identity repo does not count toward quorum
11 - Handle whose identity repo has a different pubkey than the reviewer's key
12 does not match
13 """
14 from __future__ import annotations
15
16 import base64
17 import json
18
19 import msgpack
20 import pytest
21 from datetime import datetime, timezone
22 from sqlalchemy.ext.asyncio import AsyncSession
23
24 from muse.core.types import blob_id, encode_pubkey, public_key_fingerprint
25 from musehub.core.genesis import (
26 compute_identity_id,
27 compute_proposal_id,
28 compute_repo_id,
29 compute_review_id,
30 compute_branch_id,
31 )
32
33 # ── fixed fake key material ────────────────────────────────────────────────────
34 # 32 zero bytes = a valid-length Ed25519 key (fake, not cryptographically useful)
35 _KEY_A_BYTES = b"\xaa" * 32
36 _KEY_A_FP = public_key_fingerprint(_KEY_A_BYTES)
37 _KEY_A_B64 = encode_pubkey("ed25519", _KEY_A_BYTES)
38
39 _KEY_B_BYTES = b"\xbb" * 32
40 _KEY_B_FP = public_key_fingerprint(_KEY_B_BYTES)
41 _KEY_B_B64 = encode_pubkey("ed25519", _KEY_B_BYTES)
42
43 _KEY_C_BYTES = b"\xcc" * 32
44 _KEY_C_FP = public_key_fingerprint(_KEY_C_BYTES)
45 _KEY_C_B64 = encode_pubkey("ed25519", _KEY_C_BYTES)
46
47 _NOW = datetime.now(timezone.utc)
48
49 _COUNTER: list[int] = [0]
50
51
52 # ── helpers ───────────────────────────────────────────────────────────────────
53
54
55 def _uid(tag: str) -> str:
56 """Return a unique handle/slug for this test run."""
57 _COUNTER[0] += 1
58 return f"p5{tag}{_COUNTER[0]}"
59
60
61 def _make_identity(handle: str) -> None:
62 from musehub.db.musehub_identity_models import MusehubIdentity
63 return MusehubIdentity(
64 identity_id=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, pubkey_b64: 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=pubkey_b64,
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 def _make_proposal(repo_id: str, identity_id: str) -> None:
101 from musehub.db.musehub_social_models import MusehubProposal
102 _COUNTER[0] += 1
103 proposal_id = compute_proposal_id(
104 repo_id, identity_id, "feat/x", "main", _NOW.isoformat()
105 )
106 return MusehubProposal(
107 proposal_id=proposal_id,
108 repo_id=repo_id,
109 proposal_number=_COUNTER[0],
110 title="Test proposal",
111 body="",
112 from_branch="feat/x",
113 to_branch="main",
114 state="open",
115 author="p5user",
116 created_at=_NOW,
117 updated_at=_NOW,
118 ), proposal_id
119
120
121 def _make_review(proposal_id: str, reviewer: str, state: str = "approved") -> None:
122 from musehub.db.musehub_social_models import MusehubProposalReview
123 review_id = compute_review_id(
124 proposal_id, compute_identity_id(reviewer.encode()), _NOW.isoformat()
125 )
126 return MusehubProposalReview(
127 review_id=review_id,
128 proposal_id=proposal_id,
129 reviewer_username=reviewer,
130 state=state,
131 submitted_at=_NOW,
132 created_at=_NOW,
133 )
134
135
136 async def _create_identity_repo_with_pubkey(
137 session: AsyncSession,
138 handle: str,
139 identity_id: str,
140 pubkey_b64: str,
141 ) -> None:
142 """Persist a minimal identity repo whose HEAD IdentityRecord has pubkey_b64."""
143 from musehub.db.musehub_repo_models import (
144 MusehubRepo,
145 MusehubObject,
146 MusehubObjectRef,
147 MusehubSnapshot,
148 MusehubSnapshotRef,
149 MusehubCommit,
150 MusehubCommitRef,
151 MusehubBranch,
152 )
153
154 repo_id = compute_repo_id(identity_id, "identity", "identity", _NOW.isoformat())
155 repo = MusehubRepo(
156 repo_id=repo_id,
157 name="identity",
158 owner=handle,
159 slug="identity",
160 visibility="private",
161 owner_user_id=identity_id,
162 domain_id="identity",
163 )
164 session.add(repo)
165
166 record = {
167 "handle": handle,
168 "type": "human",
169 "pubkey": pubkey_b64,
170 "quorum": None,
171 "registered_at": _NOW.isoformat(),
172 "metadata": {},
173 }
174 content = json.dumps(record).encode()
175 file_path = f"identities/{handle}.json"
176 obj_id = blob_id(content)
177 snap_id = blob_id(f"snap:{repo_id}:{handle}".encode())
178 commit_id = blob_id(f"commit:{repo_id}:{handle}".encode())
179
180 obj = MusehubObject(
181 object_id=obj_id,
182 path=file_path,
183 size_bytes=len(content),
184 storage_uri=f"s3://muse-objects/objects/{obj_id}",
185 content_cache=content,
186 )
187 session.add(obj)
188 session.add(MusehubObjectRef(object_id=obj_id, repo_id=repo_id))
189
190 manifest = {file_path: obj_id}
191 snap = MusehubSnapshot(
192 snapshot_id=snap_id,
193 directories=[],
194 manifest_blob=msgpack.packb(manifest, use_bin_type=True),
195 entry_count=1,
196 created_at=_NOW,
197 )
198 session.add(snap)
199 session.add(MusehubSnapshotRef(repo_id=repo_id, snapshot_id=snap_id))
200
201 commit = MusehubCommit(
202 commit_id=commit_id,
203 branch="main",
204 parent_ids=[],
205 message=f"identity: register {handle}",
206 author=identity_id,
207 timestamp=_NOW,
208 snapshot_id=snap_id,
209 )
210 session.add(commit)
211 session.add(MusehubCommitRef(repo_id=repo_id, commit_id=commit_id))
212
213 branch = MusehubBranch(
214 branch_id=compute_branch_id(repo_id, "main"),
215 repo_id=repo_id,
216 name="main",
217 head_commit_id=commit_id,
218 )
219 session.add(branch)
220 await session.flush()
221
222
223 # ═══════════════════════════════════════════════════════════════════════════════
224 # 1. Handle-based member resolves to fingerprint via identity repo HEAD
225 # ═══════════════════════════════════════════════════════════════════════════════
226
227
228 @pytest.mark.asyncio
229 async def test_handle_member_resolves_to_fingerprint(db_session: AsyncSession) -> None:
230 """resolve_handle_to_fingerprint reads identity repo HEAD and returns FP."""
231 from musehub.services.musehub_governance import resolve_handle_to_fingerprint
232
233 handle = _uid("alice")
234 identity_id = compute_identity_id(handle.encode())
235 identity = _make_identity(handle)
236 db_session.add(identity)
237 await db_session.flush()
238
239 await _create_identity_repo_with_pubkey(db_session, handle, identity_id, _KEY_A_B64)
240 await db_session.commit()
241
242 fp = await resolve_handle_to_fingerprint(db_session, handle)
243 assert fp == _KEY_A_FP, f"Expected {_KEY_A_FP!r}, got {fp!r}"
244
245
246 @pytest.mark.asyncio
247 async def test_handle_with_no_identity_repo_returns_none(db_session: AsyncSession) -> None:
248 """resolve_handle_to_fingerprint returns None when no identity repo exists."""
249 from musehub.services.musehub_governance import resolve_handle_to_fingerprint
250
251 handle = _uid("ghost")
252 identity = _make_identity(handle)
253 db_session.add(identity)
254 await db_session.commit()
255
256 fp = await resolve_handle_to_fingerprint(db_session, handle)
257 assert fp is None
258
259
260 # ═══════════════════════════════════════════════════════════════════════════════
261 # 2. check_quorum — handle-based members
262 # ═══════════════════════════════════════════════════════════════════════════════
263
264
265 @pytest.mark.asyncio
266 async def test_check_quorum_handle_member_counts_when_key_matches(
267 db_session: AsyncSession,
268 ) -> None:
269 """check_quorum counts a handle member whose identity repo pubkey matches reviewer's key."""
270 from musehub.services.musehub_governance import check_quorum
271
272 handle = _uid("bob")
273 identity_id = compute_identity_id(handle.encode())
274 identity = _make_identity(handle)
275 db_session.add(identity)
276 await db_session.flush()
277
278 # Identity repo: handle → KEY_A
279 await _create_identity_repo_with_pubkey(db_session, handle, identity_id, _KEY_A_B64)
280 # Auth key: reviewer registered with KEY_A
281 db_session.add(_make_auth_key(identity_id, _KEY_A_FP, _KEY_A_B64))
282
283 # Governance repo + proposal
284 repo = _make_repo(handle, "proj", identity_id)
285 db_session.add(repo)
286 proposal, proposal_id = _make_proposal(repo.repo_id, identity_id)
287 db_session.add(proposal)
288 db_session.add(_make_review(proposal_id, handle))
289 await db_session.commit()
290
291 governance = {
292 "schema": 1,
293 "quorum": {"threshold": 1, "members": [handle]},
294 }
295 met, found, threshold = await check_quorum(
296 db_session, repo.repo_id, proposal_id, governance
297 )
298 assert met, f"Expected quorum met but found={found} threshold={threshold}"
299 assert found == 1
300
301
302 @pytest.mark.asyncio
303 async def test_check_quorum_handle_member_not_counted_when_key_differs(
304 db_session: AsyncSession,
305 ) -> None:
306 """check_quorum does not count a handle member when reviewer has a different key."""
307 from musehub.services.musehub_governance import check_quorum
308
309 handle = _uid("carol")
310 identity_id = compute_identity_id(handle.encode())
311 identity = _make_identity(handle)
312 db_session.add(identity)
313 await db_session.flush()
314
315 # Identity repo: handle → KEY_A
316 await _create_identity_repo_with_pubkey(db_session, handle, identity_id, _KEY_A_B64)
317 # But reviewer's registered key is KEY_B (different)
318 db_session.add(_make_auth_key(identity_id, _KEY_B_FP, _KEY_B_B64))
319
320 repo = _make_repo(handle, "proj2", identity_id)
321 db_session.add(repo)
322 proposal, proposal_id = _make_proposal(repo.repo_id, identity_id)
323 db_session.add(proposal)
324 db_session.add(_make_review(proposal_id, handle))
325 await db_session.commit()
326
327 governance = {
328 "schema": 1,
329 "quorum": {"threshold": 1, "members": [handle]},
330 }
331 met, found, threshold = await check_quorum(
332 db_session, repo.repo_id, proposal_id, governance
333 )
334 assert not met, f"Expected quorum NOT met but found={found}"
335 assert found == 0
336
337
338 @pytest.mark.asyncio
339 async def test_check_quorum_handle_with_no_identity_repo_not_counted(
340 db_session: AsyncSession,
341 ) -> None:
342 """check_quorum skips handle members who have no identity repo."""
343 from musehub.services.musehub_governance import check_quorum
344
345 handle = _uid("dave")
346 identity_id = compute_identity_id(handle.encode())
347 identity = _make_identity(handle)
348 db_session.add(identity)
349 await db_session.flush()
350
351 # No identity repo created — resolver returns None
352 db_session.add(_make_auth_key(identity_id, _KEY_A_FP, _KEY_A_B64))
353
354 repo = _make_repo(handle, "proj3", identity_id)
355 db_session.add(repo)
356 proposal, proposal_id = _make_proposal(repo.repo_id, identity_id)
357 db_session.add(proposal)
358 db_session.add(_make_review(proposal_id, handle))
359 await db_session.commit()
360
361 governance = {
362 "schema": 1,
363 "quorum": {"threshold": 1, "members": [handle]},
364 }
365 met, found, _ = await check_quorum(
366 db_session, repo.repo_id, proposal_id, governance
367 )
368 assert not met
369 assert found == 0
370
371
372 # ═══════════════════════════════════════════════════════════════════════════════
373 # 3. Backward compatibility — sha256: entries still match fingerprints directly
374 # ═══════════════════════════════════════════════════════════════════════════════
375
376
377 @pytest.mark.asyncio
378 async def test_check_quorum_fp_member_still_works(db_session: AsyncSession) -> None:
379 """sha256:... entries in members continue to match reviewer fingerprints directly."""
380 from musehub.services.musehub_governance import check_quorum
381
382 handle = _uid("eve")
383 identity_id = compute_identity_id(handle.encode())
384 identity = _make_identity(handle)
385 db_session.add(identity)
386 await db_session.flush()
387
388 db_session.add(_make_auth_key(identity_id, _KEY_A_FP, _KEY_A_B64))
389
390 repo = _make_repo(handle, "proj4", identity_id)
391 db_session.add(repo)
392 proposal, proposal_id = _make_proposal(repo.repo_id, identity_id)
393 db_session.add(proposal)
394 db_session.add(_make_review(proposal_id, handle))
395 await db_session.commit()
396
397 # Old format: fingerprint directly in members
398 governance = {
399 "schema": 1,
400 "quorum": {"threshold": 1, "members": [_KEY_A_FP]},
401 }
402 met, found, _ = await check_quorum(
403 db_session, repo.repo_id, proposal_id, governance
404 )
405 assert met
406 assert found == 1
407
408
409 # ═══════════════════════════════════════════════════════════════════════════════
410 # 4. Key rotation propagates automatically via identity repo
411 # ═══════════════════════════════════════════════════════════════════════════════
412
413
414 @pytest.mark.asyncio
415 async def test_key_rotation_propagates_via_identity_repo(
416 db_session: AsyncSession,
417 ) -> None:
418 """After key rotation, handle-based member still satisfies quorum with the new key."""
419 from musehub.services.musehub_governance import check_quorum
420
421 handle = _uid("frank")
422 identity_id = compute_identity_id(handle.encode())
423 identity = _make_identity(handle)
424 db_session.add(identity)
425 await db_session.flush()
426
427 # Identity repo HEAD shows KEY_B (rotated)
428 await _create_identity_repo_with_pubkey(db_session, handle, identity_id, _KEY_B_B64)
429 # Reviewer's current registered key is KEY_B
430 db_session.add(_make_auth_key(identity_id, _KEY_B_FP, _KEY_B_B64))
431
432 repo = _make_repo(handle, "proj5", identity_id)
433 db_session.add(repo)
434 proposal, proposal_id = _make_proposal(repo.repo_id, identity_id)
435 db_session.add(proposal)
436 db_session.add(_make_review(proposal_id, handle))
437 await db_session.commit()
438
439 governance = {
440 "schema": 1,
441 "quorum": {"threshold": 1, "members": [handle]},
442 }
443 met, found, _ = await check_quorum(
444 db_session, repo.repo_id, proposal_id, governance
445 )
446 assert met, f"Quorum should be met after key rotation, found={found}"
447
448
449 # ═══════════════════════════════════════════════════════════════════════════════
450 # 5. Mixed handle + fingerprint member list
451 # ═══════════════════════════════════════════════════════════════════════════════
452
453
454 @pytest.mark.asyncio
455 async def test_check_quorum_mixed_members_both_resolve(
456 db_session: AsyncSession,
457 ) -> None:
458 """Mixed member list (one handle, one fingerprint) counts both correctly."""
459 from musehub.services.musehub_governance import check_quorum
460
461 handle_g = _uid("grace")
462 handle_h = _uid("hank")
463 id_g = compute_identity_id(handle_g.encode())
464 id_h = compute_identity_id(handle_h.encode())
465
466 for handle, iid in ((handle_g, id_g), (handle_h, id_h)):
467 db_session.add(_make_identity(handle))
468 await db_session.flush()
469
470 # grace → identity repo with KEY_A
471 await _create_identity_repo_with_pubkey(db_session, handle_g, id_g, _KEY_A_B64)
472 db_session.add(_make_auth_key(id_g, _KEY_A_FP, _KEY_A_B64))
473
474 # hank → fingerprint directly (old-style), registered with KEY_C
475 db_session.add(_make_auth_key(id_h, _KEY_C_FP, _KEY_C_B64))
476
477 # Use grace's repo for the proposal
478 repo = _make_repo(handle_g, "proj6", id_g)
479 db_session.add(repo)
480 proposal, proposal_id = _make_proposal(repo.repo_id, id_g)
481 db_session.add(proposal)
482 # Both approve
483 db_session.add(_make_review(proposal_id, handle_g))
484 db_session.add(_make_review(proposal_id, handle_h))
485 await db_session.commit()
486
487 governance = {
488 "schema": 1,
489 "quorum": {
490 "threshold": 2,
491 "members": [handle_g, _KEY_C_FP], # grace by handle, hank by fingerprint
492 },
493 }
494 met, found, threshold = await check_quorum(
495 db_session, repo.repo_id, proposal_id, governance
496 )
497 assert met, f"Expected quorum met, found={found} threshold={threshold}"
498 assert found == 2
File History 1 commit
sha256:ef10830ce231e0a20efcb0e2586cb879471247e916616e6fdd0d51df459e2595 fix: typing audit — 0 violations, 0 untyped defs across all… Sonnet 4.6 minor 20 days ago