gabriel / musehub public

test_bridge_mirrors.py file-level

at sha256:3 · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 💥 blast risk
sha256:0 fix: fall back to any indexed mpack in read_object_bytes when push mpac… · gabriel · Jun 17, 2026
1 """Tests for bridge mirror registry — Phase 7.
2
3 Tier 1 — Schema:
4 test_mirror_model_has_required_fields
5 test_create_mirror_request_validates_direction
6
7 Tier 2 — Integration (DB):
8 test_create_mirror_stored_in_db
9 test_list_mirrors_empty_for_new_repo
10 test_delete_mirror_removes_row
11 test_duplicate_url_rejected
12
13 Tier 3 — Edge Cases:
14 test_create_mirror_nonexistent_repo_returns_404
15 test_delete_nonexistent_mirror_returns_404
16 test_direction_invalid_returns_422
17
18 Tier 4 — Data Integrity:
19 test_cascade_delete_on_repo_delete
20
21 Tier 5 — Auth:
22 test_create_requires_auth
23 test_delete_requires_owner
24 """
25 from __future__ import annotations
26
27 import pytest
28 from httpx import AsyncClient
29 from pydantic import ValidationError
30 from sqlalchemy.ext.asyncio import AsyncSession
31
32 from musehub.core.genesis import compute_identity_id
33 from musehub.db.musehub_repo_models import MusehubBridgeMirror, MusehubRepo
34 from musehub.models.bridge import CreateMirrorRequest, MirrorListResponse, MirrorResponse
35 from musehub.types.json_types import JSONObject, StrDict
36
37
38 # ---------------------------------------------------------------------------
39 # Helpers
40 # ---------------------------------------------------------------------------
41
42
43 async def _create_repo(
44 client: AsyncClient,
45 auth_headers: StrDict,
46 name: str = "bridge-test-repo",
47 ) -> str:
48 resp = await client.post(
49 "/api/repos",
50 json={"name": name, "owner": "testuser"},
51 headers=auth_headers,
52 )
53 assert resp.status_code == 201, resp.text
54 return resp.json()["repoId"]
55
56
57 async def _create_mirror(
58 client: AsyncClient,
59 auth_headers: StrDict,
60 repo_id: str,
61 git_remote_url: str = "https://github.com/example/repo.git",
62 git_branch: str = "muse-mirror",
63 direction: str = "export",
64 auto_export: bool = False,
65 ) -> JSONObject:
66 resp = await client.post(
67 f"/api/repos/{repo_id}/mirrors",
68 json={
69 "git_remote_url": git_remote_url,
70 "git_branch": git_branch,
71 "direction": direction,
72 "auto_export": auto_export,
73 },
74 headers=auth_headers,
75 )
76 assert resp.status_code == 201, resp.text
77 return resp.json()
78
79
80 # ---------------------------------------------------------------------------
81 # Tier 1 — Schema
82 # ---------------------------------------------------------------------------
83
84
85 def test_mirror_model_has_required_fields() -> None:
86 """MirrorResponse requires all mandatory fields."""
87 with pytest.raises(ValidationError):
88 # Missing required fields
89 MirrorResponse() # type: ignore[call-arg]
90
91
92 def test_create_mirror_request_validates_direction() -> None:
93 """CreateMirrorRequest accepts valid directions and rejects unknown ones."""
94 for valid in ("export", "import", "bidirectional"):
95 req = CreateMirrorRequest(
96 git_remote_url="https://github.com/a/b.git",
97 direction=valid,
98 )
99 assert req.direction == valid
100
101 with pytest.raises(ValidationError):
102 CreateMirrorRequest(
103 git_remote_url="https://github.com/a/b.git",
104 direction="unknown",
105 )
106
107
108 # ---------------------------------------------------------------------------
109 # Tier 2 — Integration (DB)
110 # ---------------------------------------------------------------------------
111
112
113 async def test_create_mirror_stored_in_db(
114 client: AsyncClient,
115 auth_headers: StrDict,
116 ) -> None:
117 """POST creates a mirror row; GET lists it."""
118 repo_id = await _create_repo(client, auth_headers, "mirror-create-repo")
119 data = await _create_mirror(client, auth_headers, repo_id)
120
121 assert data["repoId"] == repo_id
122 assert data["gitRemoteUrl"] == "https://github.com/example/repo.git"
123 assert data["direction"] == "export"
124 assert data["autoExport"] is False
125 assert "id" in data
126
127 # Confirm GET lists the mirror — response uses camelCase
128 resp = await client.get(f"/api/repos/{repo_id}/mirrors", headers=auth_headers)
129 assert resp.status_code == 200
130 body = resp.json()
131 assert body["total"] == 1
132 assert body["mirrors"][0]["id"] == data["id"]
133 assert body["mirrors"][0]["repoId"] == repo_id
134
135
136 async def test_list_mirrors_empty_for_new_repo(
137 client: AsyncClient,
138 auth_headers: StrDict,
139 ) -> None:
140 """GET returns an empty list for a repo with no mirrors."""
141 repo_id = await _create_repo(client, auth_headers, "mirror-empty-repo")
142 resp = await client.get(f"/api/repos/{repo_id}/mirrors", headers=auth_headers)
143 assert resp.status_code == 200
144 body = resp.json()
145 assert body["mirrors"] == []
146 assert body["total"] == 0
147
148
149 async def test_delete_mirror_removes_row(
150 client: AsyncClient,
151 auth_headers: StrDict,
152 ) -> None:
153 """DELETE removes the mirror; subsequent GET shows empty list."""
154 repo_id = await _create_repo(client, auth_headers, "mirror-delete-repo")
155 mirror = await _create_mirror(client, auth_headers, repo_id)
156 mirror_id = mirror["id"]
157
158 resp = await client.delete(
159 f"/api/repos/{repo_id}/mirrors/{mirror_id}",
160 headers=auth_headers,
161 )
162 assert resp.status_code == 204
163
164 resp = await client.get(f"/api/repos/{repo_id}/mirrors", headers=auth_headers)
165 assert resp.status_code == 200
166 assert resp.json()["total"] == 0
167
168
169 async def test_duplicate_url_rejected(
170 client: AsyncClient,
171 auth_headers: StrDict,
172 ) -> None:
173 """Registering the same git_remote_url twice for the same repo returns 409."""
174 repo_id = await _create_repo(client, auth_headers, "mirror-dup-repo")
175 url = "https://github.com/example/dup.git"
176 await _create_mirror(client, auth_headers, repo_id, git_remote_url=url)
177
178 resp = await client.post(
179 f"/api/repos/{repo_id}/mirrors",
180 json={"git_remote_url": url, "direction": "export"},
181 headers=auth_headers,
182 )
183 assert resp.status_code == 409
184
185
186 # ---------------------------------------------------------------------------
187 # Tier 3 — Edge Cases
188 # ---------------------------------------------------------------------------
189
190
191 async def test_create_mirror_nonexistent_repo_returns_404(
192 client: AsyncClient,
193 auth_headers: StrDict,
194 ) -> None:
195 """POST to a non-existent repo_id returns 404."""
196 resp = await client.post(
197 "/api/repos/nonexistent-repo-id/mirrors",
198 json={"git_remote_url": "https://github.com/a/b.git", "direction": "export"},
199 headers=auth_headers,
200 )
201 assert resp.status_code == 404
202
203
204 async def test_delete_nonexistent_mirror_returns_404(
205 client: AsyncClient,
206 auth_headers: StrDict,
207 ) -> None:
208 """DELETE for a mirror_id that doesn't exist returns 404."""
209 repo_id = await _create_repo(client, auth_headers, "mirror-del-404-repo")
210 resp = await client.delete(
211 f"/api/repos/{repo_id}/mirrors/does-not-exist",
212 headers=auth_headers,
213 )
214 assert resp.status_code == 404
215
216
217 async def test_direction_invalid_returns_422(
218 client: AsyncClient,
219 auth_headers: StrDict,
220 ) -> None:
221 """POST with an invalid direction returns 422."""
222 repo_id = await _create_repo(client, auth_headers, "mirror-422-repo")
223 resp = await client.post(
224 f"/api/repos/{repo_id}/mirrors",
225 json={"git_remote_url": "https://github.com/a/b.git", "direction": "sideways"},
226 headers=auth_headers,
227 )
228 assert resp.status_code == 422
229
230
231 # ---------------------------------------------------------------------------
232 # Tier 4 — Data Integrity
233 # ---------------------------------------------------------------------------
234
235
236 async def test_cascade_delete_on_repo_delete(
237 client: AsyncClient,
238 auth_headers: StrDict,
239 db_session: AsyncSession,
240 ) -> None:
241 """Deleting the parent repo cascades to its bridge mirrors."""
242 from sqlalchemy import select
243
244 repo_id = await _create_repo(client, auth_headers, "mirror-cascade-repo")
245 mirror = await _create_mirror(client, auth_headers, repo_id)
246 mirror_id = mirror["id"]
247
248 # Verify the mirror exists in the DB
249 row = await db_session.get(MusehubBridgeMirror, mirror_id)
250 assert row is not None
251
252 # Delete the repo
253 resp = await client.delete(f"/api/repos/{repo_id}", headers=auth_headers)
254 assert resp.status_code == 204
255
256 # Mirror must be gone via CASCADE
257 db_session.expire_all()
258 row_after = await db_session.get(MusehubBridgeMirror, mirror_id)
259 assert row_after is None
260
261
262 # ---------------------------------------------------------------------------
263 # Tier 5 — Auth
264 # ---------------------------------------------------------------------------
265
266
267 async def test_create_requires_auth(
268 client: AsyncClient,
269 auth_headers: StrDict,
270 ) -> None:
271 """Unauthenticated POST returns 401."""
272 from musehub.auth.request_signing import optional_signed_request, require_signed_request
273 from musehub.main import app as _app
274
275 repo_id = await _create_repo(client, auth_headers, "mirror-auth-repo")
276
277 # Remove the auth override so the real token validator runs.
278 _app.dependency_overrides.pop(require_signed_request, None)
279 _app.dependency_overrides.pop(optional_signed_request, None)
280
281 resp = await client.post(
282 f"/api/repos/{repo_id}/mirrors",
283 json={"git_remote_url": "https://github.com/a/b.git", "direction": "export"},
284 )
285 assert resp.status_code == 401
286
287
288 async def test_delete_requires_owner(
289 client: AsyncClient,
290 auth_headers: StrDict,
291 db_session: AsyncSession,
292 ) -> None:
293 """A non-owner cannot delete a mirror — should get 403."""
294 from musehub.auth.request_signing import require_signed_request, optional_signed_request, MSignContext
295 from musehub.main import app as _app
296
297 repo_id = await _create_repo(client, auth_headers, "mirror-403-repo")
298 mirror = await _create_mirror(client, auth_headers, repo_id)
299 mirror_id = mirror["id"]
300
301 # Override auth to a different user (not the owner "testuser")
302 _OTHER_CONTEXT = MSignContext(
303 handle="other-user",
304 identity_id="other-user-id",
305 is_agent=False,
306 is_admin=False,
307 )
308 _app.dependency_overrides[require_signed_request] = lambda: _OTHER_CONTEXT
309 _app.dependency_overrides[optional_signed_request] = lambda: _OTHER_CONTEXT
310 try:
311 resp = await client.delete(
312 f"/api/repos/{repo_id}/mirrors/{mirror_id}",
313 headers={"Content-Type": "application/json"},
314 )
315 assert resp.status_code == 403
316 finally:
317 # Restore the testuser override so the auth_headers fixture teardown
318 # pops only what it set, and other tests in the session are unaffected.
319 _TEST_CONTEXT = MSignContext(
320 handle="testuser",
321 identity_id=compute_identity_id(b"testuser"),
322 is_agent=False,
323 is_admin=False,
324 )
325 _app.dependency_overrides[require_signed_request] = lambda: _TEST_CONTEXT
326 _app.dependency_overrides[optional_signed_request] = lambda: _TEST_CONTEXT