gabriel / musehub public

test_collaborators.py file-level

at sha256:8 · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 💥 blast risk
sha256:4 fix: publish_muse_release.sh failed against production on two counts D… · gabriel · Sep 12, 2026
1 """Section 27 — Collaborators & Permissions: 7-layer test suite.
2
3 Covers:
4 - musehub/api/routes/musehub/collaborators.py (CRUD + permission logic)
5 - musehub/db/musehub_collaborator_models.py (ORM model)
6 - repos.py _guard_admin / check_collaborator_access (permission gating)
7
8 Endpoints:
9 GET /api/repos/{repo_id}/collaborators
10 POST /api/repos/{repo_id}/collaborators
11 PUT /api/repos/{repo_id}/collaborators/{handle}/permission
12 DELETE /api/repos/{repo_id}/collaborators/{handle}
13 GET /api/repos/{repo_id}/collaborators/{username}/permission
14
15 Layer map
16 ---------
17 1. Unit — Permission enum, _PERMISSION_RANK, _has_permission, _orm_to_response
18 2. Integration — DB-level collaborator CRUD via session
19 3. E2E — HTTP client against full app
20 4. Stress — 50 collaborators, concurrent list calls
21 5. Data Integrity — permission stored correctly, invited_by set, unique constraint
22 6. Security — auth required, non-admin blocked, owner un-removable
23 7. Performance — timing budgets
24 """
25 from __future__ import annotations
26
27 import asyncio
28 import secrets
29 import time
30 from datetime import datetime, timezone
31
32 import pytest
33 from httpx import AsyncClient
34 from muse.core.types import fake_id
35 from musehub.core.genesis import compute_identity_id, compute_repo_id
36 from sqlalchemy import select
37 from sqlalchemy.ext.asyncio import AsyncSession
38
39 from musehub.types.json_types import StrDict
40 from musehub.api.routes.musehub.collaborators import (
41 Permission,
42 _PERMISSION_RANK,
43 _has_permission,
44 _orm_to_response,
45 )
46 from musehub.db.musehub_collaborator_models import MusehubCollaborator
47 from musehub.db.musehub_identity_models import MusehubIdentity
48 from musehub.db.musehub_repo_models import MusehubRepo
49
50
51 # ---------------------------------------------------------------------------
52 # Fixtures / helpers
53 # ---------------------------------------------------------------------------
54
55 _TEST_HANDLE = "testuser" # matches auth_headers fixture's token.handle
56
57
58 def _uid() -> str:
59 return secrets.token_hex(16)
60
61
62 async def _db_repo(
63 session: AsyncSession,
64 owner: str = _TEST_HANDLE,
65 *,
66 visibility: str = "private",
67 ) -> MusehubRepo:
68 slug = f"repo-{_uid()[:8]}"
69 created_at = datetime.now(tz=timezone.utc)
70 owner_id = compute_identity_id(owner.encode())
71 repo = MusehubRepo(
72 repo_id=compute_repo_id(owner_id, slug, "code", created_at.isoformat()),
73 name=slug,
74 slug=slug,
75 owner=owner,
76 owner_user_id=owner_id,
77 visibility=visibility,
78 created_at=created_at,
79 updated_at=created_at,
80 )
81 session.add(repo)
82 await session.flush()
83 return repo
84
85
86 async def _db_collab(
87 session: AsyncSession,
88 repo_id: str,
89 handle: str,
90 *,
91 permission: str = "write",
92 invited_by: str | None = None,
93 accepted: bool = False,
94 ) -> MusehubCollaborator:
95 c = MusehubCollaborator(
96 id=fake_id(f"{repo_id}-{handle}"),
97 repo_id=repo_id,
98 identity_handle=handle,
99 permission=permission,
100 invited_by_handle=invited_by,
101 accepted_at=datetime.now(timezone.utc) if accepted else None,
102 )
103 session.add(c)
104 await session.flush()
105 return c
106
107
108 async def _db_identity(session: AsyncSession, handle: str) -> MusehubIdentity:
109 """Create a MusehubIdentity for *handle* and flush it into the session."""
110 identity = MusehubIdentity(
111 identity_id=_uid(),
112 handle=handle,
113 display_name=handle.title(),
114 identity_type="human",
115 )
116 session.add(identity)
117 await session.flush()
118 return identity
119
120
121 async def _api_repo(
122 client: AsyncClient,
123 auth_headers: StrDict,
124 *,
125 visibility: str = "private",
126 ) -> str:
127 r = await client.post(
128 "/api/repos",
129 json={"name": f"collab-{_uid()[:8]}", "owner": _TEST_HANDLE, "visibility": visibility},
130 headers=auth_headers,
131 )
132 assert r.status_code == 201, r.text
133 return r.json()["repoId"]
134
135
136 # ===========================================================================
137 # Layer 1 — Unit
138 # ===========================================================================
139
140
141 class TestUnitPermissionEnum:
142 def test_values(self) -> None:
143 assert Permission.read == "read"
144 assert Permission.write == "write"
145 assert Permission.admin == "admin"
146 assert Permission.owner == "owner"
147
148 def test_four_levels(self) -> None:
149 assert len(list(Permission)) == 4
150
151
152 class TestUnitPermissionRank:
153 def test_read_is_lowest(self) -> None:
154 assert _PERMISSION_RANK["read"] < _PERMISSION_RANK["write"]
155
156 def test_write_lt_admin(self) -> None:
157 assert _PERMISSION_RANK["write"] < _PERMISSION_RANK["admin"]
158
159 def test_admin_lt_owner(self) -> None:
160 assert _PERMISSION_RANK["admin"] < _PERMISSION_RANK["owner"]
161
162 def test_all_levels_covered(self) -> None:
163 for p in Permission:
164 assert p.value in _PERMISSION_RANK
165
166
167 class TestUnitHasPermission:
168 def test_exact_match(self) -> None:
169 assert _has_permission("write", Permission.write) is True
170
171 def test_higher_grants_lower(self) -> None:
172 assert _has_permission("admin", Permission.write) is True
173 assert _has_permission("owner", Permission.read) is True
174
175 def test_lower_denied_higher(self) -> None:
176 assert _has_permission("read", Permission.write) is False
177 assert _has_permission("write", Permission.admin) is False
178
179 def test_unknown_permission_denied(self) -> None:
180 assert _has_permission("", Permission.read) is False
181 assert _has_permission("superuser", Permission.read) is False
182
183 def test_read_satisfies_read(self) -> None:
184 assert _has_permission("read", Permission.read) is True
185
186 def test_owner_satisfies_admin(self) -> None:
187 assert _has_permission("owner", Permission.admin) is True
188
189
190 class TestUnitOrmToResponse:
191 async def test_fields_mapped_correctly(self, db_session: AsyncSession) -> None:
192 repo = await _db_repo(db_session)
193 collab = await _db_collab(
194 db_session, repo.repo_id, "alice",
195 permission="write", invited_by="bob"
196 )
197 resp = _orm_to_response(collab)
198 assert resp.handle == "alice"
199 assert resp.permission == "write"
200 assert resp.invited_by == "bob"
201 assert resp.repo_id == repo.repo_id
202 assert resp.collaborator_id == collab.id
203
204 async def test_invited_by_none_when_null(self, db_session: AsyncSession) -> None:
205 repo = await _db_repo(db_session)
206 collab = await _db_collab(db_session, repo.repo_id, "carol", invited_by=None)
207 resp = _orm_to_response(collab)
208 assert resp.invited_by is None
209
210
211 # ===========================================================================
212 # Layer 2 — Integration (DB-level)
213 # ===========================================================================
214
215
216 class TestIntegrationCollaboratorDB:
217 async def test_insert_and_query(self, db_session: AsyncSession) -> None:
218 repo = await _db_repo(db_session)
219 collab = await _db_collab(db_session, repo.repo_id, "alice", permission="admin")
220 await db_session.flush()
221
222 result = await db_session.execute(
223 select(MusehubCollaborator).where(
224 MusehubCollaborator.repo_id == repo.repo_id
225 )
226 )
227 rows = result.scalars().all()
228 assert len(rows) == 1
229 assert rows[0].identity_handle == "alice"
230 assert rows[0].permission == "admin"
231
232 async def test_unique_constraint_on_repo_handle(
233 self, db_session: AsyncSession
234 ) -> None:
235 from sqlalchemy.exc import IntegrityError
236
237 repo = await _db_repo(db_session)
238 await _db_collab(db_session, repo.repo_id, "alice")
239 await db_session.flush()
240
241 dup = MusehubCollaborator(
242 id=_uid(),
243 repo_id=repo.repo_id,
244 identity_handle="alice",
245 permission="read",
246 )
247 db_session.add(dup)
248 with pytest.raises(IntegrityError):
249 await db_session.flush()
250
251 async def test_delete_collaborator_directly(self, db_session: AsyncSession) -> None:
252 # Verify that a collaborator can be deleted explicitly and is gone afterwards.
253 repo = await _db_repo(db_session)
254 collab = await _db_collab(db_session, repo.repo_id, "alice")
255 await db_session.commit()
256
257 await db_session.delete(collab)
258 await db_session.commit()
259
260 result = await db_session.execute(
261 select(MusehubCollaborator).where(
262 MusehubCollaborator.repo_id == repo.repo_id
263 )
264 )
265 assert result.scalars().first() is None
266
267 async def test_accepted_at_null_by_default(self, db_session: AsyncSession) -> None:
268 repo = await _db_repo(db_session)
269 collab = await _db_collab(db_session, repo.repo_id, "dave")
270 assert collab.accepted_at is None
271
272 async def test_permission_default_write(self, db_session: AsyncSession) -> None:
273 repo = await _db_repo(db_session)
274 collab = MusehubCollaborator(
275 id=_uid(),
276 repo_id=repo.repo_id,
277 identity_handle="eve",
278 )
279 db_session.add(collab)
280 await db_session.flush()
281 assert collab.permission == "write"
282
283
284 # ===========================================================================
285 # Layer 3 — E2E
286 # ===========================================================================
287
288
289 class TestE2EListCollaborators:
290 async def test_list_returns_200(
291 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
292 ) -> None:
293 repo_id = await _api_repo(client, auth_headers)
294 await _db_collab(db_session, repo_id, "alice")
295 await db_session.commit()
296
297 r = await client.get(f"/api/repos/{repo_id}/collaborators", headers=auth_headers)
298 assert r.status_code == 200
299 body = r.json()
300 assert "collaborators" in body
301 assert "total" in body
302 assert body["total"] == 1
303
304 async def test_list_requires_auth(
305 self, client: AsyncClient, db_session: AsyncSession
306 ) -> None:
307 repo = await _db_repo(db_session)
308 await db_session.commit()
309
310 r = await client.get(f"/api/repos/{repo.repo_id}/collaborators")
311 assert r.status_code == 401
312
313 async def test_list_unknown_repo_404(
314 self, client: AsyncClient, auth_headers: StrDict
315 ) -> None:
316 r = await client.get("/api/repos/no-such-repo/collaborators", headers=auth_headers)
317 assert r.status_code == 404
318
319 async def test_list_empty_repo(
320 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
321 ) -> None:
322 repo_id = await _api_repo(client, auth_headers)
323 await db_session.commit()
324
325 r = await client.get(f"/api/repos/{repo_id}/collaborators", headers=auth_headers)
326 assert r.status_code == 200
327 assert r.json()["total"] == 0
328
329
330 class TestE2EInviteCollaborator:
331 async def test_owner_can_invite_201(
332 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
333 ) -> None:
334 repo_id = await _api_repo(client, auth_headers)
335 await _db_identity(db_session, "alice")
336 await db_session.commit()
337
338 r = await client.post(
339 f"/api/repos/{repo_id}/collaborators",
340 json={"handle": "alice", "permission": "write"},
341 headers=auth_headers,
342 )
343 assert r.status_code == 201
344 body = r.json()
345 assert body["handle"] == "alice"
346 assert body["permission"] == "write"
347 assert body["invitedBy"] == _TEST_HANDLE
348
349 async def test_invite_requires_auth(
350 self, client: AsyncClient, db_session: AsyncSession
351 ) -> None:
352 repo = await _db_repo(db_session)
353 await db_session.commit()
354
355 r = await client.post(
356 f"/api/repos/{repo.repo_id}/collaborators",
357 json={"handle": "bob", "permission": "read"},
358 )
359 assert r.status_code == 401
360
361 async def test_non_admin_gets_403(
362 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
363 ) -> None:
364 """testuser is not owner (alice owns repo) and has only 'write' — gets 403."""
365 repo = await _db_repo(db_session, owner="alice")
366 await _db_collab(db_session, repo.repo_id, _TEST_HANDLE, permission="write")
367 await db_session.commit()
368
369 r = await client.post(
370 f"/api/repos/{repo.repo_id}/collaborators",
371 json={"handle": "bob", "permission": "read"},
372 headers=auth_headers,
373 )
374 assert r.status_code == 403
375
376 async def test_admin_collab_can_invite(
377 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
378 ) -> None:
379 """testuser has admin permission → can invite."""
380 repo = await _db_repo(db_session, owner="alice")
381 await _db_collab(db_session, repo.repo_id, _TEST_HANDLE, permission="admin", accepted=True)
382 await _db_identity(db_session, "bob")
383 await db_session.commit()
384
385 r = await client.post(
386 f"/api/repos/{repo.repo_id}/collaborators",
387 json={"handle": "bob", "permission": "read"},
388 headers=auth_headers,
389 )
390 assert r.status_code == 201
391
392 async def test_duplicate_invite_409(
393 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
394 ) -> None:
395 repo_id = await _api_repo(client, auth_headers)
396 await _db_identity(db_session, "alice")
397 await db_session.commit()
398
399 body = {"handle": "alice", "permission": "write"}
400 r1 = await client.post(
401 f"/api/repos/{repo_id}/collaborators", json=body, headers=auth_headers
402 )
403 assert r1.status_code == 201
404
405 r2 = await client.post(
406 f"/api/repos/{repo_id}/collaborators", json=body, headers=auth_headers
407 )
408 assert r2.status_code == 409
409 assert "already a collaborator" in r2.json()["detail"]
410
411 async def test_invite_unknown_repo_404(
412 self, client: AsyncClient, auth_headers: StrDict
413 ) -> None:
414 r = await client.post(
415 "/api/repos/no-such-repo/collaborators",
416 json={"handle": "alice", "permission": "write"},
417 headers=auth_headers,
418 )
419 assert r.status_code == 404
420
421 async def test_default_permission_write(
422 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
423 ) -> None:
424 repo_id = await _api_repo(client, auth_headers)
425 await _db_identity(db_session, "alice")
426 await db_session.commit()
427
428 r = await client.post(
429 f"/api/repos/{repo_id}/collaborators",
430 json={"handle": "alice"}, # no permission field → defaults to write
431 headers=auth_headers,
432 )
433 assert r.status_code == 201
434 assert r.json()["permission"] == "write"
435
436
437 class TestE2EAcceptCollaboratorInvite:
438 """POST /repos/{repo_id}/collaborators/accept — musehub#193."""
439
440 async def test_accept_pending_invite_sets_accepted_at(
441 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
442 ) -> None:
443 repo = await _db_repo(db_session, owner="alice")
444 await _db_collab(db_session, repo.repo_id, _TEST_HANDLE, permission="write")
445 await db_session.commit()
446
447 r = await client.post(
448 f"/api/repos/{repo.repo_id}/collaborators/accept",
449 headers=auth_headers,
450 )
451 assert r.status_code == 200
452 body = r.json()
453 assert body["handle"] == _TEST_HANDLE
454 assert body["accepted"] is True
455
456 result = await db_session.execute(
457 select(MusehubCollaborator).where(
458 MusehubCollaborator.repo_id == repo.repo_id,
459 MusehubCollaborator.identity_handle == _TEST_HANDLE,
460 )
461 )
462 row = result.scalar_one()
463 assert row.accepted_at is not None
464
465 async def test_accepting_grants_the_permission_immediately(
466 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
467 ) -> None:
468 """The exact regression #193 exists to close: an admin invite is unusable until accepted."""
469 repo = await _db_repo(db_session, owner="alice")
470 await _db_collab(db_session, repo.repo_id, _TEST_HANDLE, permission="admin")
471 await _db_identity(db_session, "bob")
472 await db_session.commit()
473
474 # Before accepting: admin invite cannot yet be exercised.
475 pre = await client.post(
476 f"/api/repos/{repo.repo_id}/collaborators",
477 json={"handle": "bob", "permission": "read"},
478 headers=auth_headers,
479 )
480 assert pre.status_code == 403
481
482 accept = await client.post(
483 f"/api/repos/{repo.repo_id}/collaborators/accept", headers=auth_headers
484 )
485 assert accept.status_code == 200
486
487 # After accepting: the same admin invite now works.
488 post = await client.post(
489 f"/api/repos/{repo.repo_id}/collaborators",
490 json={"handle": "bob", "permission": "read"},
491 headers=auth_headers,
492 )
493 assert post.status_code == 201
494
495 async def test_accept_is_idempotent(
496 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
497 ) -> None:
498 repo = await _db_repo(db_session, owner="alice")
499 await _db_collab(db_session, repo.repo_id, _TEST_HANDLE, permission="write", accepted=True)
500 await db_session.commit()
501
502 r = await client.post(
503 f"/api/repos/{repo.repo_id}/collaborators/accept", headers=auth_headers
504 )
505 assert r.status_code == 200
506 assert r.json()["accepted"] is True
507
508 async def test_accept_requires_auth(
509 self, client: AsyncClient, db_session: AsyncSession
510 ) -> None:
511 repo = await _db_repo(db_session, owner="alice")
512 await _db_collab(db_session, repo.repo_id, "alice", permission="write")
513 await db_session.commit()
514
515 r = await client.post(f"/api/repos/{repo.repo_id}/collaborators/accept")
516 assert r.status_code == 401
517
518 async def test_accept_no_pending_invite_404(
519 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
520 ) -> None:
521 repo = await _db_repo(db_session, owner="alice")
522 await db_session.commit()
523
524 r = await client.post(
525 f"/api/repos/{repo.repo_id}/collaborators/accept", headers=auth_headers
526 )
527 assert r.status_code == 404
528
529 async def test_accept_unknown_repo_404(
530 self, client: AsyncClient, auth_headers: StrDict
531 ) -> None:
532 r = await client.post(
533 "/api/repos/no-such-repo/collaborators/accept", headers=auth_headers
534 )
535 assert r.status_code == 404
536
537 async def test_owner_row_is_not_created_or_needed_for_owner_actions(
538 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
539 ) -> None:
540 """The repo owner never needs to accept anything — sanity guard against
541 a future change accidentally requiring it."""
542 repo_id = await _api_repo(client, auth_headers)
543 await _db_identity(db_session, "alice")
544 await db_session.commit()
545
546 r = await client.post(
547 f"/api/repos/{repo_id}/collaborators",
548 json={"handle": "alice", "permission": "write"},
549 headers=auth_headers,
550 )
551 assert r.status_code == 201
552
553
554 class TestE2EUpdatePermission:
555 async def test_owner_can_update_200(
556 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
557 ) -> None:
558 repo_id = await _api_repo(client, auth_headers)
559 await _db_collab(db_session, repo_id, "alice", permission="read")
560 await db_session.commit()
561
562 r = await client.put(
563 f"/api/repos/{repo_id}/collaborators/alice/permission",
564 json={"permission": "admin"},
565 headers=auth_headers,
566 )
567 assert r.status_code == 200
568 assert r.json()["permission"] == "admin"
569
570 async def test_update_requires_auth(
571 self, client: AsyncClient, db_session: AsyncSession
572 ) -> None:
573 repo = await _db_repo(db_session)
574 await _db_collab(db_session, repo.repo_id, "alice")
575 await db_session.commit()
576
577 r = await client.put(
578 f"/api/repos/{repo.repo_id}/collaborators/alice/permission",
579 json={"permission": "admin"},
580 )
581 assert r.status_code == 401
582
583 async def test_non_admin_gets_403(
584 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
585 ) -> None:
586 repo = await _db_repo(db_session, owner="alice")
587 await _db_collab(db_session, repo.repo_id, _TEST_HANDLE, permission="write")
588 await _db_collab(db_session, repo.repo_id, "bob", permission="read")
589 await db_session.commit()
590
591 r = await client.put(
592 f"/api/repos/{repo.repo_id}/collaborators/bob/permission",
593 json={"permission": "admin"},
594 headers=auth_headers,
595 )
596 assert r.status_code == 403
597
598 async def test_update_owner_permission_403(
599 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
600 ) -> None:
601 """Cannot change owner's permission via this endpoint."""
602 repo = await _db_repo(db_session, owner="alice")
603 # testuser has admin permission
604 await _db_collab(db_session, repo.repo_id, _TEST_HANDLE, permission="admin", accepted=True)
605 # alice has 'owner' permission in collaborators table
606 await _db_collab(db_session, repo.repo_id, "alice", permission="owner")
607 await db_session.commit()
608
609 r = await client.put(
610 f"/api/repos/{repo.repo_id}/collaborators/alice/permission",
611 json={"permission": "write"},
612 headers=auth_headers,
613 )
614 assert r.status_code == 403
615 assert "Owner permission" in r.json()["detail"]
616
617 async def test_update_nonexistent_collab_404(
618 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
619 ) -> None:
620 repo_id = await _api_repo(client, auth_headers)
621 await db_session.commit()
622
623 r = await client.put(
624 f"/api/repos/{repo_id}/collaborators/nobody/permission",
625 json={"permission": "read"},
626 headers=auth_headers,
627 )
628 assert r.status_code == 404
629
630
631 class TestE2ERemoveCollaborator:
632 async def test_owner_can_remove_204(
633 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
634 ) -> None:
635 repo_id = await _api_repo(client, auth_headers)
636 await _db_collab(db_session, repo_id, "alice")
637 await db_session.commit()
638
639 r = await client.delete(
640 f"/api/repos/{repo_id}/collaborators/alice", headers=auth_headers
641 )
642 assert r.status_code == 204
643
644 async def test_remove_requires_auth(
645 self, client: AsyncClient, db_session: AsyncSession
646 ) -> None:
647 repo = await _db_repo(db_session)
648 await _db_collab(db_session, repo.repo_id, "alice")
649 await db_session.commit()
650
651 r = await client.delete(
652 f"/api/repos/{repo.repo_id}/collaborators/alice"
653 )
654 assert r.status_code == 401
655
656 async def test_non_admin_gets_403(
657 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
658 ) -> None:
659 repo = await _db_repo(db_session, owner="alice")
660 await _db_collab(db_session, repo.repo_id, _TEST_HANDLE, permission="write")
661 await _db_collab(db_session, repo.repo_id, "bob", permission="read")
662 await db_session.commit()
663
664 r = await client.delete(
665 f"/api/repos/{repo.repo_id}/collaborators/bob", headers=auth_headers
666 )
667 assert r.status_code == 403
668
669 async def test_remove_owner_403(
670 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
671 ) -> None:
672 """Owner-permission collaborator cannot be removed."""
673 repo = await _db_repo(db_session, owner="alice")
674 await _db_collab(db_session, repo.repo_id, _TEST_HANDLE, permission="admin", accepted=True)
675 await _db_collab(db_session, repo.repo_id, "alice", permission="owner")
676 await db_session.commit()
677
678 r = await client.delete(
679 f"/api/repos/{repo.repo_id}/collaborators/alice", headers=auth_headers
680 )
681 assert r.status_code == 403
682 assert "Owner cannot be removed" in r.json()["detail"]
683
684 async def test_remove_nonexistent_404(
685 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
686 ) -> None:
687 repo_id = await _api_repo(client, auth_headers)
688 await db_session.commit()
689
690 r = await client.delete(
691 f"/api/repos/{repo_id}/collaborators/nobody", headers=auth_headers
692 )
693 assert r.status_code == 404
694
695
696 class TestE2ECheckAccess:
697 async def test_owner_access_is_owner_permission(
698 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
699 ) -> None:
700 repo_id = await _api_repo(client, auth_headers)
701 await db_session.commit()
702
703 # testuser is the owner; check their own permission
704 r = await client.get(
705 f"/api/repos/{repo_id}/collaborators/{_TEST_HANDLE}/permission",
706 headers=auth_headers,
707 )
708 assert r.status_code == 200
709 body = r.json()
710 assert body["permission"] == "owner"
711
712 async def test_collab_access_returns_permission(
713 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
714 ) -> None:
715 repo_id = await _api_repo(client, auth_headers)
716 await _db_collab(db_session, repo_id, "alice", permission="admin")
717 await db_session.commit()
718
719 r = await client.get(
720 f"/api/repos/{repo_id}/collaborators/alice/permission",
721 headers=auth_headers,
722 )
723 assert r.status_code == 200
724 assert r.json()["permission"] == "admin"
725
726 async def test_non_collab_404(
727 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
728 ) -> None:
729 repo_id = await _api_repo(client, auth_headers)
730 await db_session.commit()
731
732 r = await client.get(
733 f"/api/repos/{repo_id}/collaborators/stranger/permission",
734 headers=auth_headers,
735 )
736 assert r.status_code == 404
737
738 async def test_check_requires_auth(
739 self, client: AsyncClient, db_session: AsyncSession
740 ) -> None:
741 repo = await _db_repo(db_session)
742 await db_session.commit()
743
744 r = await client.get(
745 f"/api/repos/{repo.repo_id}/collaborators/{_TEST_HANDLE}/permission"
746 )
747 assert r.status_code == 401
748
749
750 # ===========================================================================
751 # Layer 4 — Stress
752 # ===========================================================================
753
754
755 class TestStress:
756 async def test_list_50_collaborators(
757 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
758 ) -> None:
759 repo_id = await _api_repo(client, auth_headers)
760 for i in range(50):
761 await _db_collab(db_session, repo_id, f"user{i}", permission="read")
762 await db_session.commit()
763
764 r = await client.get(f"/api/repos/{repo_id}/collaborators", headers=auth_headers)
765 assert r.status_code == 200
766 assert r.json()["total"] == 50
767
768 async def test_5_concurrent_list_calls(
769 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
770 ) -> None:
771 repo_id = await _api_repo(client, auth_headers)
772 for i in range(10):
773 await _db_collab(db_session, repo_id, f"stress{i}")
774 await db_session.commit()
775
776 responses = await asyncio.gather(
777 *[
778 client.get(
779 f"/api/repos/{repo_id}/collaborators", headers=auth_headers
780 )
781 for _ in range(5)
782 ]
783 )
784 assert all(r.status_code == 200 for r in responses)
785 assert all(r.json()["total"] == 10 for r in responses)
786
787
788 # ===========================================================================
789 # Layer 5 — Data Integrity
790 # ===========================================================================
791
792
793 class TestDataIntegrity:
794 async def test_invited_by_set_correctly(
795 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
796 ) -> None:
797 repo_id = await _api_repo(client, auth_headers)
798 await _db_identity(db_session, "alice")
799 await db_session.commit()
800
801 r = await client.post(
802 f"/api/repos/{repo_id}/collaborators",
803 json={"handle": "alice", "permission": "read"},
804 headers=auth_headers,
805 )
806 assert r.status_code == 201
807 assert r.json()["invitedBy"] == _TEST_HANDLE
808
809 async def test_permission_persisted_correctly(
810 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
811 ) -> None:
812 repo_id = await _api_repo(client, auth_headers)
813 await _db_identity(db_session, "alice")
814 await db_session.commit()
815
816 await client.post(
817 f"/api/repos/{repo_id}/collaborators",
818 json={"handle": "alice", "permission": "admin"},
819 headers=auth_headers,
820 )
821 db_session.expire_all()
822
823 row = (
824 await db_session.execute(
825 select(MusehubCollaborator).where(
826 MusehubCollaborator.repo_id == repo_id,
827 MusehubCollaborator.identity_handle == "alice",
828 )
829 )
830 ).scalar_one_or_none()
831 assert row is not None
832 assert row.permission == "admin"
833
834 async def test_update_persisted_in_db(
835 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
836 ) -> None:
837 repo_id = await _api_repo(client, auth_headers)
838 await _db_collab(db_session, repo_id, "alice", permission="read")
839 await db_session.commit()
840
841 await client.put(
842 f"/api/repos/{repo_id}/collaborators/alice/permission",
843 json={"permission": "admin"},
844 headers=auth_headers,
845 )
846 db_session.expire_all()
847
848 row = (
849 await db_session.execute(
850 select(MusehubCollaborator).where(
851 MusehubCollaborator.repo_id == repo_id,
852 MusehubCollaborator.identity_handle == "alice",
853 )
854 )
855 ).scalar_one_or_none()
856 assert row is not None
857 assert row.permission == "admin"
858
859 async def test_remove_deletes_db_row(
860 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
861 ) -> None:
862 repo_id = await _api_repo(client, auth_headers)
863 await _db_collab(db_session, repo_id, "alice")
864 await db_session.commit()
865
866 await client.delete(
867 f"/api/repos/{repo_id}/collaborators/alice", headers=auth_headers
868 )
869 db_session.expire_all()
870
871 row = (
872 await db_session.execute(
873 select(MusehubCollaborator).where(
874 MusehubCollaborator.repo_id == repo_id,
875 MusehubCollaborator.identity_handle == "alice",
876 )
877 )
878 ).scalar_one_or_none()
879 assert row is None
880
881 async def test_response_total_matches_actual_count(
882 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
883 ) -> None:
884 repo_id = await _api_repo(client, auth_headers)
885 for i in range(7):
886 await _db_collab(db_session, repo_id, f"u{i}")
887 await db_session.commit()
888
889 r = await client.get(f"/api/repos/{repo_id}/collaborators", headers=auth_headers)
890 body = r.json()
891 assert body["total"] == len(body["collaborators"])
892
893
894 # ===========================================================================
895 # Layer 6 — Security
896 # ===========================================================================
897
898
899 class TestSecurity:
900 async def test_all_endpoints_require_auth(
901 self, client: AsyncClient, db_session: AsyncSession
902 ) -> None:
903 repo = await _db_repo(db_session)
904 await _db_collab(db_session, repo.repo_id, "alice")
905 await db_session.commit()
906
907 endpoints = [
908 ("GET", f"/api/repos/{repo.repo_id}/collaborators"),
909 ("POST", f"/api/repos/{repo.repo_id}/collaborators"),
910 ("PUT", f"/api/repos/{repo.repo_id}/collaborators/alice/permission"),
911 ("DELETE", f"/api/repos/{repo.repo_id}/collaborators/alice"),
912 ]
913 for method, url in endpoints:
914 r = await client.request(method, url, json={"handle": "x", "permission": "read"})
915 assert r.status_code == 401, f"{method} {url} should require auth"
916
917 async def test_read_only_collab_cannot_invite(
918 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
919 ) -> None:
920 repo = await _db_repo(db_session, owner="alice")
921 await _db_collab(db_session, repo.repo_id, _TEST_HANDLE, permission="read")
922 await db_session.commit()
923
924 r = await client.post(
925 f"/api/repos/{repo.repo_id}/collaborators",
926 json={"handle": "bob", "permission": "read"},
927 headers=auth_headers,
928 )
929 assert r.status_code == 403
930
931 async def test_write_collab_cannot_remove(
932 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
933 ) -> None:
934 repo = await _db_repo(db_session, owner="alice")
935 await _db_collab(db_session, repo.repo_id, _TEST_HANDLE, permission="write")
936 await _db_collab(db_session, repo.repo_id, "bob", permission="read")
937 await db_session.commit()
938
939 r = await client.delete(
940 f"/api/repos/{repo.repo_id}/collaborators/bob", headers=auth_headers
941 )
942 assert r.status_code == 403
943
944 async def test_owner_permission_cannot_be_updated(
945 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
946 ) -> None:
947 repo = await _db_repo(db_session, owner="alice")
948 await _db_collab(db_session, repo.repo_id, _TEST_HANDLE, permission="admin")
949 await _db_collab(db_session, repo.repo_id, "alice", permission="owner")
950 await db_session.commit()
951
952 r = await client.put(
953 f"/api/repos/{repo.repo_id}/collaborators/alice/permission",
954 json={"permission": "read"},
955 headers=auth_headers,
956 )
957 assert r.status_code == 403
958
959 async def test_owner_collab_cannot_be_removed(
960 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
961 ) -> None:
962 repo = await _db_repo(db_session, owner="alice")
963 await _db_collab(db_session, repo.repo_id, _TEST_HANDLE, permission="admin")
964 await _db_collab(db_session, repo.repo_id, "alice", permission="owner")
965 await db_session.commit()
966
967 r = await client.delete(
968 f"/api/repos/{repo.repo_id}/collaborators/alice", headers=auth_headers
969 )
970 assert r.status_code == 403
971
972 async def test_check_access_requires_auth(
973 self, client: AsyncClient, db_session: AsyncSession
974 ) -> None:
975 repo = await _db_repo(db_session)
976 await db_session.commit()
977
978 r = await client.get(
979 f"/api/repos/{repo.repo_id}/collaborators/alice/permission"
980 )
981 assert r.status_code == 401
982
983 async def test_non_admin_cannot_update_permissions(
984 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
985 ) -> None:
986 repo = await _db_repo(db_session, owner="alice")
987 await _db_collab(db_session, repo.repo_id, _TEST_HANDLE, permission="write")
988 await _db_collab(db_session, repo.repo_id, "bob", permission="read")
989 await db_session.commit()
990
991 r = await client.put(
992 f"/api/repos/{repo.repo_id}/collaborators/bob/permission",
993 json={"permission": "admin"},
994 headers=auth_headers,
995 )
996 assert r.status_code == 403
997
998
999 # ===========================================================================
1000 # Layer 7 — Performance
1001 # ===========================================================================
1002
1003
1004 class TestPerformance:
1005 async def test_list_20_collaborators_under_100ms(
1006 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
1007 ) -> None:
1008 repo_id = await _api_repo(client, auth_headers)
1009 for i in range(20):
1010 await _db_collab(db_session, repo_id, f"perf{i}")
1011 await db_session.commit()
1012
1013 start = time.perf_counter()
1014 r = await client.get(f"/api/repos/{repo_id}/collaborators", headers=auth_headers)
1015 elapsed = time.perf_counter() - start
1016
1017 assert r.status_code == 200
1018 assert elapsed < 0.1, f"list collaborators took {elapsed:.3f}s"
1019
1020 def test_has_permission_1m_calls_fast(self) -> None:
1021 start = time.perf_counter()
1022 for _ in range(1_000_000):
1023 _has_permission("admin", Permission.write)
1024 elapsed = time.perf_counter() - start
1025 assert elapsed < 1.0, f"1M _has_permission calls took {elapsed:.3f}s"
1026
1027 async def test_invite_10_collabs_under_500ms(
1028 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
1029 ) -> None:
1030 repo_id = await _api_repo(client, auth_headers)
1031 for i in range(10):
1032 await _db_identity(db_session, f"batch{i}")
1033 await db_session.commit()
1034
1035 start = time.perf_counter()
1036 for i in range(10):
1037 r = await client.post(
1038 f"/api/repos/{repo_id}/collaborators",
1039 json={"handle": f"batch{i}", "permission": "read"},
1040 headers=auth_headers,
1041 )
1042 assert r.status_code == 201
1043 elapsed = time.perf_counter() - start
1044 assert elapsed < 1.5, f"10 invite calls took {elapsed:.3f}s"