"""Regression tests for musehub issue #131 Phase 3 -- the full write-route sweep. Beyond the original wire-protocol gap, the sweep found five more routes that depended only on `require_scope(...)` (an agent-capability gate that passes human identities through unconditionally) or on nothing at all, with zero per-repo owner/collaborator check: - POST /api/repos/{repo_id}/branches/{name}/reset (force-history-rewrite) - POST/POST /api/repos/{repo_id}/proposals/{id}/close|reopen - POST /api/repos/{repo_id}/sessions (+ /{id}/stop) - POST /api/repos/{repo_id}/symbol-index/rebuild - POST/DELETE /api/orgs/{org}/members/{handle} Plus a smaller gap: the actor-permission lookups in collaborator management (REST `collaborators.py` and the matching MCP write-tools) didn't filter `accepted_at IS NOT NULL`, so a *pending* (not yet accepted) admin invite could exercise admin rights before accepting. Each test proves the *fixed* (secure) behavior: a caller with no legitimate relationship to the resource must be rejected (403), while owners/admins/ write-collaborators must keep working. """ from __future__ import annotations from datetime import datetime, timezone import pytest import pytest_asyncio from httpx import AsyncClient, ASGITransport from sqlalchemy.ext.asyncio import AsyncSession from muse.core.types import fake_id from musehub.auth.dependencies import require_valid_token from musehub.auth.request_signing import MSignContext from musehub.core.genesis import compute_branch_id, compute_collaborator_id, compute_identity_id from musehub.db.database import get_db from musehub.db.musehub_collaborator_models import MusehubCollaborator from musehub.db.musehub_identity_models import MusehubIdentity from musehub.db.musehub_repo_models import MusehubBranch, MusehubCommit, MusehubCommitRef, MusehubRepo from musehub.main import app from musehub.services.musehub_orgs import add_org_member, create_org from musehub.services.musehub_proposals import create_proposal from musehub.services.musehub_repository import create_repo _OWNER = "owner-p3" _OWNER_ID = compute_identity_id(_OWNER.encode()) _ATTACKER = "attacker-p3" _ATTACKER_ID = compute_identity_id(_ATTACKER.encode()) _WRITE_COLLAB = "writer-p3" _WRITE_COLLAB_ID = compute_identity_id(_WRITE_COLLAB.encode()) _ADMIN_COLLAB = "admin-p3" _ADMIN_COLLAB_ID = compute_identity_id(_ADMIN_COLLAB.encode()) def _claims(handle: str, identity_id: str) -> MSignContext: return MSignContext(handle=handle, identity_id=identity_id, is_agent=False, is_admin=False) _OWNER_CLAIMS = _claims(_OWNER, _OWNER_ID) _ATTACKER_CLAIMS = _claims(_ATTACKER, _ATTACKER_ID) _WRITE_COLLAB_CLAIMS = _claims(_WRITE_COLLAB, _WRITE_COLLAB_ID) _ADMIN_COLLAB_CLAIMS = _claims(_ADMIN_COLLAB, _ADMIN_COLLAB_ID) async def _make_client(db_session: AsyncSession, claims: MSignContext) -> AsyncClient: async def _override_db(): yield db_session app.dependency_overrides[get_db] = _override_db app.dependency_overrides[require_valid_token] = lambda: claims return AsyncClient(transport=ASGITransport(app=app), base_url="https://localhost:1337") async def _make_repo(db_session: AsyncSession, *, name: str, visibility: str = "private") -> MusehubRepo: r = await create_repo( db_session, name=name, owner=_OWNER, owner_user_id=_OWNER_ID, visibility=visibility, initialize=False, ) await db_session.commit() return r async def _add_collaborator( db_session: AsyncSession, repo: MusehubRepo, *, handle: str, identity_id: str, permission: str, accepted: bool = True, ) -> None: created_at = datetime.now(timezone.utc).isoformat() row = MusehubCollaborator( id=compute_collaborator_id(repo.repo_id, identity_id, created_at), repo_id=repo.repo_id, identity_handle=handle, permission=permission, ) db_session.add(row) await db_session.flush() if accepted: row.accepted_at = row.invited_at await db_session.commit() async def _seed_identity(db_session: AsyncSession, handle: str, identity_id: str) -> None: db_session.add(MusehubIdentity( identity_id=identity_id, handle=handle, display_name=handle, identity_type="human", )) await db_session.commit() async def _seed_branch_with_commits(db_session: AsyncSession, repo: MusehubRepo) -> tuple[str, str]: older = fake_id(f"{repo.repo_id}-older") newer = fake_id(f"{repo.repo_id}-newer") now = datetime.now(timezone.utc) for cid, parents in ((older, []), (newer, [older])): db_session.add(MusehubCommit( commit_id=cid, parent_ids=parents, snapshot_id=fake_id(cid), message="m", author="a", timestamp=now, branch="main", )) db_session.add(MusehubCommitRef(repo_id=repo.repo_id, commit_id=cid)) db_session.add(MusehubBranch( branch_id=compute_branch_id(repo.repo_id, "main"), repo_id=repo.repo_id, name="main", head_commit_id=newer, )) await db_session.commit() return older, newer # --------------------------------------------------------------------------- # Branch reset -- owner/admin-collaborator only # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_branch_reset_rejects_non_collaborator(db_session: AsyncSession) -> None: repo = await _make_repo(db_session, name="p3-reset-blocked") older, _newer = await _seed_branch_with_commits(db_session, repo) client = await _make_client(db_session, _ATTACKER_CLAIMS) async with client: resp = await client.post( f"/api/repos/{repo.repo_id}/branches/main/reset", json={"commitId": older}, ) app.dependency_overrides.clear() assert resp.status_code == 403, resp.text @pytest.mark.asyncio async def test_branch_reset_rejects_plain_write_collaborator(db_session: AsyncSession) -> None: """Reset requires *admin*, not just write -- matches the settings-change trust level.""" repo = await _make_repo(db_session, name="p3-reset-write-blocked") older, _newer = await _seed_branch_with_commits(db_session, repo) await _add_collaborator(db_session, repo, handle=_WRITE_COLLAB, identity_id=_WRITE_COLLAB_ID, permission="write") client = await _make_client(db_session, _WRITE_COLLAB_CLAIMS) async with client: resp = await client.post( f"/api/repos/{repo.repo_id}/branches/main/reset", json={"commitId": older}, ) app.dependency_overrides.clear() assert resp.status_code == 403, resp.text @pytest.mark.asyncio async def test_branch_reset_allows_owner(db_session: AsyncSession) -> None: repo = await _make_repo(db_session, name="p3-reset-owner-ok") older, _newer = await _seed_branch_with_commits(db_session, repo) client = await _make_client(db_session, _OWNER_CLAIMS) async with client: resp = await client.post( f"/api/repos/{repo.repo_id}/branches/main/reset", json={"commitId": older}, ) app.dependency_overrides.clear() assert resp.status_code == 200, resp.text # --------------------------------------------------------------------------- # Proposal close/reopen -- owner-or-write/admin-collaborator # --------------------------------------------------------------------------- @pytest_asyncio.fixture() async def _proposal(db_session: AsyncSession) -> tuple[MusehubRepo, str]: repo = await _make_repo(db_session, name="p3-proposal-repo", visibility="public") from musehub.db.musehub_repo_models import MusehubBranch as _Branch now = datetime.now(timezone.utc) head = fake_id(f"{repo.repo_id}-head") db_session.add(MusehubCommit( commit_id=head, parent_ids=[], snapshot_id=fake_id(head), message="m", author=_OWNER, timestamp=now, branch="main", )) db_session.add(MusehubCommitRef(repo_id=repo.repo_id, commit_id=head)) db_session.add(_Branch( branch_id=compute_branch_id(repo.repo_id, "main"), repo_id=repo.repo_id, name="main", head_commit_id=head, )) db_session.add(_Branch( branch_id=compute_branch_id(repo.repo_id, "feature"), repo_id=repo.repo_id, name="feature", head_commit_id=head, )) await db_session.commit() proposal = await create_proposal( db_session, repo_id=repo.repo_id, title="t", from_branch="feature", to_branch="main", author=_OWNER, ) await db_session.commit() return repo, proposal.proposal_id @pytest.mark.asyncio async def test_close_proposal_rejects_non_collaborator(db_session: AsyncSession, _proposal) -> None: repo, proposal_id = _proposal client = await _make_client(db_session, _ATTACKER_CLAIMS) async with client: resp = await client.post(f"/api/repos/{repo.repo_id}/proposals/{proposal_id}/close") app.dependency_overrides.clear() assert resp.status_code == 403, resp.text @pytest.mark.asyncio async def test_reopen_proposal_rejects_non_collaborator(db_session: AsyncSession, _proposal) -> None: repo, proposal_id = _proposal owner_client = await _make_client(db_session, _OWNER_CLAIMS) async with owner_client: close_resp = await owner_client.post(f"/api/repos/{repo.repo_id}/proposals/{proposal_id}/close") assert close_resp.status_code == 200, close_resp.text app.dependency_overrides.clear() attacker_client = await _make_client(db_session, _ATTACKER_CLAIMS) async with attacker_client: resp = await attacker_client.post(f"/api/repos/{repo.repo_id}/proposals/{proposal_id}/reopen") app.dependency_overrides.clear() assert resp.status_code == 403, resp.text @pytest.mark.asyncio async def test_close_proposal_allows_owner(db_session: AsyncSession, _proposal) -> None: repo, proposal_id = _proposal client = await _make_client(db_session, _OWNER_CLAIMS) async with client: resp = await client.post(f"/api/repos/{repo.repo_id}/proposals/{proposal_id}/close") app.dependency_overrides.clear() assert resp.status_code == 200, resp.text # --------------------------------------------------------------------------- # Sessions -- owner-or-write/admin-collaborator # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_create_session_rejects_non_collaborator(db_session: AsyncSession) -> None: repo = await _make_repo(db_session, name="p3-session-blocked") client = await _make_client(db_session, _ATTACKER_CLAIMS) async with client: resp = await client.post(f"/api/repos/{repo.repo_id}/sessions", json={}) app.dependency_overrides.clear() assert resp.status_code == 403, resp.text @pytest.mark.asyncio async def test_create_session_allows_write_collaborator(db_session: AsyncSession) -> None: repo = await _make_repo(db_session, name="p3-session-writer-ok") await _add_collaborator(db_session, repo, handle=_WRITE_COLLAB, identity_id=_WRITE_COLLAB_ID, permission="write") client = await _make_client(db_session, _WRITE_COLLAB_CLAIMS) async with client: resp = await client.post(f"/api/repos/{repo.repo_id}/sessions", json={}) app.dependency_overrides.clear() assert resp.status_code == 201, resp.text @pytest.mark.asyncio async def test_stop_session_rejects_non_collaborator(db_session: AsyncSession) -> None: repo = await _make_repo(db_session, name="p3-session-stop-blocked") owner_client = await _make_client(db_session, _OWNER_CLAIMS) async with owner_client: create_resp = await owner_client.post(f"/api/repos/{repo.repo_id}/sessions", json={}) assert create_resp.status_code == 201, create_resp.text session_id = create_resp.json()["sessionId"] app.dependency_overrides.clear() attacker_client = await _make_client(db_session, _ATTACKER_CLAIMS) async with attacker_client: resp = await attacker_client.post( f"/api/repos/{repo.repo_id}/sessions/{session_id}/stop", json={}, ) app.dependency_overrides.clear() assert resp.status_code == 403, resp.text # --------------------------------------------------------------------------- # Symbol-index rebuild -- owner-or-write/admin-collaborator # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_rebuild_symbol_index_rejects_non_collaborator(db_session: AsyncSession) -> None: repo = await _make_repo(db_session, name="p3-symidx-blocked") now = datetime.now(timezone.utc) head = fake_id(f"{repo.repo_id}-symidx") db_session.add(MusehubCommit( commit_id=head, parent_ids=[], snapshot_id=fake_id(head), message="m", author=_OWNER, timestamp=now, branch="main", )) db_session.add(MusehubCommitRef(repo_id=repo.repo_id, commit_id=head)) await db_session.commit() client = await _make_client(db_session, _ATTACKER_CLAIMS) async with client: resp = await client.post(f"/api/repos/{repo.repo_id}/symbol-index/rebuild") app.dependency_overrides.clear() assert resp.status_code == 403, resp.text # --------------------------------------------------------------------------- # Org membership -- admin-member-only, with a bootstrap exception # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_add_first_org_member_bootstrap_allowed(db_session: AsyncSession) -> None: org = await create_org( db_session, handle="p3-org-bootstrap", display_name="Bootstrap Org", quorum=1, creator_identity_id=_OWNER_ID, ) client = await _make_client(db_session, _OWNER_CLAIMS) async with client: resp = await client.post( f"/api/orgs/{org.handle}/members/{_OWNER}", json={"weight": "admin"}, ) app.dependency_overrides.clear() assert resp.status_code == 201, resp.text @pytest.mark.asyncio async def test_add_org_member_rejects_non_admin(db_session: AsyncSession) -> None: org = await create_org( db_session, handle="p3-org-locked", display_name="Locked Org", quorum=1, creator_identity_id=_OWNER_ID, ) await add_org_member( db_session, org_handle=org.handle, member_handle=_OWNER, weight="admin", actor_identity_id=_OWNER_ID, actor_handle=_OWNER, ) client = await _make_client(db_session, _ATTACKER_CLAIMS) async with client: resp = await client.post( f"/api/orgs/{org.handle}/members/{_ATTACKER}", json={"weight": "admin"}, ) app.dependency_overrides.clear() assert resp.status_code == 403, resp.text @pytest.mark.asyncio async def test_remove_org_member_rejects_non_admin(db_session: AsyncSession) -> None: org = await create_org( db_session, handle="p3-org-remove-locked", display_name="Locked Org 2", quorum=1, creator_identity_id=_OWNER_ID, ) await add_org_member( db_session, org_handle=org.handle, member_handle=_OWNER, weight="admin", actor_identity_id=_OWNER_ID, actor_handle=_OWNER, ) client = await _make_client(db_session, _ATTACKER_CLAIMS) async with client: resp = await client.delete(f"/api/orgs/{org.handle}/members/{_OWNER}") app.dependency_overrides.clear() assert resp.status_code == 403, resp.text @pytest.mark.asyncio async def test_admin_member_can_add_another_member(db_session: AsyncSession) -> None: org = await create_org( db_session, handle="p3-org-admin-adds", display_name="Admin Org", quorum=1, creator_identity_id=_OWNER_ID, ) await add_org_member( db_session, org_handle=org.handle, member_handle=_OWNER, weight="admin", actor_identity_id=_OWNER_ID, actor_handle=_OWNER, ) client = await _make_client(db_session, _OWNER_CLAIMS) async with client: resp = await client.post( f"/api/orgs/{org.handle}/members/{_WRITE_COLLAB}", json={"weight": "write"}, ) app.dependency_overrides.clear() assert resp.status_code == 201, resp.text # --------------------------------------------------------------------------- # Collaborator management -- pending (not yet accepted) admin invite must not # grant admin rights # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_pending_admin_collaborator_cannot_invite_others(db_session: AsyncSession) -> None: repo = await _make_repo(db_session, name="p3-pending-admin-blocked") await _add_collaborator( db_session, repo, handle=_ADMIN_COLLAB, identity_id=_ADMIN_COLLAB_ID, permission="admin", accepted=False, ) client = await _make_client(db_session, _ADMIN_COLLAB_CLAIMS) async with client: resp = await client.post( f"/api/repos/{repo.repo_id}/collaborators", json={"handle": _WRITE_COLLAB, "permission": "write"}, ) app.dependency_overrides.clear() assert resp.status_code == 403, resp.text @pytest.mark.asyncio async def test_accepted_admin_collaborator_can_invite_others(db_session: AsyncSession) -> None: repo = await _make_repo(db_session, name="p3-accepted-admin-ok") await _seed_identity(db_session, _WRITE_COLLAB, _WRITE_COLLAB_ID) await _add_collaborator( db_session, repo, handle=_ADMIN_COLLAB, identity_id=_ADMIN_COLLAB_ID, permission="admin", accepted=True, ) client = await _make_client(db_session, _ADMIN_COLLAB_CLAIMS) async with client: resp = await client.post( f"/api/repos/{repo.repo_id}/collaborators", json={"handle": _WRITE_COLLAB, "permission": "write"}, ) app.dependency_overrides.clear() assert resp.status_code == 201, resp.text