gabriel / musehub public

test_admin_repair_identity_repo.py file-level

at sha256:b · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 πŸ’₯ blast risk
sha256:0 fix: fall back to any indexed mpack in read_object_bytes when push mpac… · gabriel · Jun 17, 2026
1 """TDD β€” POST /api/admin/repair-identity-repo.
2
3 Repairs a missing identity repo for an existing identity. This endpoint is
4 needed when a DB reset or migration leaves identities whose repo was never
5 created (or was wiped), so they can authenticate but have no canonical
6 identity DAG.
7
8 Test IDs:
9 AR_01 β€” 403 for non-admin caller
10 AR_02 β€” 404 when handle does not exist
11 AR_03 β€” creates repo when missing, returns {"created": true}
12 AR_04 β€” idempotent when repo already exists, returns {"created": false}
13 AR_05 β€” created repo is private, domain_id="identity", HEAD has correct pubkey
14
15 Integration tests (marked with @pytest.mark.integration) require localhost hub.
16 """
17 from __future__ import annotations
18
19 import json
20 from datetime import datetime, timezone
21
22 import pytest
23 import pytest_asyncio
24 from httpx import AsyncClient
25 from sqlalchemy.ext.asyncio import AsyncSession
26
27 from muse.core.types import encode_pubkey
28 from musehub.auth.request_signing import MSignContext, require_signed_request
29 from musehub.core.genesis import compute_identity_id
30 from musehub.db.musehub_auth_models import MusehubAuthKey
31 from musehub.db.musehub_identity_models import MusehubIdentity
32 from musehub.db.musehub_repo_models import MusehubRepo
33 from musehub.main import app
34
35 # ── fake key material ─────────────────────────────────────────────────────────
36
37 import hashlib as _hashlib
38
39 def _key_for(handle: str) -> str:
40 """Deterministic unique key per handle β€” avoids fingerprint UNIQUE collisions across tests."""
41 raw = _hashlib.sha256(handle.encode()).digest()
42 return encode_pubkey("ed25519", raw)
43
44 _NOW = datetime.now(timezone.utc)
45 _COUNTER: list[int] = [0]
46
47
48 def _uid(tag: str = "") -> str:
49 _COUNTER[0] += 1
50 return f"ar{tag}{_COUNTER[0]}"
51
52
53 def _make_ctx(is_admin: bool = False, handle: str = "admin-actor") -> MSignContext:
54 from muse.core.types import long_id
55 return MSignContext(
56 identity_id=long_id(handle.ljust(64, "0")[:64]),
57 handle=handle,
58 is_agent=False,
59 is_admin=is_admin,
60 )
61
62
63 async def _seed_identity_with_key(
64 session: AsyncSession,
65 handle: str,
66 pubkey_b64: str | None = None,
67 ) -> MusehubIdentity:
68 """Insert identity + one registered key. Does NOT create the identity repo."""
69 import base64
70 from musehub.crypto.keys import key_fingerprint
71 from musehub.core.genesis import compute_key_id
72
73 if pubkey_b64 is None:
74 pubkey_b64 = _key_for(handle)
75
76 identity_id = compute_identity_id(handle.encode())
77 identity = MusehubIdentity(
78 identity_id=identity_id,
79 handle=handle,
80 identity_type="human",
81 is_admin=False,
82 agent_capabilities=[],
83 pinned_repo_ids=[],
84 is_verified=False,
85 created_at=_NOW,
86 updated_at=_NOW,
87 )
88 session.add(identity)
89 # Flush identity first so the FK on the key insert resolves.
90 await session.flush()
91
92 _, raw_b64 = pubkey_b64.split(":", 1)
93 raw_bytes = base64.urlsafe_b64decode(raw_b64 + "==")
94 fp = key_fingerprint(raw_bytes)
95 key = MusehubAuthKey(
96 key_id=compute_key_id(identity_id, pubkey_b64),
97 identity_id=identity_id,
98 algorithm="ed25519",
99 public_key_b64=pubkey_b64,
100 fingerprint=fp,
101 label="test-key",
102 created_at=_NOW,
103 )
104 session.add(key)
105 await session.flush()
106 return identity
107
108
109 async def _has_identity_repo(session: AsyncSession, handle: str) -> bool:
110 from sqlalchemy import select
111 result = await session.execute(
112 select(MusehubRepo).where(
113 MusehubRepo.owner == handle,
114 MusehubRepo.slug == "identity",
115 )
116 )
117 return result.scalar_one_or_none() is not None
118
119
120 # ── AR_01 β€” 403 for non-admin ─────────────────────────────────────────────────
121
122
123 @pytest.mark.asyncio
124 async def test_ar01_non_admin_gets_403(client: AsyncClient) -> None:
125 """AR_01 β€” caller with is_admin=False must receive 403."""
126 app.dependency_overrides[require_signed_request] = lambda: _make_ctx(is_admin=False)
127 try:
128 resp = await client.post(
129 "/api/admin/repair-identity-repo",
130 json={"handle": "anyone"},
131 )
132 assert resp.status_code == 403, resp.text
133 finally:
134 app.dependency_overrides.pop(require_signed_request, None)
135
136
137 # ── AR_02 β€” 404 for unknown handle ───────────────────────────────────────────
138
139
140 @pytest.mark.asyncio
141 async def test_ar02_unknown_handle_gets_404(client: AsyncClient) -> None:
142 """AR_02 β€” handle that has no identity row must receive 404."""
143 app.dependency_overrides[require_signed_request] = lambda: _make_ctx(is_admin=True)
144 try:
145 resp = await client.post(
146 "/api/admin/repair-identity-repo",
147 json={"handle": f"ghost-{_uid()}"},
148 )
149 assert resp.status_code == 404, resp.text
150 finally:
151 app.dependency_overrides.pop(require_signed_request, None)
152
153
154 # ── AR_03 β€” creates repo when missing ────────────────────────────────────────
155
156
157 @pytest.mark.asyncio
158 async def test_ar03_creates_repo_when_missing(
159 client: AsyncClient,
160 db_session: AsyncSession,
161 ) -> None:
162 """AR_03 β€” when identity exists but has no repo, endpoint creates it and returns created=true."""
163 handle = f"repair-{_uid()}"
164 await _seed_identity_with_key(db_session, handle)
165 await db_session.commit()
166
167 assert not await _has_identity_repo(db_session, handle), "pre-condition: no repo yet"
168
169 app.dependency_overrides[require_signed_request] = lambda: _make_ctx(is_admin=True)
170 try:
171 resp = await client.post(
172 "/api/admin/repair-identity-repo",
173 json={"handle": handle},
174 )
175 assert resp.status_code == 200, resp.text
176 body = resp.json()
177 assert body.get("created") is True, f"expected created=true, got {body}"
178 finally:
179 app.dependency_overrides.pop(require_signed_request, None)
180
181 await db_session.reset()
182 assert await _has_identity_repo(db_session, handle), "repo must exist after repair"
183
184
185 # ── AR_04 β€” idempotent when repo already exists ───────────────────────────────
186
187
188 @pytest.mark.asyncio
189 async def test_ar04_idempotent_when_repo_exists(
190 client: AsyncClient,
191 db_session: AsyncSession,
192 ) -> None:
193 """AR_04 β€” calling repair when repo already exists returns created=false without error."""
194 handle = f"existing-{_uid()}"
195 key_b64 = _key_for(handle)
196 identity = await _seed_identity_with_key(db_session, handle, pubkey_b64=key_b64)
197
198 from musehub.services.musehub_auth import _create_identity_repo
199 await _create_identity_repo(
200 db_session,
201 identity_id=identity.identity_id,
202 handle=handle,
203 public_key_b64=key_b64,
204 )
205 await db_session.commit()
206
207 app.dependency_overrides[require_signed_request] = lambda: _make_ctx(is_admin=True)
208 try:
209 resp = await client.post(
210 "/api/admin/repair-identity-repo",
211 json={"handle": handle},
212 )
213 assert resp.status_code == 200, resp.text
214 body = resp.json()
215 assert body.get("created") is False, f"expected created=false, got {body}"
216 finally:
217 app.dependency_overrides.pop(require_signed_request, None)
218
219
220 # ── AR_05 β€” created repo has correct attributes ───────────────────────────────
221
222
223 @pytest.mark.asyncio
224 async def test_ar05_created_repo_attributes(
225 client: AsyncClient,
226 db_session: AsyncSession,
227 ) -> None:
228 """AR_05 β€” created repo is private, domain_id=identity, HEAD IdentityRecord has correct pubkey."""
229 from sqlalchemy import select
230
231 handle = f"attrs-{_uid()}"
232 key_b64 = _key_for(handle)
233 await _seed_identity_with_key(db_session, handle, pubkey_b64=key_b64)
234 await db_session.commit()
235
236 app.dependency_overrides[require_signed_request] = lambda: _make_ctx(is_admin=True)
237 try:
238 resp = await client.post(
239 "/api/admin/repair-identity-repo",
240 json={"handle": handle},
241 )
242 assert resp.status_code == 200, resp.text
243 finally:
244 app.dependency_overrides.pop(require_signed_request, None)
245
246 await db_session.reset()
247
248 # Repo attributes
249 repo_result = await db_session.execute(
250 select(MusehubRepo).where(
251 MusehubRepo.owner == handle,
252 MusehubRepo.slug == "identity",
253 )
254 )
255 repo = repo_result.scalar_one()
256 assert repo.visibility == "private", "identity repo must be private"
257 assert repo.domain_id == "identity", f"expected domain_id=identity, got {repo.domain_id!r}"
258
259 # HEAD commit message
260 from musehub.db.musehub_repo_models import MusehubBranch, MusehubCommit
261
262 branch_result = await db_session.execute(
263 select(MusehubBranch).where(
264 MusehubBranch.repo_id == repo.repo_id,
265 MusehubBranch.name == "main",
266 )
267 )
268 branch = branch_result.scalar_one()
269 commit_result = await db_session.execute(
270 select(MusehubCommit).where(MusehubCommit.commit_id == branch.head_commit_id)
271 )
272 commit = commit_result.scalar_one()
273 assert commit.message == f"identity: register {handle}"
274
275 # Verify the IdentityRecord pubkey by reading the object through the storage backend.
276 from muse.plugins.identity.records import record_from_bytes
277 from musehub.db.musehub_repo_models import MusehubObject
278 from musehub.storage.backends import get_backend
279
280 obj_result = await db_session.execute(
281 select(MusehubObject).where(
282 MusehubObject.path == f"identities/{handle}.json",
283 )
284 )
285 obj = obj_result.scalar_one()
286 backend = get_backend()
287 raw = await backend.get(obj.object_id)
288 record = record_from_bytes(raw)
289 assert record["handle"] == handle
290 assert record["pubkey"] == key_b64, (
291 f"IdentityRecord pubkey must match registered key. "
292 f"got {record['pubkey']!r}, want {key_b64!r}"
293 )