gabriel / musehub public
test_identity_repo_phase6.py python
457 lines 16.8 KB
Raw
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