gabriel / musehub public
test_identity_repo_phase5.py python
497 lines 17.7 KB
Raw
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef debug(push/stream): instrument O-frame decode path with INF… Sonnet 4.6 patch 122 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):
62 from musehub.db.musehub_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):
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):
89 from musehub.db.musehub_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):
101 from musehub.db.musehub_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"):
122 from musehub.db.musehub_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_models import (
144 MusehubRepo,
145 MusehubObject,
146 MusehubObjectRef,
147 MusehubSnapshot,
148 MusehubCommit,
149 MusehubBranch,
150 )
151
152 repo_id = compute_repo_id(identity_id, "identity", "identity", _NOW.isoformat())
153 repo = MusehubRepo(
154 repo_id=repo_id,
155 name="identity",
156 owner=handle,
157 slug="identity",
158 visibility="private",
159 owner_user_id=identity_id,
160 domain_id="identity",
161 )
162 session.add(repo)
163
164 record = {
165 "handle": handle,
166 "type": "human",
167 "pubkey": pubkey_b64,
168 "quorum": None,
169 "registered_at": _NOW.isoformat(),
170 "metadata": {},
171 }
172 content = json.dumps(record).encode()
173 file_path = f"identities/{handle}.json"
174 obj_id = blob_id(content)
175 snap_id = blob_id(f"snap:{repo_id}:{handle}".encode())
176 commit_id = blob_id(f"commit:{repo_id}:{handle}".encode())
177
178 obj = MusehubObject(
179 object_id=obj_id,
180 path=file_path,
181 size_bytes=len(content),
182 disk_path="",
183 storage_uri=f"local://{obj_id}",
184 content_cache=content,
185 )
186 session.add(obj)
187 session.add(MusehubObjectRef(object_id=obj_id, repo_id=repo_id))
188
189 manifest = {file_path: obj_id}
190 snap = MusehubSnapshot(
191 snapshot_id=snap_id,
192 repo_id=repo_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
200 commit = MusehubCommit(
201 commit_id=commit_id,
202 repo_id=repo_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
212 branch = MusehubBranch(
213 branch_id=compute_branch_id(repo_id, "main"),
214 repo_id=repo_id,
215 name="main",
216 head_commit_id=commit_id,
217 )
218 session.add(branch)
219 await session.flush()
220
221
222 # ═══════════════════════════════════════════════════════════════════════════════
223 # 1. Handle-based member resolves to fingerprint via identity repo HEAD
224 # ═══════════════════════════════════════════════════════════════════════════════
225
226
227 @pytest.mark.asyncio
228 async def test_handle_member_resolves_to_fingerprint(db_session: AsyncSession) -> None:
229 """resolve_handle_to_fingerprint reads identity repo HEAD and returns FP."""
230 from musehub.services.musehub_governance import resolve_handle_to_fingerprint
231
232 handle = _uid("alice")
233 identity_id = compute_identity_id(handle.encode())
234 identity = _make_identity(handle)
235 db_session.add(identity)
236 await db_session.flush()
237
238 await _create_identity_repo_with_pubkey(db_session, handle, identity_id, _KEY_A_B64)
239 await db_session.commit()
240
241 fp = await resolve_handle_to_fingerprint(db_session, handle)
242 assert fp == _KEY_A_FP, f"Expected {_KEY_A_FP!r}, got {fp!r}"
243
244
245 @pytest.mark.asyncio
246 async def test_handle_with_no_identity_repo_returns_none(db_session: AsyncSession) -> None:
247 """resolve_handle_to_fingerprint returns None when no identity repo exists."""
248 from musehub.services.musehub_governance import resolve_handle_to_fingerprint
249
250 handle = _uid("ghost")
251 identity = _make_identity(handle)
252 db_session.add(identity)
253 await db_session.commit()
254
255 fp = await resolve_handle_to_fingerprint(db_session, handle)
256 assert fp is None
257
258
259 # ═══════════════════════════════════════════════════════════════════════════════
260 # 2. check_quorum — handle-based members
261 # ═══════════════════════════════════════════════════════════════════════════════
262
263
264 @pytest.mark.asyncio
265 async def test_check_quorum_handle_member_counts_when_key_matches(
266 db_session: AsyncSession,
267 ) -> None:
268 """check_quorum counts a handle member whose identity repo pubkey matches reviewer's key."""
269 from musehub.services.musehub_governance import check_quorum
270
271 handle = _uid("bob")
272 identity_id = compute_identity_id(handle.encode())
273 identity = _make_identity(handle)
274 db_session.add(identity)
275 await db_session.flush()
276
277 # Identity repo: handle → KEY_A
278 await _create_identity_repo_with_pubkey(db_session, handle, identity_id, _KEY_A_B64)
279 # Auth key: reviewer registered with KEY_A
280 db_session.add(_make_auth_key(identity_id, _KEY_A_FP, _KEY_A_B64))
281
282 # Governance repo + proposal
283 repo = _make_repo(handle, "proj", identity_id)
284 db_session.add(repo)
285 proposal, proposal_id = _make_proposal(repo.repo_id, identity_id)
286 db_session.add(proposal)
287 db_session.add(_make_review(proposal_id, handle))
288 await db_session.commit()
289
290 governance = {
291 "schema": 1,
292 "quorum": {"threshold": 1, "members": [handle]},
293 }
294 met, found, threshold = await check_quorum(
295 db_session, repo.repo_id, proposal_id, governance
296 )
297 assert met, f"Expected quorum met but found={found} threshold={threshold}"
298 assert found == 1
299
300
301 @pytest.mark.asyncio
302 async def test_check_quorum_handle_member_not_counted_when_key_differs(
303 db_session: AsyncSession,
304 ) -> None:
305 """check_quorum does not count a handle member when reviewer has a different key."""
306 from musehub.services.musehub_governance import check_quorum
307
308 handle = _uid("carol")
309 identity_id = compute_identity_id(handle.encode())
310 identity = _make_identity(handle)
311 db_session.add(identity)
312 await db_session.flush()
313
314 # Identity repo: handle → KEY_A
315 await _create_identity_repo_with_pubkey(db_session, handle, identity_id, _KEY_A_B64)
316 # But reviewer's registered key is KEY_B (different)
317 db_session.add(_make_auth_key(identity_id, _KEY_B_FP, _KEY_B_B64))
318
319 repo = _make_repo(handle, "proj2", identity_id)
320 db_session.add(repo)
321 proposal, proposal_id = _make_proposal(repo.repo_id, identity_id)
322 db_session.add(proposal)
323 db_session.add(_make_review(proposal_id, handle))
324 await db_session.commit()
325
326 governance = {
327 "schema": 1,
328 "quorum": {"threshold": 1, "members": [handle]},
329 }
330 met, found, threshold = await check_quorum(
331 db_session, repo.repo_id, proposal_id, governance
332 )
333 assert not met, f"Expected quorum NOT met but found={found}"
334 assert found == 0
335
336
337 @pytest.mark.asyncio
338 async def test_check_quorum_handle_with_no_identity_repo_not_counted(
339 db_session: AsyncSession,
340 ) -> None:
341 """check_quorum skips handle members who have no identity repo."""
342 from musehub.services.musehub_governance import check_quorum
343
344 handle = _uid("dave")
345 identity_id = compute_identity_id(handle.encode())
346 identity = _make_identity(handle)
347 db_session.add(identity)
348 await db_session.flush()
349
350 # No identity repo created — resolver returns None
351 db_session.add(_make_auth_key(identity_id, _KEY_A_FP, _KEY_A_B64))
352
353 repo = _make_repo(handle, "proj3", identity_id)
354 db_session.add(repo)
355 proposal, proposal_id = _make_proposal(repo.repo_id, identity_id)
356 db_session.add(proposal)
357 db_session.add(_make_review(proposal_id, handle))
358 await db_session.commit()
359
360 governance = {
361 "schema": 1,
362 "quorum": {"threshold": 1, "members": [handle]},
363 }
364 met, found, _ = await check_quorum(
365 db_session, repo.repo_id, proposal_id, governance
366 )
367 assert not met
368 assert found == 0
369
370
371 # ═══════════════════════════════════════════════════════════════════════════════
372 # 3. Backward compatibility — sha256: entries still match fingerprints directly
373 # ═══════════════════════════════════════════════════════════════════════════════
374
375
376 @pytest.mark.asyncio
377 async def test_check_quorum_fp_member_still_works(db_session: AsyncSession) -> None:
378 """sha256:... entries in members continue to match reviewer fingerprints directly."""
379 from musehub.services.musehub_governance import check_quorum
380
381 handle = _uid("eve")
382 identity_id = compute_identity_id(handle.encode())
383 identity = _make_identity(handle)
384 db_session.add(identity)
385 await db_session.flush()
386
387 db_session.add(_make_auth_key(identity_id, _KEY_A_FP, _KEY_A_B64))
388
389 repo = _make_repo(handle, "proj4", identity_id)
390 db_session.add(repo)
391 proposal, proposal_id = _make_proposal(repo.repo_id, identity_id)
392 db_session.add(proposal)
393 db_session.add(_make_review(proposal_id, handle))
394 await db_session.commit()
395
396 # Old format: fingerprint directly in members
397 governance = {
398 "schema": 1,
399 "quorum": {"threshold": 1, "members": [_KEY_A_FP]},
400 }
401 met, found, _ = await check_quorum(
402 db_session, repo.repo_id, proposal_id, governance
403 )
404 assert met
405 assert found == 1
406
407
408 # ═══════════════════════════════════════════════════════════════════════════════
409 # 4. Key rotation propagates automatically via identity repo
410 # ═══════════════════════════════════════════════════════════════════════════════
411
412
413 @pytest.mark.asyncio
414 async def test_key_rotation_propagates_via_identity_repo(
415 db_session: AsyncSession,
416 ) -> None:
417 """After key rotation, handle-based member still satisfies quorum with the new key."""
418 from musehub.services.musehub_governance import check_quorum
419
420 handle = _uid("frank")
421 identity_id = compute_identity_id(handle.encode())
422 identity = _make_identity(handle)
423 db_session.add(identity)
424 await db_session.flush()
425
426 # Identity repo HEAD shows KEY_B (rotated)
427 await _create_identity_repo_with_pubkey(db_session, handle, identity_id, _KEY_B_B64)
428 # Reviewer's current registered key is KEY_B
429 db_session.add(_make_auth_key(identity_id, _KEY_B_FP, _KEY_B_B64))
430
431 repo = _make_repo(handle, "proj5", identity_id)
432 db_session.add(repo)
433 proposal, proposal_id = _make_proposal(repo.repo_id, identity_id)
434 db_session.add(proposal)
435 db_session.add(_make_review(proposal_id, handle))
436 await db_session.commit()
437
438 governance = {
439 "schema": 1,
440 "quorum": {"threshold": 1, "members": [handle]},
441 }
442 met, found, _ = await check_quorum(
443 db_session, repo.repo_id, proposal_id, governance
444 )
445 assert met, f"Quorum should be met after key rotation, found={found}"
446
447
448 # ═══════════════════════════════════════════════════════════════════════════════
449 # 5. Mixed handle + fingerprint member list
450 # ═══════════════════════════════════════════════════════════════════════════════
451
452
453 @pytest.mark.asyncio
454 async def test_check_quorum_mixed_members_both_resolve(
455 db_session: AsyncSession,
456 ) -> None:
457 """Mixed member list (one handle, one fingerprint) counts both correctly."""
458 from musehub.services.musehub_governance import check_quorum
459
460 handle_g = _uid("grace")
461 handle_h = _uid("hank")
462 id_g = compute_identity_id(handle_g.encode())
463 id_h = compute_identity_id(handle_h.encode())
464
465 for handle, iid in ((handle_g, id_g), (handle_h, id_h)):
466 db_session.add(_make_identity(handle))
467 await db_session.flush()
468
469 # grace → identity repo with KEY_A
470 await _create_identity_repo_with_pubkey(db_session, handle_g, id_g, _KEY_A_B64)
471 db_session.add(_make_auth_key(id_g, _KEY_A_FP, _KEY_A_B64))
472
473 # hank → fingerprint directly (old-style), registered with KEY_C
474 db_session.add(_make_auth_key(id_h, _KEY_C_FP, _KEY_C_B64))
475
476 # Use grace's repo for the proposal
477 repo = _make_repo(handle_g, "proj6", id_g)
478 db_session.add(repo)
479 proposal, proposal_id = _make_proposal(repo.repo_id, id_g)
480 db_session.add(proposal)
481 # Both approve
482 db_session.add(_make_review(proposal_id, handle_g))
483 db_session.add(_make_review(proposal_id, handle_h))
484 await db_session.commit()
485
486 governance = {
487 "schema": 1,
488 "quorum": {
489 "threshold": 2,
490 "members": [handle_g, _KEY_C_FP], # grace by handle, hank by fingerprint
491 },
492 }
493 met, found, threshold = await check_quorum(
494 db_session, repo.repo_id, proposal_id, governance
495 )
496 assert met, f"Expected quorum met, found={found} threshold={threshold}"
497 assert found == 2
File History 1 commit
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef debug(push/stream): instrument O-frame decode path with INF… Sonnet 4.6 patch 122 days ago