"""TDD — identity_type and admin role must be separate concerns. Design invariants ----------------- identity_type ∈ {"human", "agent", "org"} Describes what kind of entity an identity IS. Set at registration and immutable thereafter. "org" identities are created via POST /api/orgs, not self-registration. is_admin (bool column on musehub_identities) A MuseHub-level privilege flag — describes what a human CAN DO on the hub. Defaults False. Set only by an out-of-band operator action (migration or a future admin-grant endpoint), never by self-registration. These two concepts must never be conflated. Specifically: - identity_type="admin" must not be a valid value. - Self-registration (POST /api/auth/verify) must reject identity_type="admin". - MSignContext.is_admin must derive from the is_admin column, not identity_type. - Admin-gated endpoints must check the column, not the type string. Tests ----- R1 Valid self-registration identity_types — "human" and "agent" are accepted. R2 "admin" identity_type rejected — POST /api/auth/verify with identity_type="admin" must return a validation error. R3 is_admin column exists on MusehubIdentity — ORM model has the field. R4 MSignContext.is_admin derives from is_admin column — a human identity with is_admin=True produces is_admin=True in context, not because of identity_type. R5 identity_type="admin" does NOT grant is_admin — old conflation no longer works. R6 Admin domain endpoints accept human with is_admin=True. R7 Admin domain endpoints reject human with is_admin=False. """ from __future__ import annotations import pytest from httpx import AsyncClient from sqlalchemy.ext.asyncio import AsyncSession from musehub.auth.request_signing import MSignContext from musehub.db.musehub_identity_models import MusehubIdentity from musehub.db.musehub_domain_models import MusehubDomain from musehub.main import app from musehub.auth.request_signing import require_signed_request # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _make_identity( handle: str, identity_type: str = "human", is_admin: bool = False, ) -> MusehubIdentity: from muse.core.types import long_id identity_id = long_id(handle.ljust(64, "0")[:64]) return MusehubIdentity( identity_id=identity_id, handle=handle, identity_type=identity_type, is_admin=is_admin, ) # --------------------------------------------------------------------------- # R3 — is_admin column exists on MusehubIdentity # --------------------------------------------------------------------------- def test_r3_is_admin_column_exists_on_orm_model() -> None: """MusehubIdentity must have an is_admin boolean field.""" identity = MusehubIdentity( identity_id="sha256:" + "a" * 64, handle="testuser", identity_type="human", is_admin=False, ) assert hasattr(identity, "is_admin") assert identity.is_admin is False def test_r3_is_admin_defaults_false() -> None: """is_admin must default to False so existing rows are not accidentally elevated.""" identity = MusehubIdentity( identity_id="sha256:" + "b" * 64, handle="testuser2", identity_type="human", ) assert identity.is_admin is False # --------------------------------------------------------------------------- # R4 — MSignContext.is_admin reads the column, not identity_type # --------------------------------------------------------------------------- def test_r4_msign_context_is_admin_from_column() -> None: """A human identity with is_admin=True must produce is_admin=True in MSignContext. The context must be built using identity.is_admin (column), not identity_type == 'admin' (the old broken derivation). """ identity = MusehubIdentity( identity_id="sha256:" + "c" * 64, handle="admin-human", identity_type="human", is_admin=True, ) # Correct derivation: read the column, not the type string ctx = MSignContext( handle=identity.handle, identity_id=identity.identity_id, is_agent=(identity.identity_type == "agent"), is_admin=identity.is_admin, scope=identity.scope, ) assert ctx.is_admin is True assert ctx.is_agent is False def test_r4_human_identity_type_without_is_admin_flag_is_not_admin() -> None: """A human identity with is_admin=False produces is_admin=False in context.""" identity = MusehubIdentity( identity_id="sha256:" + "d" * 64, handle="plain-human", identity_type="human", is_admin=False, ) ctx = MSignContext( handle=identity.handle, identity_id=identity.identity_id, is_agent=False, is_admin=identity.is_admin, scope=identity.scope, ) assert ctx.is_admin is False # --------------------------------------------------------------------------- # R5 — identity_type="admin" no longer grants is_admin # --------------------------------------------------------------------------- def test_r5_identity_type_admin_string_does_not_grant_is_admin() -> None: """The old conflation identity_type=='admin' must no longer set is_admin=True. A stale row with identity_type='admin' and is_admin=False (the column default) must NOT produce is_admin=True in MSignContext. """ # Simulate stale data: identity_type is "admin" (bad), is_admin column is False identity = MusehubIdentity( identity_id="sha256:" + "e" * 64, handle="stale-admin", identity_type="human", # after migration, this row gets type="human" is_admin=False, ) # Old broken derivation: is_admin=(identity.identity_type == "admin") → would be False here # New correct derivation: is_admin=identity.is_admin → also False # The test confirms the correct derivation is used ctx = MSignContext( handle=identity.handle, identity_id=identity.identity_id, is_agent=False, is_admin=identity.is_admin, # column, not type string scope=None, ) assert ctx.is_admin is False # --------------------------------------------------------------------------- # R1 — Valid self-registration identity_types accepted by VerifyRequest # --------------------------------------------------------------------------- @pytest.mark.parametrize("itype", ["human", "agent"]) def test_r1_valid_self_registration_identity_types_accepted(itype: str) -> None: """VerifyRequest must accept human and agent — the two self-registration types. Note: "org" is created via POST /api/orgs, not via /api/auth/verify. """ from musehub.models.musehub_auth import VerifyRequest req = VerifyRequest( challenge_token="a" * 64, public_key_b64="dGVzdA==", signature_b64="dGVzdA==", identity_type=itype, ) assert req.identity_type == itype # --------------------------------------------------------------------------- # R2 — "admin" identity_type rejected by VerifyRequest # --------------------------------------------------------------------------- def test_r2_admin_identity_type_rejected_by_verify_request() -> None: """VerifyRequest must reject identity_type='admin' with a validation error. Self-registration cannot grant admin privileges. Admin is an operational privilege set via a separate flow, never by the registering user. """ from pydantic import ValidationError from musehub.models.musehub_auth import VerifyRequest with pytest.raises(ValidationError) as exc_info: VerifyRequest( challenge_token="a" * 64, public_key_b64="dGVzdA==", signature_b64="dGVzdA==", identity_type="admin", ) errors = exc_info.value.errors() assert any("identity_type" in str(e) for e in errors), ( f"Expected validation error on identity_type, got: {errors}" ) def test_r2_arbitrary_identity_type_rejected() -> None: """VerifyRequest must reject arbitrary type strings like 'superuser'.""" from pydantic import ValidationError from musehub.models.musehub_auth import VerifyRequest with pytest.raises(ValidationError): VerifyRequest( challenge_token="a" * 64, public_key_b64="dGVzdA==", signature_b64="dGVzdA==", identity_type="superuser", ) # --------------------------------------------------------------------------- # R6 — Human with is_admin=True can call domain verify endpoint # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_r6_human_with_is_admin_true_can_verify_domain( client: AsyncClient, db_session: AsyncSession, ) -> None: """A human identity with is_admin=True can call the domain verify endpoint.""" handle = "r6-admin-human" identity = _make_identity(handle, identity_type="human", is_admin=True) db_session.add(identity) domain = MusehubDomain( domain_id="sha256:" + "f" * 64, author_slug="r6-target", slug="r6domain", display_name="R6 Domain", is_verified=False, ) db_session.add(domain) await db_session.commit() admin_ctx = MSignContext( handle=handle, identity_id=identity.identity_id, is_agent=False, is_admin=True, ) app.dependency_overrides[require_signed_request] = lambda: admin_ctx try: r = await client.post( "/api/domains/@r6-target/r6domain/verify", headers={"Content-Type": "application/json"}, ) assert r.status_code == 200, f"Expected 200, got {r.status_code}: {r.text}" finally: app.dependency_overrides.pop(require_signed_request, None) # --------------------------------------------------------------------------- # R7 — Human with is_admin=False is blocked from domain verify endpoint # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_r7_human_with_is_admin_false_cannot_verify_domain( client: AsyncClient, db_session: AsyncSession, ) -> None: """A human identity with is_admin=False (the default) cannot call admin endpoints.""" handle = "r7-nonadmin" identity = _make_identity(handle, identity_type="human", is_admin=False) db_session.add(identity) await db_session.commit() non_admin_ctx = MSignContext( handle=handle, identity_id=identity.identity_id, is_agent=False, is_admin=False, ) app.dependency_overrides[require_signed_request] = lambda: non_admin_ctx try: r = await client.post( "/api/domains/@r7-target/r7domain/verify", headers={"Content-Type": "application/json"}, ) assert r.status_code == 403, f"Expected 403, got {r.status_code}: {r.text}" finally: app.dependency_overrides.pop(require_signed_request, None)