gabriel / musehub public
test_identity_belt_and_suspenders.py python
731 lines 28.5 KB
Raw
sha256:ef10830ce231e0a20efcb0e2586cb879471247e916616e6fdd0d51df459e2595 fix: typing audit — 0 violations, 0 untyped defs across all… Sonnet 4.6 minor ⚠ breaking 20 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 blob store has
28 no object (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) -> None:
76 from musehub.db.musehub_identity_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) -> None:
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_repo_models import (
114 MusehubRepo,
115 MusehubObject,
116 MusehubObjectRef,
117 MusehubSnapshot,
118 MusehubSnapshotRef,
119 MusehubCommit,
120 MusehubCommitRef,
121 MusehubBranch,
122 )
123
124 repo_id = compute_repo_id(identity_id, "identity", "identity", _NOW.isoformat())
125
126 repo = MusehubRepo(
127 repo_id=repo_id,
128 name="identity",
129 owner=handle,
130 slug="identity",
131 visibility="private",
132 owner_user_id=identity_id,
133 domain_id="identity",
134 )
135 session.add(repo)
136
137 record: JSONObject = {
138 "handle": handle,
139 "type": "human",
140 "pubkey": pubkey_b64,
141 "quorum": None,
142 "registered_at": _NOW.isoformat(),
143 "metadata": {},
144 **(extra_fields or {}),
145 }
146 content = json.dumps(record).encode()
147 file_path = f"identities/{handle}.json"
148 obj_id = blob_id(content)
149 snap_id = blob_id(f"snap:{repo_id}:{handle}".encode())
150 cmt_id = blob_id(f"cmt:{repo_id}:{handle}".encode())
151
152 session.add(MusehubObject(
153 object_id=obj_id,
154 path=file_path,
155 size_bytes=len(content),
156 storage_uri=f"s3://muse-objects/objects/{obj_id}",
157 content_cache=content,
158 ))
159 session.add(MusehubObjectRef(object_id=obj_id, repo_id=repo_id))
160 session.add(MusehubSnapshot(
161 snapshot_id=snap_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(MusehubSnapshotRef(repo_id=repo_id, snapshot_id=snap_id))
168 session.add(MusehubCommit(
169 commit_id=cmt_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(MusehubCommitRef(repo_id=repo_id, commit_id=cmt_id))
178 session.add(MusehubBranch(
179 branch_id=compute_branch_id(repo_id, "main"),
180 repo_id=repo_id,
181 name="main",
182 head_commit_id=cmt_id,
183 ))
184 await session.flush()
185 return repo_id
186
187
188 async def _read_head_record(session: AsyncSession, handle: str) -> JSONObject | None:
189 """Read and return the parsed IdentityRecord from the identity repo HEAD, or None."""
190 from musehub.db.musehub_repo_models import (
191 MusehubRepo, MusehubBranch, MusehubCommit, MusehubSnapshot, MusehubObject,
192 )
193 from musehub.storage.backends import read_object_bytes
194
195 repo_row = (await session.execute(
196 select(MusehubRepo).where(
197 MusehubRepo.owner == handle,
198 MusehubRepo.slug == "identity",
199 )
200 )).scalar_one_or_none()
201 if repo_row is None:
202 return None
203
204 branch = (await session.execute(
205 select(MusehubBranch).where(
206 MusehubBranch.repo_id == repo_row.repo_id,
207 MusehubBranch.name == "main",
208 )
209 )).scalar_one_or_none()
210 if branch is None or branch.head_commit_id is None:
211 return None
212
213 # Expire cached state so we see the latest write from commit_files_to_repo.
214 await session.commit()
215 await session.refresh(branch)
216
217 commit = await session.get(MusehubCommit, branch.head_commit_id)
218 if commit is None or commit.snapshot_id is None:
219 return None
220
221 snap = await session.get(MusehubSnapshot, commit.snapshot_id)
222 if snap is None:
223 return None
224
225 manifest: dict[str, str] = msgpack.unpackb(snap.manifest_blob, raw=False)
226 obj_id = manifest.get(f"identities/{handle}.json")
227 if obj_id is None:
228 return None
229
230 obj = await session.get(MusehubObject, obj_id)
231 if obj is None:
232 return None
233
234 raw = await read_object_bytes(obj)
235 if raw is None:
236 return None
237 try:
238 return json.loads(raw)
239 except Exception:
240 return None
241
242
243 # ═══════════════════════════════════════════════════════════════════════════════
244 # 1. _commit_key_rotation_to_identity_repo — direct tests
245 # ═══════════════════════════════════════════════════════════════════════════════
246
247
248 class TestCommitKeyRotationToIdentityRepo:
249 """Direct tests for _commit_key_rotation_to_identity_repo.
250
251 Phase 5 and Phase 6 tests only verify the effects transitively (via
252 check_quorum or manually-constructed repo states). These tests call the
253 function directly and verify its concrete behaviour.
254 """
255
256 async def test_noop_when_identity_id_not_found(
257 self, db_session: AsyncSession
258 ) -> None:
259 """No crash and no state change when the identity_id does not exist in the DB."""
260 from musehub.services.musehub_auth import _commit_key_rotation_to_identity_repo
261
262 await _commit_key_rotation_to_identity_repo(
263 db_session,
264 identity_id=long_id("ff" * 32), # non-existent
265 new_public_key_b64=_KEY_B_B64,
266 )
267 # If we reach here without an exception the no-op guard works.
268
269 async def test_noop_when_identity_has_no_identity_repo(
270 self, db_session: AsyncSession
271 ) -> None:
272 """No crash and no state change when the identity exists but has no identity repo."""
273 from musehub.services.musehub_auth import _commit_key_rotation_to_identity_repo
274
275 handle = _uid("noirepo")
276 identity_id = compute_identity_id(handle.encode())
277 db_session.add(_make_identity(handle, identity_id))
278 await db_session.commit()
279
280 await _commit_key_rotation_to_identity_repo(
281 db_session,
282 identity_id=identity_id,
283 new_public_key_b64=_KEY_B_B64,
284 )
285 # Still no identity repo and no exception.
286
287 async def test_updates_pubkey_in_head_record(
288 self, db_session: AsyncSession
289 ) -> None:
290 """After rotation the HEAD record carries the new pubkey."""
291 from musehub.services.musehub_auth import _commit_key_rotation_to_identity_repo
292
293 handle = _uid("rot")
294 identity_id = compute_identity_id(handle.encode())
295 db_session.add(_make_identity(handle, identity_id))
296 await db_session.flush()
297 await _seed_identity_repo(db_session, handle, identity_id, _KEY_A_B64)
298 await db_session.commit()
299
300 await _commit_key_rotation_to_identity_repo(
301 db_session,
302 identity_id=identity_id,
303 new_public_key_b64=_KEY_B_B64,
304 )
305
306 record = await _read_head_record(db_session, handle)
307 assert record is not None, "HEAD record must exist after rotation"
308 assert record["pubkey"] == _KEY_B_B64, (
309 f"Expected new pubkey {_KEY_B_B64!r}, got {record.get('pubkey')!r}"
310 )
311
312 async def test_preserves_other_fields_after_rotation(
313 self, db_session: AsyncSession
314 ) -> None:
315 """Rotation rewrites only pubkey; handle, type, metadata, etc. are unchanged."""
316 from musehub.services.musehub_auth import _commit_key_rotation_to_identity_repo
317
318 handle = _uid("preserve")
319 identity_id = compute_identity_id(handle.encode())
320 db_session.add(_make_identity(handle, identity_id))
321 await db_session.flush()
322 await _seed_identity_repo(
323 db_session, handle, identity_id, _KEY_A_B64,
324 extra_fields={"metadata": {"display_name": "Test User", "custom": "value"}},
325 )
326 await db_session.commit()
327
328 await _commit_key_rotation_to_identity_repo(
329 db_session,
330 identity_id=identity_id,
331 new_public_key_b64=_KEY_B_B64,
332 )
333
334 record = await _read_head_record(db_session, handle)
335 assert record is not None
336 assert record["handle"] == handle
337 assert record["type"] == "human"
338 assert record["pubkey"] == _KEY_B_B64
339 # Metadata from original record must survive the rotation.
340 assert record.get("metadata", {}).get("display_name") == "Test User"
341
342 async def test_second_rotation_overwrites_first(
343 self, db_session: AsyncSession
344 ) -> None:
345 """Two consecutive rotations produce a HEAD with the second pubkey."""
346 from musehub.services.musehub_auth import _commit_key_rotation_to_identity_repo
347
348 handle = _uid("double")
349 identity_id = compute_identity_id(handle.encode())
350 db_session.add(_make_identity(handle, identity_id))
351 await db_session.flush()
352 await _seed_identity_repo(db_session, handle, identity_id, _KEY_A_B64)
353 await db_session.commit()
354
355 await _commit_key_rotation_to_identity_repo(
356 db_session, identity_id=identity_id, new_public_key_b64=_KEY_B_B64,
357 )
358
359 _KEY_C_BYTES = b"\xcc" * 32
360 _KEY_C_B64 = encode_pubkey("ed25519", _KEY_C_BYTES)
361
362 await _commit_key_rotation_to_identity_repo(
363 db_session, identity_id=identity_id, new_public_key_b64=_KEY_C_B64,
364 )
365
366 record = await _read_head_record(db_session, handle)
367 assert record is not None
368 assert record["pubkey"] == _KEY_C_B64
369
370
371 # ═══════════════════════════════════════════════════════════════════════════════
372 # 2. State integrity
373 # ═══════════════════════════════════════════════════════════════════════════════
374
375
376 class TestStateIntegrity:
377 """Verify that identity repo HEAD and MusehubAuthKey fingerprint are consistent.
378
379 The system has two sources of truth for a key:
380 - MusehubAuthKey.fingerprint — sha256 of raw public key bytes
381 - Identity repo HEAD record — "pubkey": "ed25519:<base64url>"
382
383 These must always agree. A mismatch would mean the quorum resolver and the
384 auth verifier see different keys for the same identity.
385 """
386
387 async def test_registered_key_fingerprint_matches_identity_repo_pubkey(
388 self, db_session: AsyncSession
389 ) -> None:
390 """Key fingerprint in MusehubAuthKey == sha256(raw_bytes) from identity repo pubkey."""
391 from musehub.crypto.keys import key_fingerprint
392 from muse.core.types import decode_pubkey
393
394 handle = _uid("integ")
395 identity_id = compute_identity_id(handle.encode())
396 db_session.add(_make_identity(handle, identity_id))
397 await db_session.flush()
398
399 db_session.add(_make_auth_key(identity_id, _KEY_A_FP, _KEY_A_B64))
400 await _seed_identity_repo(db_session, handle, identity_id, _KEY_A_B64)
401 await db_session.commit()
402
403 record = await _read_head_record(db_session, handle)
404 assert record is not None
405 repo_pubkey = record["pubkey"]
406
407 _, raw_bytes = decode_pubkey(repo_pubkey)
408 repo_fp = key_fingerprint(raw_bytes)
409
410 assert repo_fp == _KEY_A_FP, (
411 f"Fingerprint mismatch: auth table has {_KEY_A_FP!r}, "
412 f"identity repo HEAD yields {repo_fp!r}"
413 )
414
415 async def test_get_identity_pubkey_consistent_with_auth_key_fingerprint(
416 self, client: AsyncClient, db_session: AsyncSession
417 ) -> None:
418 """GET /api/identities/{handle} pubkey decodes to the registered key fingerprint."""
419 from musehub.crypto.keys import key_fingerprint
420 from muse.core.types import decode_pubkey
421
422 handle = _uid("getinteg")
423 identity_id = compute_identity_id(handle.encode())
424 db_session.add(_make_identity(handle, identity_id))
425 await db_session.flush()
426 db_session.add(_make_auth_key(identity_id, _KEY_A_FP, _KEY_A_B64))
427 await _seed_identity_repo(db_session, handle, identity_id, _KEY_A_B64)
428 await db_session.commit()
429
430 r = await client.get(f"/api/identities/{handle}")
431 assert r.status_code == 200, r.text
432 pubkey_str = r.json()["pubkey"]
433 assert pubkey_str is not None, "pubkey must be present when identity repo exists"
434
435 _, raw_bytes = decode_pubkey(pubkey_str)
436 computed_fp = key_fingerprint(raw_bytes)
437 assert computed_fp == _KEY_A_FP, (
438 f"GET response pubkey yields fingerprint {computed_fp!r}, "
439 f"expected {_KEY_A_FP!r} from the registered auth key"
440 )
441
442 async def test_after_rotation_get_response_and_auth_key_agree(
443 self, client: AsyncClient, db_session: AsyncSession
444 ) -> None:
445 """After rotation, GET response pubkey agrees with the new auth key fingerprint."""
446 from musehub.crypto.keys import key_fingerprint
447 from muse.core.types import decode_pubkey
448 from musehub.services.musehub_auth import _commit_key_rotation_to_identity_repo
449
450 handle = _uid("rotinteg")
451 identity_id = compute_identity_id(handle.encode())
452 db_session.add(_make_identity(handle, identity_id))
453 await db_session.flush()
454 db_session.add(_make_auth_key(identity_id, _KEY_A_FP, _KEY_A_B64))
455 await _seed_identity_repo(db_session, handle, identity_id, _KEY_A_B64)
456 await db_session.commit()
457
458 # Simulate key rotation: add new key to auth table + update identity repo.
459 db_session.add(_make_auth_key(identity_id, _KEY_B_FP, _KEY_B_B64))
460 await _commit_key_rotation_to_identity_repo(
461 db_session, identity_id=identity_id, new_public_key_b64=_KEY_B_B64,
462 )
463 # Commit so the HTTP client's fresh DB session sees the rotated state.
464 await db_session.commit()
465
466 r = await client.get(f"/api/identities/{handle}")
467 assert r.status_code == 200, r.text
468 pubkey_str = r.json()["pubkey"]
469 assert pubkey_str is not None
470
471 _, raw_bytes = decode_pubkey(pubkey_str)
472 computed_fp = key_fingerprint(raw_bytes)
473 assert computed_fp == _KEY_B_FP, (
474 f"After rotation, GET pubkey should yield fingerprint {_KEY_B_FP!r}, "
475 f"got {computed_fp!r}"
476 )
477
478
479 # ═══════════════════════════════════════════════════════════════════════════════
480 # 3. Concurrent stress — read_object_bytes
481 # ═══════════════════════════════════════════════════════════════════════════════
482
483
484 class TestReadObjectBytesStress:
485 """read_object_bytes must be safe under high concurrency with all storage paths."""
486
487 def _cached_obj(self, data: bytes) -> None:
488 from types import SimpleNamespace
489 return SimpleNamespace(
490 object_id=long_id("aa" * 32),
491 content_cache=data,
492 storage_uri="",
493 )
494
495 def _s3_obj(self, object_id: str) -> None:
496 from types import SimpleNamespace
497 return SimpleNamespace(
498 object_id=object_id,
499 content_cache=None,
500 storage_uri=f"s3://muse-objects/{object_id}",
501 )
502
503 async def test_50_concurrent_reads_on_cached_object(self) -> None:
504 from musehub.storage.backends import read_object_bytes
505
506 expected = b"shared cached data " * 100
507 obj = self._cached_obj(expected)
508
509 results = await asyncio.gather(*[read_object_bytes(obj) for _ in range(50)])
510 assert all(r == expected for r in results), (
511 "At least one concurrent cached read returned incorrect bytes"
512 )
513
514 async def test_50_concurrent_reads_on_s3_object(self) -> None:
515 from musehub.storage.backends import read_object_bytes, get_backend
516
517 data = b"shared s3 data " * 100
518 oid = blob_id(data)
519 await get_backend().put(oid, data)
520 obj = self._s3_obj(oid)
521
522 results = await asyncio.gather(*[read_object_bytes(obj) for _ in range(50)])
523 assert all(r == data for r in results), (
524 "At least one concurrent s3 read returned incorrect bytes"
525 )
526
527 async def test_mixed_concurrent_reads_cached_and_s3(self) -> None:
528 """50 concurrent reads across cached and s3 objects all return correct bytes."""
529 from musehub.storage.backends import read_object_bytes, get_backend
530
531 cached_data = b"cached payload"
532 s3_data = b"s3 payload"
533 oid = blob_id(s3_data)
534 await get_backend().put(oid, s3_data)
535
536 cached = self._cached_obj(cached_data)
537 on_s3 = self._s3_obj(oid)
538
539 objs = [cached if i % 2 == 0 else on_s3 for i in range(50)]
540 results = await asyncio.gather(*[read_object_bytes(o) for o in objs])
541
542 for i, result in enumerate(results):
543 expected = cached_data if i % 2 == 0 else s3_data
544 assert result == expected, (
545 f"Read {i}: expected {expected!r}, got {result!r}"
546 )
547
548 async def test_concurrent_reads_complete_under_budget(self) -> None:
549 """100 concurrent in-memory reads must complete in under 0.5 s."""
550 from musehub.storage.backends import read_object_bytes
551
552 obj = self._cached_obj(b"x" * 4096)
553 start = time.perf_counter()
554 await asyncio.gather(*[read_object_bytes(obj) for _ in range(100)])
555 elapsed = time.perf_counter() - start
556 assert elapsed < 0.5, (
557 f"100 concurrent in-memory reads took {elapsed:.3f}s; "
558 "expected < 0.5s"
559 )
560
561
562 # ═══════════════════════════════════════════════════════════════════════════════
563 # 4. Security edge cases
564 # ═══════════════════════════════════════════════════════════════════════════════
565
566
567 class TestSecurityEdgeCases:
568 """Robustness and containment guarantees for identity service security paths."""
569
570 # ── read_object_bytes ──────────────────────────────────────────────────────
571
572 async def test_read_object_bytes_returns_none_when_blob_store_missing(self) -> None:
573 """read_object_bytes never raises — returns None when blob store has no object."""
574 from musehub.storage.backends import read_object_bytes
575 from types import SimpleNamespace
576 from unittest.mock import AsyncMock, MagicMock, patch
577
578 oid = long_id("cc" * 32)
579 obj = SimpleNamespace(
580 object_id=oid,
581 content_cache=None,
582 storage_uri=f"s3://muse-objects/objects/{oid}",
583 )
584 mock_backend = MagicMock()
585 mock_backend.get = AsyncMock(return_value=None)
586 with patch("musehub.storage.backends.get_backend", return_value=mock_backend):
587 result = await read_object_bytes(obj)
588 assert result is None
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 storage_uri="",
599 )
600 assert await read_object_bytes(obj) is None
601
602 # ── resolve_handle_to_fingerprint ─────────────────────────────────────────
603
604 async def test_resolve_handle_malformed_pubkey_returns_none(
605 self, db_session: AsyncSession
606 ) -> None:
607 """A pubkey string without the 'ed25519:' prefix is rejected without raising."""
608 from musehub.services.musehub_governance import resolve_handle_to_fingerprint
609
610 handle = _uid("malkey")
611 identity_id = compute_identity_id(handle.encode())
612 db_session.add(_make_identity(handle, identity_id))
613 await db_session.flush()
614
615 # Seed identity repo with a pubkey that has no canonical prefix.
616 bad_pubkey = base64.urlsafe_b64encode(b"\xaa" * 32).rstrip(b"=").decode()
617 await _seed_identity_repo(db_session, handle, identity_id, bad_pubkey)
618 await db_session.commit()
619
620 result = await resolve_handle_to_fingerprint(db_session, handle)
621 assert result is None, (
622 f"Expected None for malformed pubkey, got {result!r}"
623 )
624
625 async def test_resolve_handle_empty_pubkey_returns_none(
626 self, db_session: AsyncSession
627 ) -> None:
628 """An empty pubkey string in the identity record must return None."""
629 from musehub.services.musehub_governance import resolve_handle_to_fingerprint
630
631 handle = _uid("emptypk")
632 identity_id = compute_identity_id(handle.encode())
633 db_session.add(_make_identity(handle, identity_id))
634 await db_session.flush()
635 await _seed_identity_repo(db_session, handle, identity_id, "")
636 await db_session.commit()
637
638 result = await resolve_handle_to_fingerprint(db_session, handle)
639 assert result is None
640
641 async def test_resolve_handle_no_identity_repo_returns_none(
642 self, db_session: AsyncSession
643 ) -> None:
644 """A handle that exists in the DB but has no identity repo returns None."""
645 from musehub.services.musehub_governance import resolve_handle_to_fingerprint
646
647 handle = _uid("norepo")
648 db_session.add(_make_identity(handle))
649 await db_session.commit()
650
651 result = await resolve_handle_to_fingerprint(db_session, handle)
652 assert result is None
653
654 async def test_resolve_unknown_handle_returns_none(
655 self, db_session: AsyncSession
656 ) -> None:
657 """A handle that does not exist in the DB at all returns None without raising."""
658 from musehub.services.musehub_governance import resolve_handle_to_fingerprint
659
660 result = await resolve_handle_to_fingerprint(db_session, "nobody-bns-zzz")
661 assert result is None
662
663 # ── _read_identity_record_from_repo ───────────────────────────────────────
664
665 async def test_read_identity_record_corrupted_json_returns_none(
666 self, db_session: AsyncSession
667 ) -> None:
668 """Corrupted (non-JSON) bytes in the identity object return None, not an exception."""
669 from musehub.db.musehub_repo_models import (
670 MusehubRepo, MusehubObject, MusehubObjectRef,
671 MusehubSnapshot, MusehubSnapshotRef, MusehubCommit, MusehubCommitRef, MusehubBranch,
672 )
673 from musehub.api.routes.api.identities import _read_identity_record_from_repo
674
675 handle = _uid("corrupt")
676 identity_id = compute_identity_id(handle.encode())
677 repo_id = compute_repo_id(identity_id, "identity", "identity", _NOW.isoformat())
678
679 bad_content = b"\xff\xfe not json at all"
680 file_path = f"identities/{handle}.json"
681 obj_id = blob_id(bad_content)
682 snap_id = blob_id(f"snap:corrupt:{handle}".encode())
683 cmt_id = blob_id(f"cmt:corrupt:{handle}".encode())
684
685 db_session.add(MusehubRepo(
686 repo_id=repo_id,
687 name="identity",
688 owner=handle,
689 slug="identity",
690 visibility="private",
691 owner_user_id=identity_id,
692 domain_id="identity",
693 ))
694 db_session.add(MusehubObject(
695 object_id=obj_id,
696 path=file_path,
697 size_bytes=len(bad_content),
698 storage_uri=f"s3://muse-objects/objects/{obj_id}",
699 content_cache=bad_content,
700 ))
701 db_session.add(MusehubObjectRef(object_id=obj_id, repo_id=repo_id))
702 db_session.add(MusehubSnapshot(
703 snapshot_id=snap_id,
704 directories=[],
705 manifest_blob=msgpack.packb({file_path: obj_id}, use_bin_type=True),
706 entry_count=1,
707 created_at=_NOW,
708 ))
709 db_session.add(MusehubSnapshotRef(repo_id=repo_id, snapshot_id=snap_id))
710 db_session.add(MusehubCommit(
711 commit_id=cmt_id,
712 branch="main",
713 parent_ids=[],
714 message="corrupt init",
715 author=identity_id,
716 timestamp=_NOW,
717 snapshot_id=snap_id,
718 ))
719 db_session.add(MusehubCommitRef(repo_id=repo_id, commit_id=cmt_id))
720 db_session.add(MusehubBranch(
721 branch_id=compute_branch_id(repo_id, "main"),
722 repo_id=repo_id,
723 name="main",
724 head_commit_id=cmt_id,
725 ))
726 await db_session.commit()
727
728 result = await _read_identity_record_from_repo(db_session, handle)
729 assert result is None, (
730 "Expected None for corrupted identity record, got a parsed dict"
731 )
File History 1 commit
sha256:ef10830ce231e0a20efcb0e2586cb879471247e916616e6fdd0d51df459e2595 fix: typing audit — 0 violations, 0 untyped defs across all… Sonnet 4.6 minor 20 days ago