test_identity_repo_phase6.py
python
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