"""TDD — POST /api/admin/repair-identity-repo. Repairs a missing identity repo for an existing identity. This endpoint is needed when a DB reset or migration leaves identities whose repo was never created (or was wiped), so they can authenticate but have no canonical identity DAG. Test IDs: AR_01 — 403 for non-admin caller AR_02 — 404 when handle does not exist AR_03 — creates repo when missing, returns {"created": true} AR_04 — idempotent when repo already exists, returns {"created": false} AR_05 — created repo is private, domain_id="identity", HEAD has correct pubkey Integration tests (marked with @pytest.mark.integration) require localhost hub. """ from __future__ import annotations import json from datetime import datetime, timezone import pytest import pytest_asyncio from httpx import AsyncClient from sqlalchemy.ext.asyncio import AsyncSession from muse.core.types import encode_pubkey from musehub.auth.request_signing import MSignContext, require_signed_request from musehub.core.genesis import compute_identity_id from musehub.db.musehub_auth_models import MusehubAuthKey from musehub.db.musehub_identity_models import MusehubIdentity from musehub.db.musehub_repo_models import MusehubRepo from musehub.main import app # ── fake key material ───────────────────────────────────────────────────────── import hashlib as _hashlib def _key_for(handle: str) -> str: """Deterministic unique key per handle — avoids fingerprint UNIQUE collisions across tests.""" raw = _hashlib.sha256(handle.encode()).digest() return encode_pubkey("ed25519", raw) _NOW = datetime.now(timezone.utc) _COUNTER: list[int] = [0] def _uid(tag: str = "") -> str: _COUNTER[0] += 1 return f"ar{tag}{_COUNTER[0]}" def _make_ctx(is_admin: bool = False, handle: str = "admin-actor") -> MSignContext: from muse.core.types import long_id return MSignContext( identity_id=long_id(handle.ljust(64, "0")[:64]), handle=handle, is_agent=False, is_admin=is_admin, ) async def _seed_identity_with_key( session: AsyncSession, handle: str, pubkey_b64: str | None = None, ) -> MusehubIdentity: """Insert identity + one registered key. Does NOT create the identity repo.""" import base64 from musehub.crypto.keys import key_fingerprint from musehub.core.genesis import compute_key_id if pubkey_b64 is None: pubkey_b64 = _key_for(handle) identity_id = compute_identity_id(handle.encode()) identity = MusehubIdentity( identity_id=identity_id, handle=handle, identity_type="human", is_admin=False, agent_capabilities=[], pinned_repo_ids=[], is_verified=False, created_at=_NOW, updated_at=_NOW, ) session.add(identity) # Flush identity first so the FK on the key insert resolves. await session.flush() _, raw_b64 = pubkey_b64.split(":", 1) raw_bytes = base64.urlsafe_b64decode(raw_b64 + "==") fp = key_fingerprint(raw_bytes) key = MusehubAuthKey( key_id=compute_key_id(identity_id, pubkey_b64), identity_id=identity_id, algorithm="ed25519", public_key_b64=pubkey_b64, fingerprint=fp, label="test-key", created_at=_NOW, ) session.add(key) await session.flush() return identity async def _has_identity_repo(session: AsyncSession, handle: str) -> bool: from sqlalchemy import select result = await session.execute( select(MusehubRepo).where( MusehubRepo.owner == handle, MusehubRepo.slug == "identity", ) ) return result.scalar_one_or_none() is not None # ── AR_01 — 403 for non-admin ───────────────────────────────────────────────── @pytest.mark.asyncio async def test_ar01_non_admin_gets_403(client: AsyncClient) -> None: """AR_01 — caller with is_admin=False must receive 403.""" app.dependency_overrides[require_signed_request] = lambda: _make_ctx(is_admin=False) try: resp = await client.post( "/api/admin/repair-identity-repo", json={"handle": "anyone"}, ) assert resp.status_code == 403, resp.text finally: app.dependency_overrides.pop(require_signed_request, None) # ── AR_02 — 404 for unknown handle ─────────────────────────────────────────── @pytest.mark.asyncio async def test_ar02_unknown_handle_gets_404(client: AsyncClient) -> None: """AR_02 — handle that has no identity row must receive 404.""" app.dependency_overrides[require_signed_request] = lambda: _make_ctx(is_admin=True) try: resp = await client.post( "/api/admin/repair-identity-repo", json={"handle": f"ghost-{_uid()}"}, ) assert resp.status_code == 404, resp.text finally: app.dependency_overrides.pop(require_signed_request, None) # ── AR_03 — creates repo when missing ──────────────────────────────────────── @pytest.mark.asyncio async def test_ar03_creates_repo_when_missing( client: AsyncClient, db_session: AsyncSession, ) -> None: """AR_03 — when identity exists but has no repo, endpoint creates it and returns created=true.""" handle = f"repair-{_uid()}" await _seed_identity_with_key(db_session, handle) await db_session.commit() assert not await _has_identity_repo(db_session, handle), "pre-condition: no repo yet" app.dependency_overrides[require_signed_request] = lambda: _make_ctx(is_admin=True) try: resp = await client.post( "/api/admin/repair-identity-repo", json={"handle": handle}, ) assert resp.status_code == 200, resp.text body = resp.json() assert body.get("created") is True, f"expected created=true, got {body}" finally: app.dependency_overrides.pop(require_signed_request, None) await db_session.reset() assert await _has_identity_repo(db_session, handle), "repo must exist after repair" # ── AR_04 — idempotent when repo already exists ─────────────────────────────── @pytest.mark.asyncio async def test_ar04_idempotent_when_repo_exists( client: AsyncClient, db_session: AsyncSession, ) -> None: """AR_04 — calling repair when repo already exists returns created=false without error.""" handle = f"existing-{_uid()}" key_b64 = _key_for(handle) identity = await _seed_identity_with_key(db_session, handle, pubkey_b64=key_b64) from musehub.services.musehub_auth import _create_identity_repo await _create_identity_repo( db_session, identity_id=identity.identity_id, handle=handle, public_key_b64=key_b64, ) await db_session.commit() app.dependency_overrides[require_signed_request] = lambda: _make_ctx(is_admin=True) try: resp = await client.post( "/api/admin/repair-identity-repo", json={"handle": handle}, ) assert resp.status_code == 200, resp.text body = resp.json() assert body.get("created") is False, f"expected created=false, got {body}" finally: app.dependency_overrides.pop(require_signed_request, None) # ── AR_05 — created repo has correct attributes ─────────────────────────────── @pytest.mark.asyncio async def test_ar05_created_repo_attributes( client: AsyncClient, db_session: AsyncSession, ) -> None: """AR_05 — created repo is private, domain_id=identity, HEAD IdentityRecord has correct pubkey.""" from sqlalchemy import select handle = f"attrs-{_uid()}" key_b64 = _key_for(handle) await _seed_identity_with_key(db_session, handle, pubkey_b64=key_b64) await db_session.commit() app.dependency_overrides[require_signed_request] = lambda: _make_ctx(is_admin=True) try: resp = await client.post( "/api/admin/repair-identity-repo", json={"handle": handle}, ) assert resp.status_code == 200, resp.text finally: app.dependency_overrides.pop(require_signed_request, None) await db_session.reset() # Repo attributes repo_result = await db_session.execute( select(MusehubRepo).where( MusehubRepo.owner == handle, MusehubRepo.slug == "identity", ) ) repo = repo_result.scalar_one() assert repo.visibility == "private", "identity repo must be private" assert repo.domain_id == "identity", f"expected domain_id=identity, got {repo.domain_id!r}" # HEAD commit message from musehub.db.musehub_repo_models import MusehubBranch, MusehubCommit branch_result = await db_session.execute( select(MusehubBranch).where( MusehubBranch.repo_id == repo.repo_id, MusehubBranch.name == "main", ) ) branch = branch_result.scalar_one() commit_result = await db_session.execute( select(MusehubCommit).where(MusehubCommit.commit_id == branch.head_commit_id) ) commit = commit_result.scalar_one() assert commit.message == f"identity: register {handle}" # Verify the IdentityRecord pubkey by reading the object through the storage backend. from muse.plugins.identity.records import record_from_bytes from musehub.db.musehub_repo_models import MusehubObject from musehub.storage.backends import get_backend obj_result = await db_session.execute( select(MusehubObject).where( MusehubObject.path == f"identities/{handle}.json", ) ) obj = obj_result.scalar_one() backend = get_backend() raw = await backend.get(obj.object_id) record = record_from_bytes(raw) assert record["handle"] == handle assert record["pubkey"] == key_b64, ( f"IdentityRecord pubkey must match registered key. " f"got {record['pubkey']!r}, want {key_b64!r}" )