gabriel / musehub public

test_musehub_collaborators.py file-level

at sha256:0 · 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 MuseHub collaborators management endpoints.
2
3 Covers the acceptance criteria:
4 - GET /repos/{repo_id}/collaborators returns collaborator list
5 - POST /repos/{repo_id}/collaborators invites a collaborator (owner/admin+)
6 - PUT /repos/{repo_id}/collaborators/{handle}/permission updates permission
7 - DELETE /repos/{repo_id}/collaborators/{handle} removes collaborator
8 - GET /repos/{repo_id}/collaborators/{handle}/permission checks presence
9 - Owner cannot be removed as a collaborator
10 - Only admin+ (or owner) may mutate collaborators
11 - Duplicate invite returns 409
12 """
13 from __future__ import annotations
14
15 import pytest
16 import pytest_asyncio
17 from httpx import AsyncClient
18 from sqlalchemy.ext.asyncio import AsyncSession
19 from musehub.core.genesis import compute_identity_id
20 from musehub.db.musehub_identity_models import MusehubIdentity
21 from musehub.types.json_types import JSONObject, StrDict
22
23 # ── Constants ─────────────────────────────────────────────────────────────────
24
25 _COLLABORATOR_HANDLE = "collabuser"
26
27
28 # ── Fixtures ──────────────────────────────────────────────────────────────────
29
30
31 @pytest_asyncio.fixture(autouse=True)
32 async def collab_user(db_session: AsyncSession) -> MusehubIdentity:
33 """Seed the collabuser identity so invite endpoint can resolve it."""
34 identity = MusehubIdentity(
35 identity_id=compute_identity_id(_COLLABORATOR_HANDLE.encode()),
36 handle=_COLLABORATOR_HANDLE,
37 display_name="Collab User",
38 identity_type="human",
39 )
40 db_session.add(identity)
41 await db_session.commit()
42 await db_session.refresh(identity)
43 await db_session.commit()
44 return identity
45
46
47 # ── Helpers ───────────────────────────────────────────────────────────────────
48
49
50 async def _create_repo(client: AsyncClient, auth_headers: StrDict, name: str = "collab-test-repo") -> str:
51 """Create a repo via the API and return its repo_id."""
52 response = await client.post(
53 "/api/repos",
54 json={"name": name, "owner": "testuser"},
55 headers=auth_headers,
56 )
57 assert response.status_code == 201, response.text
58 repo_id: str = response.json()["repoId"]
59 return repo_id
60
61
62 async def _invite_collaborator(
63 client: AsyncClient,
64 auth_headers: StrDict,
65 repo_id: str,
66 handle: str = _COLLABORATOR_HANDLE,
67 permission: str = "write",
68 ) -> JSONObject:
69 """Invite a collaborator via the API."""
70 response = await client.post(
71 f"/api/repos/{repo_id}/collaborators",
72 json={"handle": handle, "permission": permission},
73 headers=auth_headers,
74 )
75 assert response.status_code == 201, response.text
76 data = response.json()
77 return data
78
79
80 # ── POST /collaborators ───────────────────────────────────────────────────────
81
82
83 async def test_invite_collaborator_returns_201(
84 client: AsyncClient,
85 auth_headers: StrDict,
86 ) -> None:
87 """Owner can invite a collaborator; response contains all required fields."""
88 repo_id = await _create_repo(client, auth_headers, "invite-201-repo")
89 data = await _invite_collaborator(client, auth_headers, repo_id)
90
91 assert data["handle"] == _COLLABORATOR_HANDLE
92 assert data["repoId"] == repo_id
93 assert data["permission"] == "write"
94 assert "collaboratorId" in data
95
96
97 async def test_invite_collaborator_duplicate_returns_409(
98 client: AsyncClient,
99 auth_headers: StrDict,
100 ) -> None:
101 """Inviting the same user twice returns 409 Conflict."""
102 repo_id = await _create_repo(client, auth_headers, "invite-dup-repo")
103 await _invite_collaborator(client, auth_headers, repo_id)
104
105 response = await client.post(
106 f"/api/repos/{repo_id}/collaborators",
107 json={"handle": _COLLABORATOR_HANDLE, "permission": "read"},
108 headers=auth_headers,
109 )
110 assert response.status_code == 409
111
112
113 async def test_invite_collaborator_unknown_repo_returns_404(
114 client: AsyncClient,
115 auth_headers: StrDict,
116 ) -> None:
117 """Inviting a collaborator to a non-existent repo returns 404."""
118 response = await client.post(
119 "/api/repos/nonexistent-repo-id/collaborators",
120 json={"handle": _COLLABORATOR_HANDLE, "permission": "read"},
121 headers=auth_headers,
122 )
123 assert response.status_code == 404
124
125
126 async def test_invite_collaborator_requires_auth(
127 client: AsyncClient,
128 ) -> None:
129 """POST /collaborators returns 401 without a MSign Authorization header."""
130 response = await client.post(
131 "/api/repos/some-repo/collaborators",
132 json={"handle": _COLLABORATOR_HANDLE, "permission": "read"},
133 )
134 assert response.status_code == 401
135
136
137 # ── GET /collaborators ────────────────────────────────────────────────────────
138
139
140 async def test_list_collaborators_empty(
141 client: AsyncClient,
142 auth_headers: StrDict,
143 ) -> None:
144 """GET /collaborators returns empty list for a repo with no collaborators."""
145 repo_id = await _create_repo(client, auth_headers, "list-empty-repo")
146 response = await client.get(
147 f"/api/repos/{repo_id}/collaborators",
148 headers=auth_headers,
149 )
150 assert response.status_code == 200
151 body = response.json()
152 assert body["total"] == 0
153 assert body["collaborators"] == []
154
155
156 async def test_list_collaborators_after_invite(
157 client: AsyncClient,
158 auth_headers: StrDict,
159 ) -> None:
160 """GET /collaborators returns the invited collaborator after POST."""
161 repo_id = await _create_repo(client, auth_headers, "list-after-invite-repo")
162 await _invite_collaborator(client, auth_headers, repo_id)
163
164 response = await client.get(
165 f"/api/repos/{repo_id}/collaborators",
166 headers=auth_headers,
167 )
168 assert response.status_code == 200
169 body = response.json()
170 assert body["total"] == 1
171 assert body["collaborators"][0]["handle"] == _COLLABORATOR_HANDLE
172
173
174 # ── GET /collaborators/{user_id}/permission ───────────────────────────────────
175
176
177 async def test_check_permission_not_collaborator(
178 client: AsyncClient,
179 auth_headers: StrDict,
180 ) -> None:
181 """Permission check returns 404 for a non-member user (access-check semantics)."""
182 repo_id = await _create_repo(client, auth_headers, "perm-check-not-member-repo")
183 response = await client.get(
184 f"/api/repos/{repo_id}/collaborators/{_COLLABORATOR_HANDLE}/permission",
185 headers=auth_headers,
186 )
187 assert response.status_code == 404
188 assert _COLLABORATOR_HANDLE in response.json()["detail"]
189
190
191 async def test_check_permission_is_collaborator(
192 client: AsyncClient,
193 auth_headers: StrDict,
194 ) -> None:
195 """Permission check returns username and permission level after invite."""
196 repo_id = await _create_repo(client, auth_headers, "perm-check-member-repo")
197 await _invite_collaborator(client, auth_headers, repo_id, permission="admin")
198
199 response = await client.get(
200 f"/api/repos/{repo_id}/collaborators/{_COLLABORATOR_HANDLE}/permission",
201 headers=auth_headers,
202 )
203 assert response.status_code == 200
204 body = response.json()
205 assert body["username"] == _COLLABORATOR_HANDLE
206 assert body["permission"] == "admin"
207
208
209 # ── PUT /collaborators/{user_id}/permission ───────────────────────────────────
210
211
212 async def test_update_permission_success(
213 client: AsyncClient,
214 auth_headers: StrDict,
215 ) -> None:
216 """Owner can update a collaborator's permission level."""
217 repo_id = await _create_repo(client, auth_headers, "update-perm-repo")
218 await _invite_collaborator(client, auth_headers, repo_id, permission="read")
219
220 response = await client.put(
221 f"/api/repos/{repo_id}/collaborators/{_COLLABORATOR_HANDLE}/permission",
222 json={"permission": "admin"},
223 headers=auth_headers,
224 )
225 assert response.status_code == 200
226 body = response.json()
227 assert body["permission"] == "admin"
228
229
230 async def test_update_permission_not_found_returns_404(
231 client: AsyncClient,
232 auth_headers: StrDict,
233 ) -> None:
234 """Updating permission for a non-collaborator returns 404."""
235 repo_id = await _create_repo(client, auth_headers, "update-perm-404-repo")
236 response = await client.put(
237 f"/api/repos/{repo_id}/collaborators/{_COLLABORATOR_HANDLE}/permission",
238 json={"permission": "admin"},
239 headers=auth_headers,
240 )
241 assert response.status_code == 404
242
243
244 # ── DELETE /collaborators/{user_id} ──────────────────────────────────────────
245
246
247 async def test_remove_collaborator_success(
248 client: AsyncClient,
249 auth_headers: StrDict,
250 ) -> None:
251 """Owner can remove a collaborator; subsequent list shows 0 collaborators."""
252 repo_id = await _create_repo(client, auth_headers, "remove-collab-repo")
253 await _invite_collaborator(client, auth_headers, repo_id)
254
255 response = await client.delete(
256 f"/api/repos/{repo_id}/collaborators/{_COLLABORATOR_HANDLE}",
257 headers=auth_headers,
258 )
259 assert response.status_code == 204
260
261 list_response = await client.get(
262 f"/api/repos/{repo_id}/collaborators",
263 headers=auth_headers,
264 )
265 assert list_response.json()["total"] == 0
266
267
268 async def test_remove_collaborator_not_found_returns_404(
269 client: AsyncClient,
270 auth_headers: StrDict,
271 ) -> None:
272 """Removing a non-collaborator returns 404."""
273 repo_id = await _create_repo(client, auth_headers, "remove-404-repo")
274 response = await client.delete(
275 f"/api/repos/{repo_id}/collaborators/{_COLLABORATOR_HANDLE}",
276 headers=auth_headers,
277 )
278 assert response.status_code == 404