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