gabriel / musehub public
test_identity_belt_and_suspenders.py python
733 lines 28.1 KB
Raw
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef debug(push/stream): instrument O-frame decode path with INF… Sonnet 4.6 patch 121 days ago
1 """Belt-and-suspenders tests for the identity plugin implementation (phases 1–6).
2
3 Covers gaps not addressed by test_identity_repo_phase{1-6}.py:
4
5 1. _commit_key_rotation_to_identity_repo — direct unit/integration tests
6 - no-op when identity_id is not in the DB
7 - no-op when identity has no identity repo
8 - correctly writes new pubkey to HEAD
9 - preserves all other record fields after rotation
10
11 2. State integrity
12 - pubkey in identity repo HEAD and registered MusehubAuthKey fingerprint are
13 consistent with each other (sha256 of raw key bytes matches)
14 - after key rotation the GET /api/identities/{handle} response is in sync with
15 the auth key table
16
17 3. Concurrent stress
18 - 50 concurrent read_object_bytes calls on a content-cached object all return
19 the same bytes
20 - mixed concurrent reads (cached + disk) all return correct bytes
21
22 4. Security edge cases
23 - resolve_handle_to_fingerprint returns None when the identity record contains
24 a malformed pubkey string (no "ed25519:" prefix)
25 - resolve_handle_to_fingerprint returns None when the identity record JSON is
26 corrupted
27 - read_object_bytes returns None rather than raising when the disk path does
28 not exist (exception containment guarantee)
29 """
30 from __future__ import annotations
31
32 import asyncio
33 import base64
34 import json
35 import time
36
37 import msgpack
38 import pytest
39 from datetime import datetime, timezone
40 from httpx import AsyncClient
41 from sqlalchemy.ext.asyncio import AsyncSession
42 from sqlalchemy import select
43
44 from muse.core.types import long_id, blob_id, encode_pubkey, public_key_fingerprint
45 from musehub.core.genesis import (
46 compute_identity_id,
47 compute_repo_id,
48 compute_branch_id,
49 )
50 from musehub.types.json_types import JSONObject
51
52 # ── key material ──────────────────────────────────────────────────────────────
53
54 _KEY_A_BYTES = b"\xaa" * 32
55 _KEY_A_FP = public_key_fingerprint(_KEY_A_BYTES)
56 _KEY_A_B64 = encode_pubkey("ed25519", _KEY_A_BYTES)
57
58 _KEY_B_BYTES = b"\xbb" * 32
59 _KEY_B_FP = public_key_fingerprint(_KEY_B_BYTES)
60 _KEY_B_B64 = encode_pubkey("ed25519", _KEY_B_BYTES)
61
62 _NOW = datetime.now(timezone.utc)
63
64 _COUNTER: list[int] = [0]
65
66
67 def _uid(tag: str = "") -> str:
68 _COUNTER[0] += 1
69 return f"bns{tag}{_COUNTER[0]}"
70
71
72 # ── DB row factories ──────────────────────────────────────────────────────────
73
74
75 def _make_identity(handle: str, identity_id: str | None = None):
76 from musehub.db.musehub_models import MusehubIdentity
77 return MusehubIdentity(
78 identity_id=identity_id or compute_identity_id(handle.encode()),
79 handle=handle,
80 identity_type="human",
81 agent_capabilities=[],
82 pinned_repo_ids=[],
83 is_verified=False,
84 created_at=_NOW,
85 updated_at=_NOW,
86 )
87
88
89 def _make_auth_key(identity_id: str, fingerprint: str, pubkey_b64: str):
90 from musehub.db.musehub_auth_models import MusehubAuthKey
91 return MusehubAuthKey(
92 key_id=fingerprint,
93 identity_id=identity_id,
94 algorithm="ed25519",
95 public_key_b64=pubkey_b64,
96 fingerprint=fingerprint,
97 label="test key",
98 created_at=_NOW,
99 )
100
101
102 async def _seed_identity_repo(
103 session: AsyncSession,
104 handle: str,
105 identity_id: str,
106 pubkey_b64: str,
107 extra_fields: JSONObject | None = None,
108 ) -> str:
109 """Create a minimal identity repo whose HEAD IdentityRecord has the given pubkey.
110
111 Returns the repo_id.
112 """
113 from musehub.db.musehub_models import (
114 MusehubRepo,
115 MusehubObject,
116 MusehubObjectRef,
117 MusehubSnapshot,
118 MusehubCommit,
119 MusehubBranch,
120 )
121
122 repo_id = compute_repo_id(identity_id, "identity", "identity", _NOW.isoformat())
123
124 repo = MusehubRepo(
125 repo_id=repo_id,
126 name="identity",
127 owner=handle,
128 slug="identity",
129 visibility="private",
130 owner_user_id=identity_id,
131 domain_id="identity",
132 )
133 session.add(repo)
134
135 record: JSONObject = {
136 "handle": handle,
137 "type": "human",
138 "pubkey": pubkey_b64,
139 "quorum": None,
140 "registered_at": _NOW.isoformat(),
141 "metadata": {},
142 **(extra_fields or {}),
143 }
144 content = json.dumps(record).encode()
145 file_path = f"identities/{handle}.json"
146 obj_id = blob_id(content)
147 snap_id = blob_id(f"snap:{repo_id}:{handle}".encode())
148 cmt_id = blob_id(f"cmt:{repo_id}:{handle}".encode())
149
150 session.add(MusehubObject(
151 object_id=obj_id,
152 path=file_path,
153 size_bytes=len(content),
154 disk_path="",
155 storage_uri=f"local://{obj_id}",
156 content_cache=content,
157 ))
158 session.add(MusehubObjectRef(object_id=obj_id, repo_id=repo_id))
159 session.add(MusehubSnapshot(
160 snapshot_id=snap_id,
161 repo_id=repo_id,
162 directories=[],
163 manifest_blob=msgpack.packb({file_path: obj_id}, use_bin_type=True),
164 entry_count=1,
165 created_at=_NOW,
166 ))
167 session.add(MusehubCommit(
168 commit_id=cmt_id,
169 repo_id=repo_id,
170 branch="main",
171 parent_ids=[],
172 message=f"identity: register {handle}",
173 author=identity_id,
174 timestamp=_NOW,
175 snapshot_id=snap_id,
176 ))
177 session.add(MusehubBranch(
178 branch_id=compute_branch_id(repo_id, "main"),
179 repo_id=repo_id,
180 name="main",
181 head_commit_id=cmt_id,
182 ))
183 await session.flush()
184 return repo_id
185
186
187 async def _read_head_record(session: AsyncSession, handle: str) -> JSONObject | None:
188 """Read and return the parsed IdentityRecord from the identity repo HEAD, or None."""
189 from musehub.db.musehub_models import (
190 MusehubRepo, MusehubBranch, MusehubCommit, MusehubSnapshot, MusehubObject,
191 )
192 from musehub.storage.backends import read_object_bytes
193
194 repo_row = (await session.execute(
195 select(MusehubRepo).where(
196 MusehubRepo.owner == handle,
197 MusehubRepo.slug == "identity",
198 )
199 )).scalar_one_or_none()
200 if repo_row is None:
201 return None
202
203 branch = (await session.execute(
204 select(MusehubBranch).where(
205 MusehubBranch.repo_id == repo_row.repo_id,
206 MusehubBranch.name == "main",
207 )
208 )).scalar_one_or_none()
209 if branch is None or branch.head_commit_id is None:
210 return None
211
212 # Expire cached state so we see the latest write from commit_files_to_repo.
213 await session.commit()
214 await session.refresh(branch)
215
216 commit = await session.get(MusehubCommit, branch.head_commit_id)
217 if commit is None or commit.snapshot_id is None:
218 return None
219
220 snap = await session.get(MusehubSnapshot, commit.snapshot_id)
221 if snap is None:
222 return None
223
224 manifest: dict[str, str] = msgpack.unpackb(snap.manifest_blob, raw=False)
225 obj_id = manifest.get(f"identities/{handle}.json")
226 if obj_id is None:
227 return None
228
229 obj = await session.get(MusehubObject, obj_id)
230 if obj is None:
231 return None
232
233 raw = await read_object_bytes(obj)
234 if raw is None:
235 return None
236 try:
237 return json.loads(raw)
238 except Exception:
239 return None
240
241
242 # ═══════════════════════════════════════════════════════════════════════════════
243 # 1. _commit_key_rotation_to_identity_repo — direct tests
244 # ═══════════════════════════════════════════════════════════════════════════════
245
246
247 class TestCommitKeyRotationToIdentityRepo:
248 """Direct tests for _commit_key_rotation_to_identity_repo.
249
250 Phase 5 and Phase 6 tests only verify the effects transitively (via
251 check_quorum or manually-constructed repo states). These tests call the
252 function directly and verify its concrete behaviour.
253 """
254
255 async def test_noop_when_identity_id_not_found(
256 self, db_session: AsyncSession
257 ) -> None:
258 """No crash and no state change when the identity_id does not exist in the DB."""
259 from musehub.services.musehub_auth import _commit_key_rotation_to_identity_repo
260
261 await _commit_key_rotation_to_identity_repo(
262 db_session,
263 identity_id=long_id("ff" * 32), # non-existent
264 new_public_key_b64=_KEY_B_B64,
265 )
266 # If we reach here without an exception the no-op guard works.
267
268 async def test_noop_when_identity_has_no_identity_repo(
269 self, db_session: AsyncSession
270 ) -> None:
271 """No crash and no state change when the identity exists but has no identity repo."""
272 from musehub.services.musehub_auth import _commit_key_rotation_to_identity_repo
273
274 handle = _uid("noirepo")
275 identity_id = compute_identity_id(handle.encode())
276 db_session.add(_make_identity(handle, identity_id))
277 await db_session.commit()
278
279 await _commit_key_rotation_to_identity_repo(
280 db_session,
281 identity_id=identity_id,
282 new_public_key_b64=_KEY_B_B64,
283 )
284 # Still no identity repo and no exception.
285
286 async def test_updates_pubkey_in_head_record(
287 self, db_session: AsyncSession
288 ) -> None:
289 """After rotation the HEAD record carries the new pubkey."""
290 from musehub.services.musehub_auth import _commit_key_rotation_to_identity_repo
291
292 handle = _uid("rot")
293 identity_id = compute_identity_id(handle.encode())
294 db_session.add(_make_identity(handle, identity_id))
295 await db_session.flush()
296 await _seed_identity_repo(db_session, handle, identity_id, _KEY_A_B64)
297 await db_session.commit()
298
299 await _commit_key_rotation_to_identity_repo(
300 db_session,
301 identity_id=identity_id,
302 new_public_key_b64=_KEY_B_B64,
303 )
304
305 record = await _read_head_record(db_session, handle)
306 assert record is not None, "HEAD record must exist after rotation"
307 assert record["pubkey"] == _KEY_B_B64, (
308 f"Expected new pubkey {_KEY_B_B64!r}, got {record.get('pubkey')!r}"
309 )
310
311 async def test_preserves_other_fields_after_rotation(
312 self, db_session: AsyncSession
313 ) -> None:
314 """Rotation rewrites only pubkey; handle, type, metadata, etc. are unchanged."""
315 from musehub.services.musehub_auth import _commit_key_rotation_to_identity_repo
316
317 handle = _uid("preserve")
318 identity_id = compute_identity_id(handle.encode())
319 db_session.add(_make_identity(handle, identity_id))
320 await db_session.flush()
321 await _seed_identity_repo(
322 db_session, handle, identity_id, _KEY_A_B64,
323 extra_fields={"metadata": {"display_name": "Test User", "custom": "value"}},
324 )
325 await db_session.commit()
326
327 await _commit_key_rotation_to_identity_repo(
328 db_session,
329 identity_id=identity_id,
330 new_public_key_b64=_KEY_B_B64,
331 )
332
333 record = await _read_head_record(db_session, handle)
334 assert record is not None
335 assert record["handle"] == handle
336 assert record["type"] == "human"
337 assert record["pubkey"] == _KEY_B_B64
338 # Metadata from original record must survive the rotation.
339 assert record.get("metadata", {}).get("display_name") == "Test User"
340
341 async def test_second_rotation_overwrites_first(
342 self, db_session: AsyncSession
343 ) -> None:
344 """Two consecutive rotations produce a HEAD with the second pubkey."""
345 from musehub.services.musehub_auth import _commit_key_rotation_to_identity_repo
346
347 handle = _uid("double")
348 identity_id = compute_identity_id(handle.encode())
349 db_session.add(_make_identity(handle, identity_id))
350 await db_session.flush()
351 await _seed_identity_repo(db_session, handle, identity_id, _KEY_A_B64)
352 await db_session.commit()
353
354 await _commit_key_rotation_to_identity_repo(
355 db_session, identity_id=identity_id, new_public_key_b64=_KEY_B_B64,
356 )
357
358 _KEY_C_BYTES = b"\xcc" * 32
359 _KEY_C_B64 = encode_pubkey("ed25519", _KEY_C_BYTES)
360
361 await _commit_key_rotation_to_identity_repo(
362 db_session, identity_id=identity_id, new_public_key_b64=_KEY_C_B64,
363 )
364
365 record = await _read_head_record(db_session, handle)
366 assert record is not None
367 assert record["pubkey"] == _KEY_C_B64
368
369
370 # ═══════════════════════════════════════════════════════════════════════════════
371 # 2. State integrity
372 # ═══════════════════════════════════════════════════════════════════════════════
373
374
375 class TestStateIntegrity:
376 """Verify that identity repo HEAD and MusehubAuthKey fingerprint are consistent.
377
378 The system has two sources of truth for a key:
379 - MusehubAuthKey.fingerprint — sha256 of raw public key bytes
380 - Identity repo HEAD record — "pubkey": "ed25519:<base64url>"
381
382 These must always agree. A mismatch would mean the quorum resolver and the
383 auth verifier see different keys for the same identity.
384 """
385
386 async def test_registered_key_fingerprint_matches_identity_repo_pubkey(
387 self, db_session: AsyncSession
388 ) -> None:
389 """Key fingerprint in MusehubAuthKey == sha256(raw_bytes) from identity repo pubkey."""
390 from musehub.crypto.keys import key_fingerprint
391 from muse.core.types import decode_pubkey
392
393 handle = _uid("integ")
394 identity_id = compute_identity_id(handle.encode())
395 db_session.add(_make_identity(handle, identity_id))
396 await db_session.flush()
397
398 db_session.add(_make_auth_key(identity_id, _KEY_A_FP, _KEY_A_B64))
399 await _seed_identity_repo(db_session, handle, identity_id, _KEY_A_B64)
400 await db_session.commit()
401
402 record = await _read_head_record(db_session, handle)
403 assert record is not None
404 repo_pubkey = record["pubkey"]
405
406 _, raw_bytes = decode_pubkey(repo_pubkey)
407 repo_fp = key_fingerprint(raw_bytes)
408
409 assert repo_fp == _KEY_A_FP, (
410 f"Fingerprint mismatch: auth table has {_KEY_A_FP!r}, "
411 f"identity repo HEAD yields {repo_fp!r}"
412 )
413
414 async def test_get_identity_pubkey_consistent_with_auth_key_fingerprint(
415 self, client: AsyncClient, db_session: AsyncSession
416 ) -> None:
417 """GET /api/identities/{handle} pubkey decodes to the registered key fingerprint."""
418 from musehub.crypto.keys import key_fingerprint
419 from muse.core.types import decode_pubkey
420
421 handle = _uid("getinteg")
422 identity_id = compute_identity_id(handle.encode())
423 db_session.add(_make_identity(handle, identity_id))
424 await db_session.flush()
425 db_session.add(_make_auth_key(identity_id, _KEY_A_FP, _KEY_A_B64))
426 await _seed_identity_repo(db_session, handle, identity_id, _KEY_A_B64)
427 await db_session.commit()
428
429 r = await client.get(f"/api/identities/{handle}")
430 assert r.status_code == 200, r.text
431 pubkey_str = r.json()["pubkey"]
432 assert pubkey_str is not None, "pubkey must be present when identity repo exists"
433
434 _, raw_bytes = decode_pubkey(pubkey_str)
435 computed_fp = key_fingerprint(raw_bytes)
436 assert computed_fp == _KEY_A_FP, (
437 f"GET response pubkey yields fingerprint {computed_fp!r}, "
438 f"expected {_KEY_A_FP!r} from the registered auth key"
439 )
440
441 async def test_after_rotation_get_response_and_auth_key_agree(
442 self, client: AsyncClient, db_session: AsyncSession
443 ) -> None:
444 """After rotation, GET response pubkey agrees with the new auth key fingerprint."""
445 from musehub.crypto.keys import key_fingerprint
446 from muse.core.types import decode_pubkey
447 from musehub.services.musehub_auth import _commit_key_rotation_to_identity_repo
448
449 handle = _uid("rotinteg")
450 identity_id = compute_identity_id(handle.encode())
451 db_session.add(_make_identity(handle, identity_id))
452 await db_session.flush()
453 db_session.add(_make_auth_key(identity_id, _KEY_A_FP, _KEY_A_B64))
454 await _seed_identity_repo(db_session, handle, identity_id, _KEY_A_B64)
455 await db_session.commit()
456
457 # Simulate key rotation: add new key to auth table + update identity repo.
458 db_session.add(_make_auth_key(identity_id, _KEY_B_FP, _KEY_B_B64))
459 await _commit_key_rotation_to_identity_repo(
460 db_session, identity_id=identity_id, new_public_key_b64=_KEY_B_B64,
461 )
462 # Commit so the HTTP client's fresh DB session sees the rotated state.
463 await db_session.commit()
464
465 r = await client.get(f"/api/identities/{handle}")
466 assert r.status_code == 200, r.text
467 pubkey_str = r.json()["pubkey"]
468 assert pubkey_str is not None
469
470 _, raw_bytes = decode_pubkey(pubkey_str)
471 computed_fp = key_fingerprint(raw_bytes)
472 assert computed_fp == _KEY_B_FP, (
473 f"After rotation, GET pubkey should yield fingerprint {_KEY_B_FP!r}, "
474 f"got {computed_fp!r}"
475 )
476
477
478 # ═══════════════════════════════════════════════════════════════════════════════
479 # 3. Concurrent stress — read_object_bytes
480 # ═══════════════════════════════════════════════════════════════════════════════
481
482
483 class TestReadObjectBytesStress:
484 """read_object_bytes must be safe under high concurrency with all storage paths."""
485
486 def _cached_obj(self, data: bytes):
487 from types import SimpleNamespace
488 return SimpleNamespace(
489 object_id=long_id("aa" * 32),
490 content_cache=data,
491 disk_path="",
492 storage_uri="",
493 )
494
495 def _disk_obj(self, path: str):
496 from types import SimpleNamespace
497 return SimpleNamespace(
498 object_id=long_id("bb" * 32),
499 content_cache=None,
500 disk_path=path,
501 storage_uri="",
502 )
503
504 async def test_50_concurrent_reads_on_cached_object(self) -> None:
505 from musehub.storage.backends import read_object_bytes
506
507 expected = b"shared cached data " * 100
508 obj = self._cached_obj(expected)
509
510 results = await asyncio.gather(*[read_object_bytes(obj) for _ in range(50)])
511 assert all(r == expected for r in results), (
512 "At least one concurrent cached read returned incorrect bytes"
513 )
514
515 async def test_50_concurrent_reads_on_disk_object(self, tmp_path) -> None:
516 from musehub.storage.backends import read_object_bytes
517
518 data = b"shared disk data " * 100
519 disk_file = tmp_path / "shared_obj"
520 disk_file.write_bytes(data)
521 obj = self._disk_obj(str(disk_file))
522
523 results = await asyncio.gather(*[read_object_bytes(obj) for _ in range(50)])
524 assert all(r == data for r in results), (
525 "At least one concurrent disk read returned incorrect bytes"
526 )
527
528 async def test_mixed_concurrent_reads_cached_and_disk(self, tmp_path) -> None:
529 """50 concurrent reads across cached and disk objects all return correct bytes."""
530 from musehub.storage.backends import read_object_bytes
531
532 cached_data = b"cached payload"
533 disk_data = b"disk payload"
534 disk_file = tmp_path / "mixed_obj"
535 disk_file.write_bytes(disk_data)
536
537 cached = self._cached_obj(cached_data)
538 on_disk = self._disk_obj(str(disk_file))
539
540 # Interleave reads of both objects.
541 objs = [cached if i % 2 == 0 else on_disk for i in range(50)]
542 results = await asyncio.gather(*[read_object_bytes(o) for o in objs])
543
544 for i, result in enumerate(results):
545 expected = cached_data if i % 2 == 0 else disk_data
546 assert result == expected, (
547 f"Read {i}: expected {expected!r}, got {result!r}"
548 )
549
550 async def test_concurrent_reads_complete_under_budget(self) -> None:
551 """100 concurrent in-memory reads must complete in under 0.5 s."""
552 from musehub.storage.backends import read_object_bytes
553
554 obj = self._cached_obj(b"x" * 4096)
555 start = time.perf_counter()
556 await asyncio.gather(*[read_object_bytes(obj) for _ in range(100)])
557 elapsed = time.perf_counter() - start
558 assert elapsed < 0.5, (
559 f"100 concurrent in-memory reads took {elapsed:.3f}s; "
560 "expected < 0.5s"
561 )
562
563
564 # ═══════════════════════════════════════════════════════════════════════════════
565 # 4. Security edge cases
566 # ═══════════════════════════════════════════════════════════════════════════════
567
568
569 class TestSecurityEdgeCases:
570 """Robustness and containment guarantees for identity service security paths."""
571
572 # ── read_object_bytes ──────────────────────────────────────────────────────
573
574 async def test_read_object_bytes_returns_none_on_missing_disk_path(
575 self, tmp_path
576 ) -> None:
577 """read_object_bytes never raises — returns None for non-existent disk path."""
578 from musehub.storage.backends import read_object_bytes
579 from types import SimpleNamespace
580
581 obj = SimpleNamespace(
582 object_id=long_id("cc" * 32),
583 content_cache=None,
584 disk_path=str(tmp_path / "does_not_exist"),
585 storage_uri="",
586 )
587 result = await read_object_bytes(obj)
588 assert result is None, "Expected None for a missing disk path, not an exception"
589
590 async def test_read_object_bytes_returns_none_on_empty_paths(self) -> None:
591 """read_object_bytes returns None when no cache and no URI are set."""
592 from musehub.storage.backends import read_object_bytes
593 from types import SimpleNamespace
594
595 obj = SimpleNamespace(
596 object_id=long_id("dd" * 32),
597 content_cache=None,
598 disk_path="",
599 storage_uri="",
600 )
601 assert await read_object_bytes(obj) is None
602
603 # ── resolve_handle_to_fingerprint ─────────────────────────────────────────
604
605 async def test_resolve_handle_malformed_pubkey_returns_none(
606 self, db_session: AsyncSession
607 ) -> None:
608 """A pubkey string without the 'ed25519:' prefix is rejected without raising."""
609 from musehub.services.musehub_governance import resolve_handle_to_fingerprint
610
611 handle = _uid("malkey")
612 identity_id = compute_identity_id(handle.encode())
613 db_session.add(_make_identity(handle, identity_id))
614 await db_session.flush()
615
616 # Seed identity repo with a pubkey that has no canonical prefix.
617 bad_pubkey = base64.urlsafe_b64encode(b"\xaa" * 32).rstrip(b"=").decode()
618 await _seed_identity_repo(db_session, handle, identity_id, bad_pubkey)
619 await db_session.commit()
620
621 result = await resolve_handle_to_fingerprint(db_session, handle)
622 assert result is None, (
623 f"Expected None for malformed pubkey, got {result!r}"
624 )
625
626 async def test_resolve_handle_empty_pubkey_returns_none(
627 self, db_session: AsyncSession
628 ) -> None:
629 """An empty pubkey string in the identity record must return None."""
630 from musehub.services.musehub_governance import resolve_handle_to_fingerprint
631
632 handle = _uid("emptypk")
633 identity_id = compute_identity_id(handle.encode())
634 db_session.add(_make_identity(handle, identity_id))
635 await db_session.flush()
636 await _seed_identity_repo(db_session, handle, identity_id, "")
637 await db_session.commit()
638
639 result = await resolve_handle_to_fingerprint(db_session, handle)
640 assert result is None
641
642 async def test_resolve_handle_no_identity_repo_returns_none(
643 self, db_session: AsyncSession
644 ) -> None:
645 """A handle that exists in the DB but has no identity repo returns None."""
646 from musehub.services.musehub_governance import resolve_handle_to_fingerprint
647
648 handle = _uid("norepo")
649 db_session.add(_make_identity(handle))
650 await db_session.commit()
651
652 result = await resolve_handle_to_fingerprint(db_session, handle)
653 assert result is None
654
655 async def test_resolve_unknown_handle_returns_none(
656 self, db_session: AsyncSession
657 ) -> None:
658 """A handle that does not exist in the DB at all returns None without raising."""
659 from musehub.services.musehub_governance import resolve_handle_to_fingerprint
660
661 result = await resolve_handle_to_fingerprint(db_session, "nobody-bns-zzz")
662 assert result is None
663
664 # ── _read_identity_record_from_repo ───────────────────────────────────────
665
666 async def test_read_identity_record_corrupted_json_returns_none(
667 self, db_session: AsyncSession
668 ) -> None:
669 """Corrupted (non-JSON) bytes in the identity object return None, not an exception."""
670 from musehub.db.musehub_models import (
671 MusehubRepo, MusehubObject, MusehubObjectRef,
672 MusehubSnapshot, MusehubCommit, MusehubBranch,
673 )
674 from musehub.api.routes.api.identities import _read_identity_record_from_repo
675
676 handle = _uid("corrupt")
677 identity_id = compute_identity_id(handle.encode())
678 repo_id = compute_repo_id(identity_id, "identity", "identity", _NOW.isoformat())
679
680 bad_content = b"\xff\xfe not json at all"
681 file_path = f"identities/{handle}.json"
682 obj_id = blob_id(bad_content)
683 snap_id = blob_id(f"snap:corrupt:{handle}".encode())
684 cmt_id = blob_id(f"cmt:corrupt:{handle}".encode())
685
686 db_session.add(MusehubRepo(
687 repo_id=repo_id,
688 name="identity",
689 owner=handle,
690 slug="identity",
691 visibility="private",
692 owner_user_id=identity_id,
693 domain_id="identity",
694 ))
695 db_session.add(MusehubObject(
696 object_id=obj_id,
697 path=file_path,
698 size_bytes=len(bad_content),
699 disk_path="",
700 storage_uri=f"local://{obj_id}",
701 content_cache=bad_content,
702 ))
703 db_session.add(MusehubObjectRef(object_id=obj_id, repo_id=repo_id))
704 db_session.add(MusehubSnapshot(
705 snapshot_id=snap_id,
706 repo_id=repo_id,
707 directories=[],
708 manifest_blob=msgpack.packb({file_path: obj_id}, use_bin_type=True),
709 entry_count=1,
710 created_at=_NOW,
711 ))
712 db_session.add(MusehubCommit(
713 commit_id=cmt_id,
714 repo_id=repo_id,
715 branch="main",
716 parent_ids=[],
717 message="corrupt init",
718 author=identity_id,
719 timestamp=_NOW,
720 snapshot_id=snap_id,
721 ))
722 db_session.add(MusehubBranch(
723 branch_id=compute_branch_id(repo_id, "main"),
724 repo_id=repo_id,
725 name="main",
726 head_commit_id=cmt_id,
727 ))
728 await db_session.commit()
729
730 result = await _read_identity_record_from_repo(db_session, handle)
731 assert result is None, (
732 "Expected None for corrupted identity record, got a parsed dict"
733 )
File History 1 commit
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef debug(push/stream): instrument O-frame decode path with INF… Sonnet 4.6 patch 121 days ago