test_identity_repo_phase6.py
python
sha256:ef10830ce231e0a20efcb0e2586cb879471247e916616e6fdd0d51df459e2595
fix: typing audit — 0 violations, 0 untyped defs across all…
Sonnet 4.6
minor
⚠ breaking
20 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") -> None: |
| 60 | from musehub.db.musehub_identity_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_repo_models import ( |
| 83 | MusehubRepo, |
| 84 | MusehubObject, |
| 85 | MusehubObjectRef, |
| 86 | MusehubSnapshot, |
| 87 | MusehubSnapshotRef, |
| 88 | MusehubCommit, |
| 89 | MusehubCommitRef, |
| 90 | MusehubBranch, |
| 91 | ) |
| 92 | |
| 93 | identity_id = compute_identity_id(handle.encode()) |
| 94 | repo_id = compute_repo_id(identity_id, "identity", "identity", _NOW.isoformat()) |
| 95 | |
| 96 | repo = MusehubRepo( |
| 97 | repo_id=repo_id, |
| 98 | name="identity", |
| 99 | owner=handle, |
| 100 | slug="identity", |
| 101 | visibility="private", |
| 102 | owner_user_id=identity_id, |
| 103 | domain_id="identity", |
| 104 | ) |
| 105 | session.add(repo) |
| 106 | |
| 107 | record: JSONObject = { |
| 108 | "handle": handle, |
| 109 | "type": identity_type, |
| 110 | "pubkey": pubkey_b64, |
| 111 | "quorum": quorum, |
| 112 | "registered_at": _NOW.isoformat(), |
| 113 | "metadata": {"display_name": display_name} if display_name else {}, |
| 114 | } |
| 115 | content = json.dumps(record).encode() |
| 116 | file_path = f"identities/{handle}.json" |
| 117 | obj_id = blob_id(content) |
| 118 | snap_id = blob_id(f"snap:{repo_id}:{handle}".encode()) |
| 119 | cmt_id = blob_id(f"cmt:{repo_id}:{handle}".encode()) |
| 120 | |
| 121 | session.add(MusehubObject( |
| 122 | object_id=obj_id, |
| 123 | path=file_path, |
| 124 | size_bytes=len(content), |
| 125 | storage_uri=f"s3://muse-objects/objects/{obj_id}", |
| 126 | content_cache=content, |
| 127 | )) |
| 128 | session.add(MusehubObjectRef(object_id=obj_id, repo_id=repo_id)) |
| 129 | session.add(MusehubSnapshot( |
| 130 | snapshot_id=snap_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(MusehubSnapshotRef(repo_id=repo_id, snapshot_id=snap_id)) |
| 137 | session.add(MusehubCommit( |
| 138 | commit_id=cmt_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(MusehubCommitRef(repo_id=repo_id, commit_id=cmt_id)) |
| 147 | session.add(MusehubBranch( |
| 148 | branch_id=compute_branch_id(repo_id, "main"), |
| 149 | repo_id=repo_id, |
| 150 | name="main", |
| 151 | head_commit_id=cmt_id, |
| 152 | )) |
| 153 | await session.flush() |
| 154 | |
| 155 | |
| 156 | async def _update_identity_repo_key( |
| 157 | session: AsyncSession, |
| 158 | handle: str, |
| 159 | new_pubkey_b64: str, |
| 160 | ) -> None: |
| 161 | """Commit a key-rotation update to the identity repo (new HEAD with updated pubkey).""" |
| 162 | from musehub.db.musehub_repo_models import ( |
| 163 | MusehubObject, |
| 164 | MusehubObjectRef, |
| 165 | MusehubSnapshot, |
| 166 | MusehubSnapshotRef, |
| 167 | MusehubCommit, |
| 168 | MusehubCommitRef, |
| 169 | MusehubBranch, |
| 170 | ) |
| 171 | from sqlalchemy import select |
| 172 | |
| 173 | identity_id = compute_identity_id(handle.encode()) |
| 174 | repo_id = compute_repo_id(identity_id, "identity", "identity", _NOW.isoformat()) |
| 175 | |
| 176 | record: JSONObject = { |
| 177 | "handle": handle, |
| 178 | "type": "human", |
| 179 | "pubkey": new_pubkey_b64, |
| 180 | "quorum": None, |
| 181 | "registered_at": _NOW.isoformat(), |
| 182 | "metadata": {}, |
| 183 | } |
| 184 | content = json.dumps(record).encode() |
| 185 | file_path = f"identities/{handle}.json" |
| 186 | obj_id = blob_id(content) |
| 187 | snap_id = blob_id(f"snap2:{repo_id}:{handle}".encode()) |
| 188 | cmt_id = blob_id(f"cmt2:{repo_id}:{handle}".encode()) |
| 189 | |
| 190 | prev_cmt_id = blob_id(f"cmt:{repo_id}:{handle}".encode()) |
| 191 | |
| 192 | session.add(MusehubObject( |
| 193 | object_id=obj_id, |
| 194 | path=file_path, |
| 195 | size_bytes=len(content), |
| 196 | storage_uri=f"s3://muse-objects/objects/{obj_id}", |
| 197 | content_cache=content, |
| 198 | )) |
| 199 | session.add(MusehubObjectRef(object_id=obj_id, repo_id=repo_id)) |
| 200 | session.add(MusehubSnapshot( |
| 201 | snapshot_id=snap_id, |
| 202 | directories=[], |
| 203 | manifest_blob=msgpack.packb({file_path: obj_id}, use_bin_type=True), |
| 204 | entry_count=1, |
| 205 | created_at=_NOW, |
| 206 | )) |
| 207 | session.add(MusehubSnapshotRef(repo_id=repo_id, snapshot_id=snap_id)) |
| 208 | session.add(MusehubCommit( |
| 209 | commit_id=cmt_id, |
| 210 | branch="main", |
| 211 | parent_ids=[prev_cmt_id], |
| 212 | message=f"identity: rotate key for {handle}", |
| 213 | author=identity_id, |
| 214 | timestamp=_NOW, |
| 215 | snapshot_id=snap_id, |
| 216 | )) |
| 217 | session.add(MusehubCommitRef(repo_id=repo_id, commit_id=cmt_id)) |
| 218 | # Update branch HEAD |
| 219 | branch_result = await session.execute( |
| 220 | __import__("sqlalchemy", fromlist=["select"]).select(MusehubBranch).where( |
| 221 | MusehubBranch.repo_id == repo_id, |
| 222 | MusehubBranch.name == "main", |
| 223 | ) |
| 224 | ) |
| 225 | branch = branch_result.scalar_one() |
| 226 | branch.head_commit_id = cmt_id |
| 227 | await session.flush() |
| 228 | |
| 229 | |
| 230 | # ── import MusehubBranch for _update_identity_repo_key ─────────────────────── |
| 231 | from musehub.db.musehub_repo_models import MusehubBranch # noqa: E402 |
| 232 | |
| 233 | |
| 234 | # ═══════════════════════════════════════════════════════════════════════════════ |
| 235 | # 1. pubkey sourced from identity repo HEAD |
| 236 | # ═══════════════════════════════════════════════════════════════════════════════ |
| 237 | |
| 238 | |
| 239 | class TestGetIdentityReadsFromRepo: |
| 240 | async def test_get_identity_returns_pubkey_from_repo( |
| 241 | self, client: AsyncClient, db_session: AsyncSession |
| 242 | ) -> None: |
| 243 | handle = _uid("alice") |
| 244 | identity = _make_identity(handle) |
| 245 | db_session.add(identity) |
| 246 | await db_session.flush() |
| 247 | await _seed_identity_repo(db_session, handle, _KEY_X_B64) |
| 248 | await db_session.commit() |
| 249 | |
| 250 | r = await client.get(f"/api/identities/{handle}") |
| 251 | assert r.status_code == 200, r.text |
| 252 | data = r.json() |
| 253 | assert data["pubkey"] == _KEY_X_B64, ( |
| 254 | f"Expected pubkey from identity repo, got {data.get('pubkey')!r}" |
| 255 | ) |
| 256 | |
| 257 | async def test_get_identity_pubkey_reflects_key_rotation( |
| 258 | self, client: AsyncClient, db_session: AsyncSession |
| 259 | ) -> None: |
| 260 | """After a rotation commit the endpoint immediately returns the new pubkey.""" |
| 261 | handle = _uid("bob") |
| 262 | identity = _make_identity(handle) |
| 263 | db_session.add(identity) |
| 264 | await db_session.flush() |
| 265 | await _seed_identity_repo(db_session, handle, _KEY_X_B64) |
| 266 | await _update_identity_repo_key(db_session, handle, _KEY_Y_B64) |
| 267 | await db_session.commit() |
| 268 | |
| 269 | r = await client.get(f"/api/identities/{handle}") |
| 270 | assert r.status_code == 200, r.text |
| 271 | data = r.json() |
| 272 | assert data["pubkey"] == _KEY_Y_B64, ( |
| 273 | f"Expected rotated pubkey {_KEY_Y_B64!r}, got {data.get('pubkey')!r}" |
| 274 | ) |
| 275 | |
| 276 | async def test_get_identity_type_from_repo( |
| 277 | self, client: AsyncClient, db_session: AsyncSession |
| 278 | ) -> None: |
| 279 | handle = _uid("carol") |
| 280 | identity = _make_identity(handle, identity_type="agent") |
| 281 | db_session.add(identity) |
| 282 | await db_session.flush() |
| 283 | await _seed_identity_repo(db_session, handle, _KEY_X_B64, identity_type="agent") |
| 284 | await db_session.commit() |
| 285 | |
| 286 | r = await client.get(f"/api/identities/{handle}") |
| 287 | assert r.status_code == 200, r.text |
| 288 | assert r.json()["identity_type"] == "agent" |
| 289 | |
| 290 | async def test_get_org_identity_includes_quorum( |
| 291 | self, client: AsyncClient, db_session: AsyncSession |
| 292 | ) -> None: |
| 293 | handle = _uid("myorg") |
| 294 | from musehub.db.musehub_identity_models import MusehubIdentity |
| 295 | from muse.core.types import blob_id |
| 296 | identity = MusehubIdentity( |
| 297 | identity_id=blob_id(f"org\x00{handle}\x00{_NOW.isoformat()}".encode()), |
| 298 | handle=handle, |
| 299 | identity_type="org", |
| 300 | org_quorum=3, |
| 301 | agent_capabilities=[], |
| 302 | pinned_repo_ids=[], |
| 303 | is_verified=False, |
| 304 | created_at=_NOW, |
| 305 | updated_at=_NOW, |
| 306 | ) |
| 307 | db_session.add(identity) |
| 308 | await db_session.flush() |
| 309 | await _seed_identity_repo( |
| 310 | db_session, handle, pubkey_b64=None, |
| 311 | identity_type="org", quorum=3, display_name="My Org" |
| 312 | ) |
| 313 | await db_session.commit() |
| 314 | |
| 315 | r = await client.get(f"/api/identities/{handle}") |
| 316 | assert r.status_code == 200, r.text |
| 317 | data = r.json() |
| 318 | assert data["quorum"] == 3, f"Expected quorum=3, got {data.get('quorum')!r}" |
| 319 | assert data["identity_type"] == "org" |
| 320 | |
| 321 | async def test_get_identity_display_name_from_repo_metadata( |
| 322 | self, client: AsyncClient, db_session: AsyncSession |
| 323 | ) -> None: |
| 324 | handle = _uid("dave") |
| 325 | identity = _make_identity(handle) |
| 326 | db_session.add(identity) |
| 327 | await db_session.flush() |
| 328 | await _seed_identity_repo( |
| 329 | db_session, handle, _KEY_X_B64, display_name="Dave Repo Name" |
| 330 | ) |
| 331 | await db_session.commit() |
| 332 | |
| 333 | r = await client.get(f"/api/identities/{handle}") |
| 334 | assert r.status_code == 200, r.text |
| 335 | data = r.json() |
| 336 | assert data["display_name"] == "Dave Repo Name", ( |
| 337 | f"Expected display_name from identity repo metadata, got {data.get('display_name')!r}" |
| 338 | ) |
| 339 | |
| 340 | |
| 341 | # ═══════════════════════════════════════════════════════════════════════════════ |
| 342 | # 2. DB-only profile fields still appear in response |
| 343 | # ═══════════════════════════════════════════════════════════════════════════════ |
| 344 | |
| 345 | |
| 346 | class TestGetIdentityMergesDbEnrichment: |
| 347 | async def test_db_bio_present_alongside_repo_pubkey( |
| 348 | self, client: AsyncClient, db_session: AsyncSession |
| 349 | ) -> None: |
| 350 | """bio from DB + pubkey from identity repo both appear in the response.""" |
| 351 | handle = _uid("eve") |
| 352 | from musehub.db.musehub_identity_models import MusehubIdentity |
| 353 | identity = MusehubIdentity( |
| 354 | identity_id=compute_identity_id(handle.encode()), |
| 355 | handle=handle, |
| 356 | identity_type="human", |
| 357 | bio="A bio from the DB", |
| 358 | agent_capabilities=[], |
| 359 | pinned_repo_ids=[], |
| 360 | is_verified=False, |
| 361 | created_at=_NOW, |
| 362 | updated_at=_NOW, |
| 363 | ) |
| 364 | db_session.add(identity) |
| 365 | await db_session.flush() |
| 366 | await _seed_identity_repo(db_session, handle, _KEY_X_B64) |
| 367 | await db_session.commit() |
| 368 | |
| 369 | r = await client.get(f"/api/identities/{handle}") |
| 370 | assert r.status_code == 200, r.text |
| 371 | data = r.json() |
| 372 | assert data["pubkey"] == _KEY_X_B64 |
| 373 | assert data["bio"] == "A bio from the DB", ( |
| 374 | f"Expected bio from DB, got {data.get('bio')!r}" |
| 375 | ) |
| 376 | |
| 377 | async def test_db_avatar_url_present( |
| 378 | self, client: AsyncClient, db_session: AsyncSession |
| 379 | ) -> None: |
| 380 | handle = _uid("frank") |
| 381 | from musehub.db.musehub_identity_models import MusehubIdentity |
| 382 | identity = MusehubIdentity( |
| 383 | identity_id=compute_identity_id(handle.encode()), |
| 384 | handle=handle, |
| 385 | identity_type="human", |
| 386 | avatar_url="https://example.com/avatar.png", |
| 387 | agent_capabilities=[], |
| 388 | pinned_repo_ids=[], |
| 389 | is_verified=False, |
| 390 | created_at=_NOW, |
| 391 | updated_at=_NOW, |
| 392 | ) |
| 393 | db_session.add(identity) |
| 394 | await db_session.flush() |
| 395 | await _seed_identity_repo(db_session, handle, _KEY_X_B64) |
| 396 | await db_session.commit() |
| 397 | |
| 398 | r = await client.get(f"/api/identities/{handle}") |
| 399 | assert r.status_code == 200, r.text |
| 400 | assert r.json()["avatar_url"] == "https://example.com/avatar.png" |
| 401 | |
| 402 | |
| 403 | # ═══════════════════════════════════════════════════════════════════════════════ |
| 404 | # 3. DB fallback when no identity repo exists |
| 405 | # ═══════════════════════════════════════════════════════════════════════════════ |
| 406 | |
| 407 | |
| 408 | class TestGetIdentityFallback: |
| 409 | async def test_no_identity_repo_falls_back_to_db( |
| 410 | self, client: AsyncClient, db_session: AsyncSession |
| 411 | ) -> None: |
| 412 | """Without an identity repo the endpoint still returns 200 using DB data.""" |
| 413 | handle = _uid("grace") |
| 414 | identity = _make_identity(handle) |
| 415 | db_session.add(identity) |
| 416 | await db_session.commit() |
| 417 | |
| 418 | r = await client.get(f"/api/identities/{handle}") |
| 419 | assert r.status_code == 200, r.text |
| 420 | data = r.json() |
| 421 | assert data["handle"] == handle |
| 422 | |
| 423 | async def test_no_identity_repo_pubkey_is_null( |
| 424 | self, client: AsyncClient, db_session: AsyncSession |
| 425 | ) -> None: |
| 426 | handle = _uid("hank") |
| 427 | identity = _make_identity(handle) |
| 428 | db_session.add(identity) |
| 429 | await db_session.commit() |
| 430 | |
| 431 | r = await client.get(f"/api/identities/{handle}") |
| 432 | assert r.status_code == 200, r.text |
| 433 | data = r.json() |
| 434 | assert "pubkey" in data, "Response must always include a pubkey field" |
| 435 | assert data["pubkey"] is None, ( |
| 436 | f"Expected pubkey=null without identity repo, got {data['pubkey']!r}" |
| 437 | ) |
| 438 | |
| 439 | async def test_no_identity_repo_quorum_is_null( |
| 440 | self, client: AsyncClient, db_session: AsyncSession |
| 441 | ) -> None: |
| 442 | handle = _uid("irene") |
| 443 | identity = _make_identity(handle) |
| 444 | db_session.add(identity) |
| 445 | await db_session.commit() |
| 446 | |
| 447 | r = await client.get(f"/api/identities/{handle}") |
| 448 | assert r.status_code == 200, r.text |
| 449 | data = r.json() |
| 450 | assert "quorum" in data, "Response must always include a quorum field" |
| 451 | assert data["quorum"] is None |
| 452 | |
| 453 | async def test_unknown_handle_returns_404( |
| 454 | self, client: AsyncClient |
| 455 | ) -> None: |
| 456 | r = await client.get("/api/identities/nobody-p6-zzzzzz") |
| 457 | assert r.status_code == 404 |
File History
1 commit
sha256:ef10830ce231e0a20efcb0e2586cb879471247e916616e6fdd0d51df459e2595
fix: typing audit — 0 violations, 0 untyped defs across all…
Sonnet 4.6
minor
⚠
20 days ago