gabriel / musehub public
test_musehub_collaborators.py python
255 lines 9.2 KB
Raw
sha256:9590cee1e0ccd6c76528f005b95d634d80f5019f0dcb7c371e149adc31d1fb65 refactor: enforce gRPC framing on all MWP wire traffic Sonnet 4.6 minor ⚠ breaking 156 days ago
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 from httpx import AsyncClient
17 from musehub.types.json_types import JSONObject, StrDict
18
19 # ── Constants ─────────────────────────────────────────────────────────────────
20
21 _COLLABORATOR_HANDLE = "collabuser"
22
23
24 # ── Helpers ───────────────────────────────────────────────────────────────────
25
26
27 async def _create_repo(client: AsyncClient, auth_headers: StrDict, name: str = "collab-test-repo") -> str:
28 """Create a repo via the API and return its repo_id."""
29 response = await client.post(
30 "/api/repos",
31 json={"name": name, "owner": "testuser"},
32 headers=auth_headers,
33 )
34 assert response.status_code == 201, response.text
35 repo_id: str = response.json()["repoId"]
36 return repo_id
37
38
39 async def _invite_collaborator(
40 client: AsyncClient,
41 auth_headers: StrDict,
42 repo_id: str,
43 handle: str = _COLLABORATOR_HANDLE,
44 permission: str = "write",
45 ) -> JSONObject:
46 """Invite a collaborator via the API."""
47 response = await client.post(
48 f"/api/repos/{repo_id}/collaborators",
49 json={"handle": handle, "permission": permission},
50 headers=auth_headers,
51 )
52 assert response.status_code == 201, response.text
53 data = response.json()
54 return data
55
56
57 # ── POST /collaborators ───────────────────────────────────────────────────────
58
59
60 async def test_invite_collaborator_returns_201(
61 client: AsyncClient,
62 auth_headers: StrDict,
63 ) -> None:
64 """Owner can invite a collaborator; response contains all required fields."""
65 repo_id = await _create_repo(client, auth_headers, "invite-201-repo")
66 data = await _invite_collaborator(client, auth_headers, repo_id)
67
68 assert data["handle"] == _COLLABORATOR_HANDLE
69 assert data["repoId"] == repo_id
70 assert data["permission"] == "write"
71 assert "collaboratorId" in data
72
73
74 async def test_invite_collaborator_duplicate_returns_409(
75 client: AsyncClient,
76 auth_headers: StrDict,
77 ) -> None:
78 """Inviting the same user twice returns 409 Conflict."""
79 repo_id = await _create_repo(client, auth_headers, "invite-dup-repo")
80 await _invite_collaborator(client, auth_headers, repo_id)
81
82 response = await client.post(
83 f"/api/repos/{repo_id}/collaborators",
84 json={"handle": _COLLABORATOR_HANDLE, "permission": "read"},
85 headers=auth_headers,
86 )
87 assert response.status_code == 409
88
89
90 async def test_invite_collaborator_unknown_repo_returns_404(
91 client: AsyncClient,
92 auth_headers: StrDict,
93 ) -> None:
94 """Inviting a collaborator to a non-existent repo returns 404."""
95 response = await client.post(
96 "/api/repos/nonexistent-repo-id/collaborators",
97 json={"handle": _COLLABORATOR_HANDLE, "permission": "read"},
98 headers=auth_headers,
99 )
100 assert response.status_code == 404
101
102
103 async def test_invite_collaborator_requires_auth(
104 client: AsyncClient,
105 ) -> None:
106 """POST /collaborators returns 401 without a MSign Authorization header."""
107 response = await client.post(
108 "/api/repos/some-repo/collaborators",
109 json={"handle": _COLLABORATOR_HANDLE, "permission": "read"},
110 )
111 assert response.status_code == 401
112
113
114 # ── GET /collaborators ────────────────────────────────────────────────────────
115
116
117 async def test_list_collaborators_empty(
118 client: AsyncClient,
119 auth_headers: StrDict,
120 ) -> None:
121 """GET /collaborators returns empty list for a repo with no collaborators."""
122 repo_id = await _create_repo(client, auth_headers, "list-empty-repo")
123 response = await client.get(
124 f"/api/repos/{repo_id}/collaborators",
125 headers=auth_headers,
126 )
127 assert response.status_code == 200
128 body = response.json()
129 assert body["total"] == 0
130 assert body["collaborators"] == []
131
132
133 async def test_list_collaborators_after_invite(
134 client: AsyncClient,
135 auth_headers: StrDict,
136 ) -> None:
137 """GET /collaborators returns the invited collaborator after POST."""
138 repo_id = await _create_repo(client, auth_headers, "list-after-invite-repo")
139 await _invite_collaborator(client, auth_headers, repo_id)
140
141 response = await client.get(
142 f"/api/repos/{repo_id}/collaborators",
143 headers=auth_headers,
144 )
145 assert response.status_code == 200
146 body = response.json()
147 assert body["total"] == 1
148 assert body["collaborators"][0]["handle"] == _COLLABORATOR_HANDLE
149
150
151 # ── GET /collaborators/{user_id}/permission ───────────────────────────────────
152
153
154 async def test_check_permission_not_collaborator(
155 client: AsyncClient,
156 auth_headers: StrDict,
157 ) -> None:
158 """Permission check returns 404 for a non-member user (access-check semantics)."""
159 repo_id = await _create_repo(client, auth_headers, "perm-check-not-member-repo")
160 response = await client.get(
161 f"/api/repos/{repo_id}/collaborators/{_COLLABORATOR_HANDLE}/permission",
162 headers=auth_headers,
163 )
164 assert response.status_code == 404
165 assert _COLLABORATOR_HANDLE in response.json()["detail"]
166
167
168 async def test_check_permission_is_collaborator(
169 client: AsyncClient,
170 auth_headers: StrDict,
171 ) -> None:
172 """Permission check returns username and permission level after invite."""
173 repo_id = await _create_repo(client, auth_headers, "perm-check-member-repo")
174 await _invite_collaborator(client, auth_headers, repo_id, permission="admin")
175
176 response = await client.get(
177 f"/api/repos/{repo_id}/collaborators/{_COLLABORATOR_HANDLE}/permission",
178 headers=auth_headers,
179 )
180 assert response.status_code == 200
181 body = response.json()
182 assert body["username"] == _COLLABORATOR_HANDLE
183 assert body["permission"] == "admin"
184
185
186 # ── PUT /collaborators/{user_id}/permission ───────────────────────────────────
187
188
189 async def test_update_permission_success(
190 client: AsyncClient,
191 auth_headers: StrDict,
192 ) -> None:
193 """Owner can update a collaborator's permission level."""
194 repo_id = await _create_repo(client, auth_headers, "update-perm-repo")
195 await _invite_collaborator(client, auth_headers, repo_id, permission="read")
196
197 response = await client.put(
198 f"/api/repos/{repo_id}/collaborators/{_COLLABORATOR_HANDLE}/permission",
199 json={"permission": "admin"},
200 headers=auth_headers,
201 )
202 assert response.status_code == 200
203 body = response.json()
204 assert body["permission"] == "admin"
205
206
207 async def test_update_permission_not_found_returns_404(
208 client: AsyncClient,
209 auth_headers: StrDict,
210 ) -> None:
211 """Updating permission for a non-collaborator returns 404."""
212 repo_id = await _create_repo(client, auth_headers, "update-perm-404-repo")
213 response = await client.put(
214 f"/api/repos/{repo_id}/collaborators/{_COLLABORATOR_HANDLE}/permission",
215 json={"permission": "admin"},
216 headers=auth_headers,
217 )
218 assert response.status_code == 404
219
220
221 # ── DELETE /collaborators/{user_id} ──────────────────────────────────────────
222
223
224 async def test_remove_collaborator_success(
225 client: AsyncClient,
226 auth_headers: StrDict,
227 ) -> None:
228 """Owner can remove a collaborator; subsequent list shows 0 collaborators."""
229 repo_id = await _create_repo(client, auth_headers, "remove-collab-repo")
230 await _invite_collaborator(client, auth_headers, repo_id)
231
232 response = await client.delete(
233 f"/api/repos/{repo_id}/collaborators/{_COLLABORATOR_HANDLE}",
234 headers=auth_headers,
235 )
236 assert response.status_code == 204
237
238 list_response = await client.get(
239 f"/api/repos/{repo_id}/collaborators",
240 headers=auth_headers,
241 )
242 assert list_response.json()["total"] == 0
243
244
245 async def test_remove_collaborator_not_found_returns_404(
246 client: AsyncClient,
247 auth_headers: StrDict,
248 ) -> None:
249 """Removing a non-collaborator returns 404."""
250 repo_id = await _create_repo(client, auth_headers, "remove-404-repo")
251 response = await client.delete(
252 f"/api/repos/{repo_id}/collaborators/{_COLLABORATOR_HANDLE}",
253 headers=auth_headers,
254 )
255 assert response.status_code == 404
File History 1 commit
sha256:9590cee1e0ccd6c76528f005b95d634d80f5019f0dcb7c371e149adc31d1fb65 refactor: enforce gRPC framing on all MWP wire traffic Sonnet 4.6 minor ⚠ 156 days ago