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