"""Phase 6 — GET /api/identities/{handle} reads from identity repo HEAD. TDD regression suite: every test starts RED and turns GREEN as the feature is implemented. Their permanent role is to prevent regressions. What this phase covers: - GET /api/identities/{handle} response includes a top-level `pubkey` field sourced from the identity repo HEAD IdentityRecord - After a key-rotation commit to the identity repo, the response reflects the new pubkey immediately (no DB update required) - `identity_type` is sourced from the identity repo `type` field - For org identities, `quorum` appears in the response from the repo record - DB-only profile fields (bio, avatar_url, etc.) still appear alongside identity-repo data (the repo is canonical truth; the DB supplies enrichment) - When no identity repo exists yet (migration period), the endpoint falls back to the DB and returns `pubkey: null` without erroring - 404 when the handle does not exist at all """ from __future__ import annotations import base64 import json import msgpack import pytest from datetime import datetime, timezone from httpx import AsyncClient from sqlalchemy.ext.asyncio import AsyncSession from muse.core.types import blob_id, encode_pubkey from musehub.core.genesis import ( compute_identity_id, compute_repo_id, compute_branch_id, ) from musehub.types.json_types import JSONObject # ── fake key material ───────────────────────────────────────────────────────── _KEY_X_BYTES = b"\x11" * 32 _KEY_X_B64 = encode_pubkey("ed25519", _KEY_X_BYTES) _KEY_Y_BYTES = b"\x22" * 32 _KEY_Y_B64 = encode_pubkey("ed25519", _KEY_Y_BYTES) _NOW = datetime.now(timezone.utc) _COUNTER: list[int] = [0] # ── helpers ─────────────────────────────────────────────────────────────────── def _uid(tag: str = "") -> str: _COUNTER[0] += 1 return f"p6{tag}{_COUNTER[0]}" def _make_identity(handle: str, identity_type: str = "human") -> None: from musehub.db.musehub_identity_models import MusehubIdentity return MusehubIdentity( identity_id=compute_identity_id(handle.encode()), handle=handle, identity_type=identity_type, agent_capabilities=[], pinned_repo_ids=[], is_verified=False, created_at=_NOW, updated_at=_NOW, ) async def _seed_identity_repo( session: AsyncSession, handle: str, pubkey_b64: str, identity_type: str = "human", quorum: int | None = None, display_name: str | None = None, ) -> None: """Create a minimal identity repo whose HEAD IdentityRecord has the given pubkey.""" from musehub.db.musehub_repo_models import ( MusehubRepo, MusehubObject, MusehubObjectRef, MusehubSnapshot, MusehubSnapshotRef, MusehubCommit, MusehubCommitRef, MusehubBranch, ) identity_id = compute_identity_id(handle.encode()) repo_id = compute_repo_id(identity_id, "identity", "identity", _NOW.isoformat()) repo = MusehubRepo( repo_id=repo_id, name="identity", owner=handle, slug="identity", visibility="private", owner_user_id=identity_id, domain_id="identity", ) session.add(repo) record: JSONObject = { "handle": handle, "type": identity_type, "pubkey": pubkey_b64, "quorum": quorum, "registered_at": _NOW.isoformat(), "metadata": {"display_name": display_name} if display_name else {}, } content = json.dumps(record).encode() file_path = f"identities/{handle}.json" obj_id = blob_id(content) snap_id = blob_id(f"snap:{repo_id}:{handle}".encode()) cmt_id = blob_id(f"cmt:{repo_id}:{handle}".encode()) session.add(MusehubObject( object_id=obj_id, path=file_path, size_bytes=len(content), storage_uri=f"s3://muse-objects/objects/{obj_id}", content_cache=content, )) session.add(MusehubObjectRef(object_id=obj_id, repo_id=repo_id)) session.add(MusehubSnapshot( snapshot_id=snap_id, directories=[], manifest_blob=msgpack.packb({file_path: obj_id}, use_bin_type=True), entry_count=1, created_at=_NOW, )) session.add(MusehubSnapshotRef(repo_id=repo_id, snapshot_id=snap_id)) session.add(MusehubCommit( commit_id=cmt_id, branch="main", parent_ids=[], message=f"identity: register {handle}", author=identity_id, timestamp=_NOW, snapshot_id=snap_id, )) session.add(MusehubCommitRef(repo_id=repo_id, commit_id=cmt_id)) session.add(MusehubBranch( branch_id=compute_branch_id(repo_id, "main"), repo_id=repo_id, name="main", head_commit_id=cmt_id, )) await session.flush() async def _update_identity_repo_key( session: AsyncSession, handle: str, new_pubkey_b64: str, ) -> None: """Commit a key-rotation update to the identity repo (new HEAD with updated pubkey).""" from musehub.db.musehub_repo_models import ( MusehubObject, MusehubObjectRef, MusehubSnapshot, MusehubSnapshotRef, MusehubCommit, MusehubCommitRef, MusehubBranch, ) from sqlalchemy import select identity_id = compute_identity_id(handle.encode()) repo_id = compute_repo_id(identity_id, "identity", "identity", _NOW.isoformat()) record: JSONObject = { "handle": handle, "type": "human", "pubkey": new_pubkey_b64, "quorum": None, "registered_at": _NOW.isoformat(), "metadata": {}, } content = json.dumps(record).encode() file_path = f"identities/{handle}.json" obj_id = blob_id(content) snap_id = blob_id(f"snap2:{repo_id}:{handle}".encode()) cmt_id = blob_id(f"cmt2:{repo_id}:{handle}".encode()) prev_cmt_id = blob_id(f"cmt:{repo_id}:{handle}".encode()) session.add(MusehubObject( object_id=obj_id, path=file_path, size_bytes=len(content), storage_uri=f"s3://muse-objects/objects/{obj_id}", content_cache=content, )) session.add(MusehubObjectRef(object_id=obj_id, repo_id=repo_id)) session.add(MusehubSnapshot( snapshot_id=snap_id, directories=[], manifest_blob=msgpack.packb({file_path: obj_id}, use_bin_type=True), entry_count=1, created_at=_NOW, )) session.add(MusehubSnapshotRef(repo_id=repo_id, snapshot_id=snap_id)) session.add(MusehubCommit( commit_id=cmt_id, branch="main", parent_ids=[prev_cmt_id], message=f"identity: rotate key for {handle}", author=identity_id, timestamp=_NOW, snapshot_id=snap_id, )) session.add(MusehubCommitRef(repo_id=repo_id, commit_id=cmt_id)) # Update branch HEAD branch_result = await session.execute( __import__("sqlalchemy", fromlist=["select"]).select(MusehubBranch).where( MusehubBranch.repo_id == repo_id, MusehubBranch.name == "main", ) ) branch = branch_result.scalar_one() branch.head_commit_id = cmt_id await session.flush() # ── import MusehubBranch for _update_identity_repo_key ─────────────────────── from musehub.db.musehub_repo_models import MusehubBranch # noqa: E402 # ═══════════════════════════════════════════════════════════════════════════════ # 1. pubkey sourced from identity repo HEAD # ═══════════════════════════════════════════════════════════════════════════════ class TestGetIdentityReadsFromRepo: async def test_get_identity_returns_pubkey_from_repo( self, client: AsyncClient, db_session: AsyncSession ) -> None: handle = _uid("alice") identity = _make_identity(handle) db_session.add(identity) await db_session.flush() await _seed_identity_repo(db_session, handle, _KEY_X_B64) await db_session.commit() r = await client.get(f"/api/identities/{handle}") assert r.status_code == 200, r.text data = r.json() assert data["pubkey"] == _KEY_X_B64, ( f"Expected pubkey from identity repo, got {data.get('pubkey')!r}" ) async def test_get_identity_pubkey_reflects_key_rotation( self, client: AsyncClient, db_session: AsyncSession ) -> None: """After a rotation commit the endpoint immediately returns the new pubkey.""" handle = _uid("bob") identity = _make_identity(handle) db_session.add(identity) await db_session.flush() await _seed_identity_repo(db_session, handle, _KEY_X_B64) await _update_identity_repo_key(db_session, handle, _KEY_Y_B64) await db_session.commit() r = await client.get(f"/api/identities/{handle}") assert r.status_code == 200, r.text data = r.json() assert data["pubkey"] == _KEY_Y_B64, ( f"Expected rotated pubkey {_KEY_Y_B64!r}, got {data.get('pubkey')!r}" ) async def test_get_identity_type_from_repo( self, client: AsyncClient, db_session: AsyncSession ) -> None: handle = _uid("carol") identity = _make_identity(handle, identity_type="agent") db_session.add(identity) await db_session.flush() await _seed_identity_repo(db_session, handle, _KEY_X_B64, identity_type="agent") await db_session.commit() r = await client.get(f"/api/identities/{handle}") assert r.status_code == 200, r.text assert r.json()["identity_type"] == "agent" async def test_get_org_identity_includes_quorum( self, client: AsyncClient, db_session: AsyncSession ) -> None: handle = _uid("myorg") from musehub.db.musehub_identity_models import MusehubIdentity from muse.core.types import blob_id identity = MusehubIdentity( identity_id=blob_id(f"org\x00{handle}\x00{_NOW.isoformat()}".encode()), handle=handle, identity_type="org", org_quorum=3, agent_capabilities=[], pinned_repo_ids=[], is_verified=False, created_at=_NOW, updated_at=_NOW, ) db_session.add(identity) await db_session.flush() await _seed_identity_repo( db_session, handle, pubkey_b64=None, identity_type="org", quorum=3, display_name="My Org" ) await db_session.commit() r = await client.get(f"/api/identities/{handle}") assert r.status_code == 200, r.text data = r.json() assert data["quorum"] == 3, f"Expected quorum=3, got {data.get('quorum')!r}" assert data["identity_type"] == "org" async def test_get_identity_display_name_from_repo_metadata( self, client: AsyncClient, db_session: AsyncSession ) -> None: handle = _uid("dave") identity = _make_identity(handle) db_session.add(identity) await db_session.flush() await _seed_identity_repo( db_session, handle, _KEY_X_B64, display_name="Dave Repo Name" ) await db_session.commit() r = await client.get(f"/api/identities/{handle}") assert r.status_code == 200, r.text data = r.json() assert data["display_name"] == "Dave Repo Name", ( f"Expected display_name from identity repo metadata, got {data.get('display_name')!r}" ) # ═══════════════════════════════════════════════════════════════════════════════ # 2. DB-only profile fields still appear in response # ═══════════════════════════════════════════════════════════════════════════════ class TestGetIdentityMergesDbEnrichment: async def test_db_bio_present_alongside_repo_pubkey( self, client: AsyncClient, db_session: AsyncSession ) -> None: """bio from DB + pubkey from identity repo both appear in the response.""" handle = _uid("eve") from musehub.db.musehub_identity_models import MusehubIdentity identity = MusehubIdentity( identity_id=compute_identity_id(handle.encode()), handle=handle, identity_type="human", bio="A bio from the DB", agent_capabilities=[], pinned_repo_ids=[], is_verified=False, created_at=_NOW, updated_at=_NOW, ) db_session.add(identity) await db_session.flush() await _seed_identity_repo(db_session, handle, _KEY_X_B64) await db_session.commit() r = await client.get(f"/api/identities/{handle}") assert r.status_code == 200, r.text data = r.json() assert data["pubkey"] == _KEY_X_B64 assert data["bio"] == "A bio from the DB", ( f"Expected bio from DB, got {data.get('bio')!r}" ) async def test_db_avatar_url_present( self, client: AsyncClient, db_session: AsyncSession ) -> None: handle = _uid("frank") from musehub.db.musehub_identity_models import MusehubIdentity identity = MusehubIdentity( identity_id=compute_identity_id(handle.encode()), handle=handle, identity_type="human", avatar_url="https://example.com/avatar.png", agent_capabilities=[], pinned_repo_ids=[], is_verified=False, created_at=_NOW, updated_at=_NOW, ) db_session.add(identity) await db_session.flush() await _seed_identity_repo(db_session, handle, _KEY_X_B64) await db_session.commit() r = await client.get(f"/api/identities/{handle}") assert r.status_code == 200, r.text assert r.json()["avatar_url"] == "https://example.com/avatar.png" # ═══════════════════════════════════════════════════════════════════════════════ # 3. DB fallback when no identity repo exists # ═══════════════════════════════════════════════════════════════════════════════ class TestGetIdentityFallback: async def test_no_identity_repo_falls_back_to_db( self, client: AsyncClient, db_session: AsyncSession ) -> None: """Without an identity repo the endpoint still returns 200 using DB data.""" handle = _uid("grace") identity = _make_identity(handle) db_session.add(identity) await db_session.commit() r = await client.get(f"/api/identities/{handle}") assert r.status_code == 200, r.text data = r.json() assert data["handle"] == handle async def test_no_identity_repo_pubkey_is_null( self, client: AsyncClient, db_session: AsyncSession ) -> None: handle = _uid("hank") identity = _make_identity(handle) db_session.add(identity) await db_session.commit() r = await client.get(f"/api/identities/{handle}") assert r.status_code == 200, r.text data = r.json() assert "pubkey" in data, "Response must always include a pubkey field" assert data["pubkey"] is None, ( f"Expected pubkey=null without identity repo, got {data['pubkey']!r}" ) async def test_no_identity_repo_quorum_is_null( self, client: AsyncClient, db_session: AsyncSession ) -> None: handle = _uid("irene") identity = _make_identity(handle) db_session.add(identity) await db_session.commit() r = await client.get(f"/api/identities/{handle}") assert r.status_code == 200, r.text data = r.json() assert "quorum" in data, "Response must always include a quorum field" assert data["quorum"] is None async def test_unknown_handle_returns_404( self, client: AsyncClient ) -> None: r = await client.get("/api/identities/nobody-p6-zzzzzz") assert r.status_code == 404