gabriel / musehub public
test_identity_repo_phase6.py python
456 lines 16.7 KB
Raw
sha256:bb2baaabdd19320bde50cb69d447fd1c1e571467df729be1a23b5e93064b046a feat(intel): standardize headers, gauge icon, velocity card… Sonnet 4.6 minor ⚠ breaking 143 days ago
1 """Phase 6 — GET /api/identities/{handle} reads from identity repo HEAD.
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 - GET /api/identities/{handle} response includes a top-level `pubkey` field
8 sourced from the identity repo HEAD IdentityRecord
9 - After a key-rotation commit to the identity repo, the response reflects
10 the new pubkey immediately (no DB update required)
11 - `identity_type` is sourced from the identity repo `type` field
12 - For org identities, `quorum` appears in the response from the repo record
13 - DB-only profile fields (bio, avatar_url, etc.) still appear alongside
14 identity-repo data (the repo is canonical truth; the DB supplies enrichment)
15 - When no identity repo exists yet (migration period), the endpoint falls back
16 to the DB and returns `pubkey: null` without erroring
17 - 404 when the handle does not exist at all
18 """
19 from __future__ import annotations
20
21 import base64
22 import hashlib
23 import json
24
25 import msgpack
26 import pytest
27 from datetime import datetime, timezone
28 from httpx import AsyncClient
29 from sqlalchemy.ext.asyncio import AsyncSession
30
31 from musehub.core.genesis import (
32 compute_identity_id,
33 compute_repo_id,
34 compute_branch_id,
35 )
36
37 # ── fake key material ─────────────────────────────────────────────────────────
38
39 _KEY_X_BYTES = b"\x11" * 32
40 _KEY_X_B64 = "ed25519:" + base64.urlsafe_b64encode(_KEY_X_BYTES).rstrip(b"=").decode()
41
42 _KEY_Y_BYTES = b"\x22" * 32
43 _KEY_Y_B64 = "ed25519:" + base64.urlsafe_b64encode(_KEY_Y_BYTES).rstrip(b"=").decode()
44
45 _NOW = datetime.now(timezone.utc)
46
47 _COUNTER: list[int] = [0]
48
49
50 # ── helpers ───────────────────────────────────────────────────────────────────
51
52
53 def _uid(tag: str = "") -> str:
54 _COUNTER[0] += 1
55 return f"p6{tag}{_COUNTER[0]}"
56
57
58 def _make_identity(handle: str, identity_type: str = "human"):
59 from musehub.db.musehub_models import MusehubIdentity
60 return MusehubIdentity(
61 identity_id=compute_identity_id(handle.encode()),
62 handle=handle,
63 identity_type=identity_type,
64 agent_capabilities=[],
65 pinned_repo_ids=[],
66 is_verified=False,
67 created_at=_NOW,
68 updated_at=_NOW,
69 )
70
71
72 async def _seed_identity_repo(
73 session: AsyncSession,
74 handle: str,
75 pubkey_b64: str,
76 identity_type: str = "human",
77 quorum: int | None = None,
78 display_name: str | None = None,
79 ) -> None:
80 """Create a minimal identity repo whose HEAD IdentityRecord has the given pubkey."""
81 from musehub.db.musehub_models import (
82 MusehubRepo,
83 MusehubObject,
84 MusehubObjectRef,
85 MusehubSnapshot,
86 MusehubCommit,
87 MusehubBranch,
88 )
89
90 identity_id = compute_identity_id(handle.encode())
91 repo_id = compute_repo_id(identity_id, "identity", "identity", _NOW.isoformat())
92
93 repo = MusehubRepo(
94 repo_id=repo_id,
95 name="identity",
96 owner=handle,
97 slug="identity",
98 visibility="private",
99 owner_user_id=identity_id,
100 domain_id="identity",
101 )
102 session.add(repo)
103
104 record: dict = {
105 "handle": handle,
106 "type": identity_type,
107 "pubkey": pubkey_b64,
108 "quorum": quorum,
109 "registered_at": _NOW.isoformat(),
110 "metadata": {"display_name": display_name} if display_name else {},
111 }
112 content = json.dumps(record).encode()
113 file_path = f"identities/{handle}.json"
114 obj_id = "sha256:" + hashlib.sha256(content).hexdigest()
115 snap_id = "sha256:" + hashlib.sha256(f"snap:{repo_id}:{handle}".encode()).hexdigest()
116 cmt_id = "sha256:" + hashlib.sha256(f"cmt:{repo_id}:{handle}".encode()).hexdigest()
117
118 session.add(MusehubObject(
119 object_id=obj_id,
120 path=file_path,
121 size_bytes=len(content),
122 disk_path="",
123 storage_uri=f"local://{obj_id}",
124 content_cache=content,
125 ))
126 session.add(MusehubObjectRef(object_id=obj_id, repo_id=repo_id))
127 session.add(MusehubSnapshot(
128 snapshot_id=snap_id,
129 repo_id=repo_id,
130 directories=[],
131 manifest_blob=msgpack.packb({file_path: obj_id}, use_bin_type=True),
132 entry_count=1,
133 created_at=_NOW,
134 ))
135 session.add(MusehubCommit(
136 commit_id=cmt_id,
137 repo_id=repo_id,
138 branch="main",
139 parent_ids=[],
140 message=f"identity: register {handle}",
141 author=identity_id,
142 timestamp=_NOW,
143 snapshot_id=snap_id,
144 commit_meta={},
145 ))
146 session.add(MusehubBranch(
147 branch_id=compute_branch_id(repo_id, "main"),
148 repo_id=repo_id,
149 name="main",
150 head_commit_id=cmt_id,
151 ))
152 await session.flush()
153
154
155 async def _update_identity_repo_key(
156 session: AsyncSession,
157 handle: str,
158 new_pubkey_b64: str,
159 ) -> None:
160 """Commit a key-rotation update to the identity repo (new HEAD with updated pubkey)."""
161 from musehub.db.musehub_models import (
162 MusehubObject,
163 MusehubObjectRef,
164 MusehubSnapshot,
165 MusehubCommit,
166 MusehubBranch,
167 )
168 from sqlalchemy import select
169
170 identity_id = compute_identity_id(handle.encode())
171 repo_id = compute_repo_id(identity_id, "identity", "identity", _NOW.isoformat())
172
173 record: dict = {
174 "handle": handle,
175 "type": "human",
176 "pubkey": new_pubkey_b64,
177 "quorum": None,
178 "registered_at": _NOW.isoformat(),
179 "metadata": {},
180 }
181 content = json.dumps(record).encode()
182 file_path = f"identities/{handle}.json"
183 obj_id = "sha256:" + hashlib.sha256(content).hexdigest()
184 snap_id = "sha256:" + hashlib.sha256(f"snap2:{repo_id}:{handle}".encode()).hexdigest()
185 cmt_id = "sha256:" + hashlib.sha256(f"cmt2:{repo_id}:{handle}".encode()).hexdigest()
186
187 prev_cmt_id = "sha256:" + hashlib.sha256(f"cmt:{repo_id}:{handle}".encode()).hexdigest()
188
189 session.add(MusehubObject(
190 object_id=obj_id,
191 path=file_path,
192 size_bytes=len(content),
193 disk_path="",
194 storage_uri=f"local://{obj_id}",
195 content_cache=content,
196 ))
197 session.add(MusehubObjectRef(object_id=obj_id, repo_id=repo_id))
198 session.add(MusehubSnapshot(
199 snapshot_id=snap_id,
200 repo_id=repo_id,
201 directories=[],
202 manifest_blob=msgpack.packb({file_path: obj_id}, use_bin_type=True),
203 entry_count=1,
204 created_at=_NOW,
205 ))
206 session.add(MusehubCommit(
207 commit_id=cmt_id,
208 repo_id=repo_id,
209 branch="main",
210 parent_ids=[prev_cmt_id],
211 message=f"identity: rotate key for {handle}",
212 author=identity_id,
213 timestamp=_NOW,
214 snapshot_id=snap_id,
215 commit_meta={},
216 ))
217 # Update branch HEAD
218 branch_result = await session.execute(
219 __import__("sqlalchemy", fromlist=["select"]).select(MusehubBranch).where(
220 MusehubBranch.repo_id == repo_id,
221 MusehubBranch.name == "main",
222 )
223 )
224 branch = branch_result.scalar_one()
225 branch.head_commit_id = cmt_id
226 await session.flush()
227
228
229 # ── import MusehubBranch for _update_identity_repo_key ───────────────────────
230 from musehub.db.musehub_models import MusehubBranch # noqa: E402
231
232
233 # ═══════════════════════════════════════════════════════════════════════════════
234 # 1. pubkey sourced from identity repo HEAD
235 # ═══════════════════════════════════════════════════════════════════════════════
236
237
238 class TestGetIdentityReadsFromRepo:
239 async def test_get_identity_returns_pubkey_from_repo(
240 self, client: AsyncClient, db_session: AsyncSession
241 ) -> None:
242 handle = _uid("alice")
243 identity = _make_identity(handle)
244 db_session.add(identity)
245 await db_session.flush()
246 await _seed_identity_repo(db_session, handle, _KEY_X_B64)
247 await db_session.commit()
248
249 r = await client.get(f"/api/identities/{handle}")
250 assert r.status_code == 200, r.text
251 data = r.json()
252 assert data["pubkey"] == _KEY_X_B64, (
253 f"Expected pubkey from identity repo, got {data.get('pubkey')!r}"
254 )
255
256 async def test_get_identity_pubkey_reflects_key_rotation(
257 self, client: AsyncClient, db_session: AsyncSession
258 ) -> None:
259 """After a rotation commit the endpoint immediately returns the new pubkey."""
260 handle = _uid("bob")
261 identity = _make_identity(handle)
262 db_session.add(identity)
263 await db_session.flush()
264 await _seed_identity_repo(db_session, handle, _KEY_X_B64)
265 await _update_identity_repo_key(db_session, handle, _KEY_Y_B64)
266 await db_session.commit()
267
268 r = await client.get(f"/api/identities/{handle}")
269 assert r.status_code == 200, r.text
270 data = r.json()
271 assert data["pubkey"] == _KEY_Y_B64, (
272 f"Expected rotated pubkey {_KEY_Y_B64!r}, got {data.get('pubkey')!r}"
273 )
274
275 async def test_get_identity_type_from_repo(
276 self, client: AsyncClient, db_session: AsyncSession
277 ) -> None:
278 handle = _uid("carol")
279 identity = _make_identity(handle, identity_type="agent")
280 db_session.add(identity)
281 await db_session.flush()
282 await _seed_identity_repo(db_session, handle, _KEY_X_B64, identity_type="agent")
283 await db_session.commit()
284
285 r = await client.get(f"/api/identities/{handle}")
286 assert r.status_code == 200, r.text
287 assert r.json()["identity_type"] == "agent"
288
289 async def test_get_org_identity_includes_quorum(
290 self, client: AsyncClient, db_session: AsyncSession
291 ) -> None:
292 handle = _uid("myorg")
293 from musehub.db.musehub_models import MusehubIdentity
294 from muse.core.types import blob_id
295 identity = MusehubIdentity(
296 identity_id=blob_id(f"org\x00{handle}\x00{_NOW.isoformat()}".encode()),
297 handle=handle,
298 identity_type="org",
299 org_quorum=3,
300 agent_capabilities=[],
301 pinned_repo_ids=[],
302 is_verified=False,
303 created_at=_NOW,
304 updated_at=_NOW,
305 )
306 db_session.add(identity)
307 await db_session.flush()
308 await _seed_identity_repo(
309 db_session, handle, pubkey_b64=None,
310 identity_type="org", quorum=3, display_name="My Org"
311 )
312 await db_session.commit()
313
314 r = await client.get(f"/api/identities/{handle}")
315 assert r.status_code == 200, r.text
316 data = r.json()
317 assert data["quorum"] == 3, f"Expected quorum=3, got {data.get('quorum')!r}"
318 assert data["identity_type"] == "org"
319
320 async def test_get_identity_display_name_from_repo_metadata(
321 self, client: AsyncClient, db_session: AsyncSession
322 ) -> None:
323 handle = _uid("dave")
324 identity = _make_identity(handle)
325 db_session.add(identity)
326 await db_session.flush()
327 await _seed_identity_repo(
328 db_session, handle, _KEY_X_B64, display_name="Dave Repo Name"
329 )
330 await db_session.commit()
331
332 r = await client.get(f"/api/identities/{handle}")
333 assert r.status_code == 200, r.text
334 data = r.json()
335 assert data["display_name"] == "Dave Repo Name", (
336 f"Expected display_name from identity repo metadata, got {data.get('display_name')!r}"
337 )
338
339
340 # ═══════════════════════════════════════════════════════════════════════════════
341 # 2. DB-only profile fields still appear in response
342 # ═══════════════════════════════════════════════════════════════════════════════
343
344
345 class TestGetIdentityMergesDbEnrichment:
346 async def test_db_bio_present_alongside_repo_pubkey(
347 self, client: AsyncClient, db_session: AsyncSession
348 ) -> None:
349 """bio from DB + pubkey from identity repo both appear in the response."""
350 handle = _uid("eve")
351 from musehub.db.musehub_models import MusehubIdentity
352 identity = MusehubIdentity(
353 identity_id=compute_identity_id(handle.encode()),
354 handle=handle,
355 identity_type="human",
356 bio="A bio from the DB",
357 agent_capabilities=[],
358 pinned_repo_ids=[],
359 is_verified=False,
360 created_at=_NOW,
361 updated_at=_NOW,
362 )
363 db_session.add(identity)
364 await db_session.flush()
365 await _seed_identity_repo(db_session, handle, _KEY_X_B64)
366 await db_session.commit()
367
368 r = await client.get(f"/api/identities/{handle}")
369 assert r.status_code == 200, r.text
370 data = r.json()
371 assert data["pubkey"] == _KEY_X_B64
372 assert data["bio"] == "A bio from the DB", (
373 f"Expected bio from DB, got {data.get('bio')!r}"
374 )
375
376 async def test_db_avatar_url_present(
377 self, client: AsyncClient, db_session: AsyncSession
378 ) -> None:
379 handle = _uid("frank")
380 from musehub.db.musehub_models import MusehubIdentity
381 identity = MusehubIdentity(
382 identity_id=compute_identity_id(handle.encode()),
383 handle=handle,
384 identity_type="human",
385 avatar_url="https://example.com/avatar.png",
386 agent_capabilities=[],
387 pinned_repo_ids=[],
388 is_verified=False,
389 created_at=_NOW,
390 updated_at=_NOW,
391 )
392 db_session.add(identity)
393 await db_session.flush()
394 await _seed_identity_repo(db_session, handle, _KEY_X_B64)
395 await db_session.commit()
396
397 r = await client.get(f"/api/identities/{handle}")
398 assert r.status_code == 200, r.text
399 assert r.json()["avatar_url"] == "https://example.com/avatar.png"
400
401
402 # ═══════════════════════════════════════════════════════════════════════════════
403 # 3. DB fallback when no identity repo exists
404 # ═══════════════════════════════════════════════════════════════════════════════
405
406
407 class TestGetIdentityFallback:
408 async def test_no_identity_repo_falls_back_to_db(
409 self, client: AsyncClient, db_session: AsyncSession
410 ) -> None:
411 """Without an identity repo the endpoint still returns 200 using DB data."""
412 handle = _uid("grace")
413 identity = _make_identity(handle)
414 db_session.add(identity)
415 await db_session.commit()
416
417 r = await client.get(f"/api/identities/{handle}")
418 assert r.status_code == 200, r.text
419 data = r.json()
420 assert data["handle"] == handle
421
422 async def test_no_identity_repo_pubkey_is_null(
423 self, client: AsyncClient, db_session: AsyncSession
424 ) -> None:
425 handle = _uid("hank")
426 identity = _make_identity(handle)
427 db_session.add(identity)
428 await db_session.commit()
429
430 r = await client.get(f"/api/identities/{handle}")
431 assert r.status_code == 200, r.text
432 data = r.json()
433 assert "pubkey" in data, "Response must always include a pubkey field"
434 assert data["pubkey"] is None, (
435 f"Expected pubkey=null without identity repo, got {data['pubkey']!r}"
436 )
437
438 async def test_no_identity_repo_quorum_is_null(
439 self, client: AsyncClient, db_session: AsyncSession
440 ) -> None:
441 handle = _uid("irene")
442 identity = _make_identity(handle)
443 db_session.add(identity)
444 await db_session.commit()
445
446 r = await client.get(f"/api/identities/{handle}")
447 assert r.status_code == 200, r.text
448 data = r.json()
449 assert "quorum" in data, "Response must always include a quorum field"
450 assert data["quorum"] is None
451
452 async def test_unknown_handle_returns_404(
453 self, client: AsyncClient
454 ) -> None:
455 r = await client.get("/api/identities/nobody-p6-zzzzzz")
456 assert r.status_code == 404
File History 1 commit
sha256:bb2baaabdd19320bde50cb69d447fd1c1e571467df729be1a23b5e93064b046a feat(intel): standardize headers, gauge icon, velocity card… Sonnet 4.6 minor 143 days ago