"""Phase 1 — Identity repo created on registration. TDD regression suite: every test starts RED and turns GREEN as the feature is implemented. Their permanent role is to prevent regressions. What this phase covers: - verify_and_authenticate() with a new handle creates {handle}/identity repo - Repo has domain="identity", visibility="private" - Repo has an initial commit on "main" containing identities/{handle}.json - The file content is a valid IdentityRecord with the correct pubkey and handle - A second login (existing key) does NOT create a duplicate repo - register_agent_identity() also creates an identity repo for agents """ from __future__ import annotations import json import pytest from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from muse.core.types import blob_id, encode_pubkey from muse.plugins.identity.records import ( identity_path, record_from_bytes, ) from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from musehub.core.genesis import compute_identity_id, compute_key_id from musehub.crypto.keys import b64url_encode, key_fingerprint from musehub.db.musehub_auth_models import MusehubAuthKey from musehub.db.musehub_repo_models import MusehubBranch, MusehubCommit, MusehubObject, MusehubRepo, MusehubSnapshot from musehub.services.musehub_auth import ( VerifyResponse, create_challenge, verify_and_authenticate, register_agent_identity, ) from musehub.types.json_types import JSONObject # ── helpers ─────────────────────────────────────────────────────────────────── def _keypair() -> tuple[Ed25519PrivateKey, bytes]: priv = Ed25519PrivateKey.generate() pub = priv.public_key().public_bytes_raw() return priv, pub def _sign_nonce(priv: Ed25519PrivateKey, nonce_hex: str) -> str: from muse.core.types import encode_sig sig_bytes = priv.sign(bytes.fromhex(nonce_hex)) return encode_sig("ed25519", sig_bytes) async def _register( session: AsyncSession, handle: str, priv: Ed25519PrivateKey, pub: bytes, ) -> VerifyResponse: """Full challenge → verify flow for a fresh identity.""" public_key_b64 = encode_pubkey("ed25519", pub) fp = key_fingerprint(pub) nonce = await create_challenge(session, fingerprint=fp, algorithm="ed25519") sig_b64 = _sign_nonce(priv, nonce) return await verify_and_authenticate( session=session, challenge_token=nonce, public_key_b64=public_key_b64, signature_b64=sig_b64, handle=handle, display_name=handle, label="test-key", ) # ═══════════════════════════════════════════════════════════════════════════════ # 1. Identity repo created on registration # ═══════════════════════════════════════════════════════════════════════════════ class TestIdentityRepoCreatedOnRegistration: async def test_registration_creates_identity_repo( self, db_session: AsyncSession ) -> None: """verify_and_authenticate with a new handle must create a {handle}/identity repo.""" priv, pub = _keypair() await _register(db_session, "alice", priv, pub) result = await db_session.execute( select(MusehubRepo).where( MusehubRepo.owner == "alice", MusehubRepo.slug == "identity", ) ) repo = result.scalar_one_or_none() assert repo is not None, ( "Expected {handle}/identity repo to be created on registration, " "but no MusehubRepo row found with owner='alice', slug='identity'." ) async def test_identity_repo_has_correct_domain( self, db_session: AsyncSession ) -> None: priv, pub = _keypair() await _register(db_session, "bob", priv, pub) result = await db_session.execute( select(MusehubRepo).where( MusehubRepo.owner == "bob", MusehubRepo.slug == "identity", ) ) repo = result.scalar_one_or_none() assert repo is not None assert repo.domain_id == "identity", ( f"Expected domain_id='identity', got {repo.domain_id!r}." ) async def test_identity_repo_is_private( self, db_session: AsyncSession ) -> None: priv, pub = _keypair() await _register(db_session, "carol", priv, pub) result = await db_session.execute( select(MusehubRepo).where( MusehubRepo.owner == "carol", MusehubRepo.slug == "identity", ) ) repo = result.scalar_one_or_none() assert repo is not None assert repo.visibility == "private", ( f"Identity repo must be private, got visibility={repo.visibility!r}." ) async def test_login_does_not_create_duplicate_repo( self, db_session: AsyncSession ) -> None: """A second verify_and_authenticate with the same key must not create a second repo.""" priv, pub = _keypair() await _register(db_session, "dave", priv, pub) # Second login with same key public_key_b64 = encode_pubkey("ed25519", pub) fp = key_fingerprint(pub) nonce = await create_challenge(db_session, fingerprint=fp, algorithm="ed25519") sig_b64 = _sign_nonce(priv, nonce) await verify_and_authenticate( session=db_session, challenge_token=nonce, public_key_b64=public_key_b64, signature_b64=sig_b64, handle=None, display_name=None, label=None, ) result = await db_session.execute( select(MusehubRepo).where( MusehubRepo.owner == "dave", MusehubRepo.slug == "identity", ) ) repos = result.scalars().all() assert len(repos) == 1, ( f"Expected exactly one identity repo for 'dave', found {len(repos)}." ) # ═══════════════════════════════════════════════════════════════════════════════ # 2. Initial commit contains identities/{handle}.json # ═══════════════════════════════════════════════════════════════════════════════ class TestIdentityRepoInitialCommit: async def test_identity_repo_has_commit_on_main( self, db_session: AsyncSession ) -> None: """The identity repo must have at least one commit on 'main'.""" priv, pub = _keypair() await _register(db_session, "eve", priv, pub) repo_result = await db_session.execute( select(MusehubRepo).where( MusehubRepo.owner == "eve", MusehubRepo.slug == "identity", ) ) repo = repo_result.scalar_one_or_none() assert repo is not None branch_result = await db_session.execute( select(MusehubBranch).where( MusehubBranch.repo_id == repo.repo_id, MusehubBranch.name == "main", ) ) branch = branch_result.scalar_one_or_none() assert branch is not None, "Identity repo must have a 'main' branch." assert branch.head_commit_id is not None, ( "Identity repo 'main' branch must have a non-null head_commit_id." ) async def test_initial_commit_has_identity_record_object( self, db_session: AsyncSession ) -> None: """The initial commit must reference an object at identities/{handle}.json.""" priv, pub = _keypair() await _register(db_session, "frank", priv, pub) repo_result = await db_session.execute( select(MusehubRepo).where( MusehubRepo.owner == "frank", MusehubRepo.slug == "identity", ) ) repo = repo_result.scalar_one_or_none() assert repo is not None # Find the commit branch_result = await db_session.execute( select(MusehubBranch).where( MusehubBranch.repo_id == repo.repo_id, MusehubBranch.name == "main", ) ) branch = branch_result.scalar_one_or_none() assert branch is not None commit_result = await db_session.execute( select(MusehubCommit).where( MusehubCommit.commit_id == branch.head_commit_id ) ) commit = commit_result.scalar_one_or_none() assert commit is not None # The commit must reference a snapshot assert commit.snapshot_id is not None, ( "Initial identity commit must have a snapshot_id." ) # The snapshot manifest must include identities/{handle}.json import msgpack snap_result = await db_session.execute( select(MusehubSnapshot).where( MusehubSnapshot.snapshot_id == commit.snapshot_id ) ) snap = snap_result.scalar_one_or_none() assert snap is not None, "No MusehubSnapshot row found for commit snapshot_id." manifest: JSONObject = msgpack.unpackb(snap.manifest_blob, raw=False) expected_path = identity_path("frank") assert expected_path in manifest, ( f"Expected {expected_path!r} in snapshot manifest {list(manifest)!r}. " "The initial commit must include the identity record." ) # ═══════════════════════════════════════════════════════════════════════════════ # 3. Identity record content is correct # ═══════════════════════════════════════════════════════════════════════════════ class TestIdentityRecordContent: async def test_identity_record_handle_matches( self, db_session: AsyncSession ) -> None: priv, pub = _keypair() await _register(db_session, "grace", priv, pub) record = await _read_identity_record(db_session, "grace") assert record["handle"] == "grace" async def test_identity_record_type_is_human( self, db_session: AsyncSession ) -> None: priv, pub = _keypair() await _register(db_session, "heidi", priv, pub) record = await _read_identity_record(db_session, "heidi") assert record["type"] == "human" async def test_identity_record_pubkey_matches_registered_key( self, db_session: AsyncSession ) -> None: priv, pub = _keypair() await _register(db_session, "ivan", priv, pub) expected_pubkey = encode_pubkey("ed25519", pub) record = await _read_identity_record(db_session, "ivan") assert record["pubkey"] == expected_pubkey, ( f"IdentityRecord.pubkey {record['pubkey']!r} does not match " f"the registered public key {expected_pubkey!r}." ) async def test_identity_record_registered_at_is_set( self, db_session: AsyncSession ) -> None: priv, pub = _keypair() await _register(db_session, "judy", priv, pub) record = await _read_identity_record(db_session, "judy") assert record.get("registered_at"), ( "IdentityRecord.registered_at must be a non-empty ISO-8601 string." ) # ═══════════════════════════════════════════════════════════════════════════════ # 4. Agent registration also creates an identity repo # ═══════════════════════════════════════════════════════════════════════════════ class TestAgentIdentityRepo: async def test_agent_registration_creates_identity_repo( self, db_session: AsyncSession ) -> None: """register_agent_identity() must also create an identity repo for agents.""" _, pub = _keypair() public_key_b64 = encode_pubkey("ed25519", pub) fp = key_fingerprint(pub) await register_agent_identity( session=db_session, handle="test-agent-01", public_key_b64=public_key_b64, fingerprint=fp, algorithm="ed25519", spawned_by="grace", ) result = await db_session.execute( select(MusehubRepo).where( MusehubRepo.owner == "test-agent-01", MusehubRepo.slug == "identity", ) ) repo = result.scalar_one_or_none() assert repo is not None, ( "register_agent_identity() must create a {handle}/identity repo " "with domain='identity'." ) assert repo.domain_id == "identity" async def test_agent_identity_record_type_is_agent( self, db_session: AsyncSession ) -> None: _, pub = _keypair() public_key_b64 = encode_pubkey("ed25519", pub) fp = key_fingerprint(pub) await register_agent_identity( session=db_session, handle="test-agent-02", public_key_b64=public_key_b64, fingerprint=fp, algorithm="ed25519", spawned_by="grace", ) record = await _read_identity_record(db_session, "test-agent-02") assert record["type"] == "agent", ( f"Expected IdentityRecord.type='agent', got {record['type']!r}." ) # ── helper to read the identity record from the DB ──────────────────────────── async def _read_identity_record(session: AsyncSession, handle: str) -> JSONObject: """Read identities/{handle}.json content from the identity repo's initial commit.""" repo_result = await session.execute( select(MusehubRepo).where( MusehubRepo.owner == handle, MusehubRepo.slug == "identity", ) ) repo = repo_result.scalar_one() branch_result = await session.execute( select(MusehubBranch).where( MusehubBranch.repo_id == repo.repo_id, MusehubBranch.name == "main", ) ) branch = branch_result.scalar_one() commit_result = await session.execute( select(MusehubCommit).where( MusehubCommit.commit_id == branch.head_commit_id ) ) commit = commit_result.scalar_one() import msgpack snap_result = await session.execute( select(MusehubSnapshot).where( MusehubSnapshot.snapshot_id == commit.snapshot_id ) ) snap = snap_result.scalar_one() manifest: JSONObject = msgpack.unpackb(snap.manifest_blob, raw=False) file_path = identity_path(handle) object_id = manifest[file_path] obj_result = await session.execute( select(MusehubObject).where(MusehubObject.object_id == object_id) ) obj = obj_result.scalar_one() from musehub.storage.backends import read_object_bytes raw = await read_object_bytes(obj) if raw is None: return None return json.loads(raw)