"""Tests for bridge mirror registry — Phase 7. Tier 1 — Schema: test_mirror_model_has_required_fields test_create_mirror_request_validates_direction Tier 2 — Integration (DB): test_create_mirror_stored_in_db test_list_mirrors_empty_for_new_repo test_delete_mirror_removes_row test_duplicate_url_rejected Tier 3 — Edge Cases: test_create_mirror_nonexistent_repo_returns_404 test_delete_nonexistent_mirror_returns_404 test_direction_invalid_returns_422 Tier 4 — Data Integrity: test_cascade_delete_on_repo_delete Tier 5 — Auth: test_create_requires_auth test_delete_requires_owner """ from __future__ import annotations import pytest from httpx import AsyncClient from pydantic import ValidationError from sqlalchemy.ext.asyncio import AsyncSession from musehub.core.genesis import compute_identity_id from musehub.db.musehub_repo_models import MusehubBridgeMirror, MusehubRepo from musehub.models.bridge import CreateMirrorRequest, MirrorListResponse, MirrorResponse from musehub.types.json_types import JSONObject, StrDict # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- async def _create_repo( client: AsyncClient, auth_headers: StrDict, name: str = "bridge-test-repo", ) -> str: resp = await client.post( "/api/repos", json={"name": name, "owner": "testuser"}, headers=auth_headers, ) assert resp.status_code == 201, resp.text return resp.json()["repoId"] async def _create_mirror( client: AsyncClient, auth_headers: StrDict, repo_id: str, git_remote_url: str = "https://github.com/example/repo.git", git_branch: str = "muse-mirror", direction: str = "export", auto_export: bool = False, ) -> JSONObject: resp = await client.post( f"/api/repos/{repo_id}/mirrors", json={ "git_remote_url": git_remote_url, "git_branch": git_branch, "direction": direction, "auto_export": auto_export, }, headers=auth_headers, ) assert resp.status_code == 201, resp.text return resp.json() # --------------------------------------------------------------------------- # Tier 1 — Schema # --------------------------------------------------------------------------- def test_mirror_model_has_required_fields() -> None: """MirrorResponse requires all mandatory fields.""" with pytest.raises(ValidationError): # Missing required fields MirrorResponse() # type: ignore[call-arg] def test_create_mirror_request_validates_direction() -> None: """CreateMirrorRequest accepts valid directions and rejects unknown ones.""" for valid in ("export", "import", "bidirectional"): req = CreateMirrorRequest( git_remote_url="https://github.com/a/b.git", direction=valid, ) assert req.direction == valid with pytest.raises(ValidationError): CreateMirrorRequest( git_remote_url="https://github.com/a/b.git", direction="unknown", ) # --------------------------------------------------------------------------- # Tier 2 — Integration (DB) # --------------------------------------------------------------------------- async def test_create_mirror_stored_in_db( client: AsyncClient, auth_headers: StrDict, ) -> None: """POST creates a mirror row; GET lists it.""" repo_id = await _create_repo(client, auth_headers, "mirror-create-repo") data = await _create_mirror(client, auth_headers, repo_id) assert data["repoId"] == repo_id assert data["gitRemoteUrl"] == "https://github.com/example/repo.git" assert data["direction"] == "export" assert data["autoExport"] is False assert "id" in data # Confirm GET lists the mirror — response uses camelCase resp = await client.get(f"/api/repos/{repo_id}/mirrors", headers=auth_headers) assert resp.status_code == 200 body = resp.json() assert body["total"] == 1 assert body["mirrors"][0]["id"] == data["id"] assert body["mirrors"][0]["repoId"] == repo_id async def test_list_mirrors_empty_for_new_repo( client: AsyncClient, auth_headers: StrDict, ) -> None: """GET returns an empty list for a repo with no mirrors.""" repo_id = await _create_repo(client, auth_headers, "mirror-empty-repo") resp = await client.get(f"/api/repos/{repo_id}/mirrors", headers=auth_headers) assert resp.status_code == 200 body = resp.json() assert body["mirrors"] == [] assert body["total"] == 0 async def test_delete_mirror_removes_row( client: AsyncClient, auth_headers: StrDict, ) -> None: """DELETE removes the mirror; subsequent GET shows empty list.""" repo_id = await _create_repo(client, auth_headers, "mirror-delete-repo") mirror = await _create_mirror(client, auth_headers, repo_id) mirror_id = mirror["id"] resp = await client.delete( f"/api/repos/{repo_id}/mirrors/{mirror_id}", headers=auth_headers, ) assert resp.status_code == 204 resp = await client.get(f"/api/repos/{repo_id}/mirrors", headers=auth_headers) assert resp.status_code == 200 assert resp.json()["total"] == 0 async def test_duplicate_url_rejected( client: AsyncClient, auth_headers: StrDict, ) -> None: """Registering the same git_remote_url twice for the same repo returns 409.""" repo_id = await _create_repo(client, auth_headers, "mirror-dup-repo") url = "https://github.com/example/dup.git" await _create_mirror(client, auth_headers, repo_id, git_remote_url=url) resp = await client.post( f"/api/repos/{repo_id}/mirrors", json={"git_remote_url": url, "direction": "export"}, headers=auth_headers, ) assert resp.status_code == 409 # --------------------------------------------------------------------------- # Tier 3 — Edge Cases # --------------------------------------------------------------------------- async def test_create_mirror_nonexistent_repo_returns_404( client: AsyncClient, auth_headers: StrDict, ) -> None: """POST to a non-existent repo_id returns 404.""" resp = await client.post( "/api/repos/nonexistent-repo-id/mirrors", json={"git_remote_url": "https://github.com/a/b.git", "direction": "export"}, headers=auth_headers, ) assert resp.status_code == 404 async def test_delete_nonexistent_mirror_returns_404( client: AsyncClient, auth_headers: StrDict, ) -> None: """DELETE for a mirror_id that doesn't exist returns 404.""" repo_id = await _create_repo(client, auth_headers, "mirror-del-404-repo") resp = await client.delete( f"/api/repos/{repo_id}/mirrors/does-not-exist", headers=auth_headers, ) assert resp.status_code == 404 async def test_direction_invalid_returns_422( client: AsyncClient, auth_headers: StrDict, ) -> None: """POST with an invalid direction returns 422.""" repo_id = await _create_repo(client, auth_headers, "mirror-422-repo") resp = await client.post( f"/api/repos/{repo_id}/mirrors", json={"git_remote_url": "https://github.com/a/b.git", "direction": "sideways"}, headers=auth_headers, ) assert resp.status_code == 422 # --------------------------------------------------------------------------- # Tier 4 — Data Integrity # --------------------------------------------------------------------------- async def test_cascade_delete_on_repo_delete( client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession, ) -> None: """Deleting the parent repo cascades to its bridge mirrors.""" from sqlalchemy import select repo_id = await _create_repo(client, auth_headers, "mirror-cascade-repo") mirror = await _create_mirror(client, auth_headers, repo_id) mirror_id = mirror["id"] # Verify the mirror exists in the DB row = await db_session.get(MusehubBridgeMirror, mirror_id) assert row is not None # Delete the repo resp = await client.delete(f"/api/repos/{repo_id}", headers=auth_headers) assert resp.status_code == 204 # Mirror must be gone via CASCADE db_session.expire_all() row_after = await db_session.get(MusehubBridgeMirror, mirror_id) assert row_after is None # --------------------------------------------------------------------------- # Tier 5 — Auth # --------------------------------------------------------------------------- async def test_create_requires_auth( client: AsyncClient, auth_headers: StrDict, ) -> None: """Unauthenticated POST returns 401.""" from musehub.auth.request_signing import optional_signed_request, require_signed_request from musehub.main import app as _app repo_id = await _create_repo(client, auth_headers, "mirror-auth-repo") # Remove the auth override so the real token validator runs. _app.dependency_overrides.pop(require_signed_request, None) _app.dependency_overrides.pop(optional_signed_request, None) resp = await client.post( f"/api/repos/{repo_id}/mirrors", json={"git_remote_url": "https://github.com/a/b.git", "direction": "export"}, ) assert resp.status_code == 401 async def test_delete_requires_owner( client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession, ) -> None: """A non-owner cannot delete a mirror — should get 403.""" from musehub.auth.request_signing import require_signed_request, optional_signed_request, MSignContext from musehub.main import app as _app repo_id = await _create_repo(client, auth_headers, "mirror-403-repo") mirror = await _create_mirror(client, auth_headers, repo_id) mirror_id = mirror["id"] # Override auth to a different user (not the owner "testuser") _OTHER_CONTEXT = MSignContext( handle="other-user", identity_id="other-user-id", is_agent=False, is_admin=False, ) _app.dependency_overrides[require_signed_request] = lambda: _OTHER_CONTEXT _app.dependency_overrides[optional_signed_request] = lambda: _OTHER_CONTEXT try: resp = await client.delete( f"/api/repos/{repo_id}/mirrors/{mirror_id}", headers={"Content-Type": "application/json"}, ) assert resp.status_code == 403 finally: # Restore the testuser override so the auth_headers fixture teardown # pops only what it set, and other tests in the session are unaffected. _TEST_CONTEXT = MSignContext( handle="testuser", identity_id=compute_identity_id(b"testuser"), is_agent=False, is_admin=False, ) _app.dependency_overrides[require_signed_request] = lambda: _TEST_CONTEXT _app.dependency_overrides[optional_signed_request] = lambda: _TEST_CONTEXT