gabriel / musehub public
test_collaborators.py python
899 lines 31.9 KB
Raw
sha256:9590cee1e0ccd6c76528f005b95d634d80f5019f0dcb7c371e149adc31d1fb65 refactor: enforce gRPC framing on all MWP wire traffic Sonnet 4.6 minor ⚠ breaking 156 days ago
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 time
29 import uuid
30 from datetime import datetime, timezone
31
32 import pytest
33 from httpx import AsyncClient
34 from sqlalchemy import select
35 from sqlalchemy.ext.asyncio import AsyncSession
36
37 from musehub.types.json_types import StrDict
38 from musehub.api.routes.musehub.collaborators import (
39 Permission,
40 _PERMISSION_RANK,
41 _has_permission,
42 _orm_to_response,
43 )
44 from musehub.db.musehub_collaborator_models import MusehubCollaborator
45 from musehub.db.musehub_models import MusehubRepo
46
47
48 # ---------------------------------------------------------------------------
49 # Fixtures / helpers
50 # ---------------------------------------------------------------------------
51
52 _TEST_HANDLE = "testuser" # matches auth_headers fixture's token.handle
53
54
55 def _uid() -> str:
56 return str(uuid.uuid4())
57
58
59 async def _db_repo(
60 session: AsyncSession,
61 owner: str = _TEST_HANDLE,
62 *,
63 visibility: str = "private",
64 ) -> MusehubRepo:
65 slug = f"repo-{_uid()[:8]}"
66 repo = MusehubRepo(
67 repo_id=_uid(),
68 name=slug,
69 slug=slug,
70 owner=owner,
71 owner_user_id=owner,
72 visibility=visibility,
73 )
74 session.add(repo)
75 await session.flush()
76 return repo
77
78
79 async def _db_collab(
80 session: AsyncSession,
81 repo_id: str,
82 handle: str,
83 *,
84 permission: str = "write",
85 invited_by: str | None = None,
86 accepted: bool = False,
87 ) -> MusehubCollaborator:
88 c = MusehubCollaborator(
89 id=_uid(),
90 repo_id=repo_id,
91 identity_handle=handle,
92 permission=permission,
93 invited_by_handle=invited_by,
94 accepted_at=datetime.now(timezone.utc) if accepted else None,
95 )
96 session.add(c)
97 await session.flush()
98 return c
99
100
101 async def _api_repo(
102 client: AsyncClient,
103 auth_headers: StrDict,
104 *,
105 visibility: str = "private",
106 ) -> str:
107 r = await client.post(
108 "/api/repos",
109 json={"name": f"collab-{_uid()[:8]}", "owner": _TEST_HANDLE, "visibility": visibility},
110 headers=auth_headers,
111 )
112 assert r.status_code == 201, r.text
113 return r.json()["repoId"]
114
115
116 # ===========================================================================
117 # Layer 1 — Unit
118 # ===========================================================================
119
120
121 class TestUnitPermissionEnum:
122 def test_values(self) -> None:
123 assert Permission.read == "read"
124 assert Permission.write == "write"
125 assert Permission.admin == "admin"
126 assert Permission.owner == "owner"
127
128 def test_four_levels(self) -> None:
129 assert len(list(Permission)) == 4
130
131
132 class TestUnitPermissionRank:
133 def test_read_is_lowest(self) -> None:
134 assert _PERMISSION_RANK["read"] < _PERMISSION_RANK["write"]
135
136 def test_write_lt_admin(self) -> None:
137 assert _PERMISSION_RANK["write"] < _PERMISSION_RANK["admin"]
138
139 def test_admin_lt_owner(self) -> None:
140 assert _PERMISSION_RANK["admin"] < _PERMISSION_RANK["owner"]
141
142 def test_all_levels_covered(self) -> None:
143 for p in Permission:
144 assert p.value in _PERMISSION_RANK
145
146
147 class TestUnitHasPermission:
148 def test_exact_match(self) -> None:
149 assert _has_permission("write", Permission.write) is True
150
151 def test_higher_grants_lower(self) -> None:
152 assert _has_permission("admin", Permission.write) is True
153 assert _has_permission("owner", Permission.read) is True
154
155 def test_lower_denied_higher(self) -> None:
156 assert _has_permission("read", Permission.write) is False
157 assert _has_permission("write", Permission.admin) is False
158
159 def test_unknown_permission_denied(self) -> None:
160 assert _has_permission("", Permission.read) is False
161 assert _has_permission("superuser", Permission.read) is False
162
163 def test_read_satisfies_read(self) -> None:
164 assert _has_permission("read", Permission.read) is True
165
166 def test_owner_satisfies_admin(self) -> None:
167 assert _has_permission("owner", Permission.admin) is True
168
169
170 class TestUnitOrmToResponse:
171 async def test_fields_mapped_correctly(self, db_session: AsyncSession) -> None:
172 repo = await _db_repo(db_session)
173 collab = await _db_collab(
174 db_session, repo.repo_id, "alice",
175 permission="write", invited_by="bob"
176 )
177 resp = _orm_to_response(collab)
178 assert resp.handle == "alice"
179 assert resp.permission == "write"
180 assert resp.invited_by == "bob"
181 assert resp.repo_id == repo.repo_id
182 assert resp.collaborator_id == collab.id
183
184 async def test_invited_by_none_when_null(self, db_session: AsyncSession) -> None:
185 repo = await _db_repo(db_session)
186 collab = await _db_collab(db_session, repo.repo_id, "carol", invited_by=None)
187 resp = _orm_to_response(collab)
188 assert resp.invited_by is None
189
190
191 # ===========================================================================
192 # Layer 2 — Integration (DB-level)
193 # ===========================================================================
194
195
196 class TestIntegrationCollaboratorDB:
197 async def test_insert_and_query(self, db_session: AsyncSession) -> None:
198 repo = await _db_repo(db_session)
199 collab = await _db_collab(db_session, repo.repo_id, "alice", permission="admin")
200 await db_session.flush()
201
202 result = await db_session.execute(
203 select(MusehubCollaborator).where(
204 MusehubCollaborator.repo_id == repo.repo_id
205 )
206 )
207 rows = result.scalars().all()
208 assert len(rows) == 1
209 assert rows[0].identity_handle == "alice"
210 assert rows[0].permission == "admin"
211
212 async def test_unique_constraint_on_repo_handle(
213 self, db_session: AsyncSession
214 ) -> None:
215 from sqlalchemy.exc import IntegrityError
216
217 repo = await _db_repo(db_session)
218 await _db_collab(db_session, repo.repo_id, "alice")
219 await db_session.flush()
220
221 dup = MusehubCollaborator(
222 id=_uid(),
223 repo_id=repo.repo_id,
224 identity_handle="alice",
225 permission="read",
226 )
227 db_session.add(dup)
228 with pytest.raises(IntegrityError):
229 await db_session.flush()
230
231 async def test_delete_collaborator_directly(self, db_session: AsyncSession) -> None:
232 # Verify that a collaborator can be deleted explicitly and is gone afterwards.
233 repo = await _db_repo(db_session)
234 collab = await _db_collab(db_session, repo.repo_id, "alice")
235 await db_session.commit()
236
237 await db_session.delete(collab)
238 await db_session.commit()
239
240 result = await db_session.execute(
241 select(MusehubCollaborator).where(
242 MusehubCollaborator.repo_id == repo.repo_id
243 )
244 )
245 assert result.scalars().first() is None
246
247 async def test_accepted_at_null_by_default(self, db_session: AsyncSession) -> None:
248 repo = await _db_repo(db_session)
249 collab = await _db_collab(db_session, repo.repo_id, "dave")
250 assert collab.accepted_at is None
251
252 async def test_permission_default_write(self, db_session: AsyncSession) -> None:
253 repo = await _db_repo(db_session)
254 collab = MusehubCollaborator(
255 id=_uid(),
256 repo_id=repo.repo_id,
257 identity_handle="eve",
258 )
259 db_session.add(collab)
260 await db_session.flush()
261 assert collab.permission == "write"
262
263
264 # ===========================================================================
265 # Layer 3 — E2E
266 # ===========================================================================
267
268
269 class TestE2EListCollaborators:
270 async def test_list_returns_200(
271 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
272 ) -> None:
273 repo_id = await _api_repo(client, auth_headers)
274 await _db_collab(db_session, repo_id, "alice")
275 await db_session.commit()
276
277 r = await client.get(f"/api/repos/{repo_id}/collaborators", headers=auth_headers)
278 assert r.status_code == 200
279 body = r.json()
280 assert "collaborators" in body
281 assert "total" in body
282 assert body["total"] == 1
283
284 async def test_list_requires_auth(
285 self, client: AsyncClient, db_session: AsyncSession
286 ) -> None:
287 repo = await _db_repo(db_session)
288 await db_session.commit()
289
290 r = await client.get(f"/api/repos/{repo.repo_id}/collaborators")
291 assert r.status_code == 401
292
293 async def test_list_unknown_repo_404(
294 self, client: AsyncClient, auth_headers: StrDict
295 ) -> None:
296 r = await client.get("/api/repos/no-such-repo/collaborators", headers=auth_headers)
297 assert r.status_code == 404
298
299 async def test_list_empty_repo(
300 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
301 ) -> None:
302 repo_id = await _api_repo(client, auth_headers)
303 await db_session.commit()
304
305 r = await client.get(f"/api/repos/{repo_id}/collaborators", headers=auth_headers)
306 assert r.status_code == 200
307 assert r.json()["total"] == 0
308
309
310 class TestE2EInviteCollaborator:
311 async def test_owner_can_invite_201(
312 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
313 ) -> None:
314 repo_id = await _api_repo(client, auth_headers)
315 await db_session.commit()
316
317 r = await client.post(
318 f"/api/repos/{repo_id}/collaborators",
319 json={"handle": "alice", "permission": "write"},
320 headers=auth_headers,
321 )
322 assert r.status_code == 201
323 body = r.json()
324 assert body["handle"] == "alice"
325 assert body["permission"] == "write"
326 assert body["invitedBy"] == _TEST_HANDLE
327
328 async def test_invite_requires_auth(
329 self, client: AsyncClient, db_session: AsyncSession
330 ) -> None:
331 repo = await _db_repo(db_session)
332 await db_session.commit()
333
334 r = await client.post(
335 f"/api/repos/{repo.repo_id}/collaborators",
336 json={"handle": "bob", "permission": "read"},
337 )
338 assert r.status_code == 401
339
340 async def test_non_admin_gets_403(
341 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
342 ) -> None:
343 """testuser is not owner (alice owns repo) and has only 'write' — gets 403."""
344 repo = await _db_repo(db_session, owner="alice")
345 await _db_collab(db_session, repo.repo_id, _TEST_HANDLE, permission="write")
346 await db_session.commit()
347
348 r = await client.post(
349 f"/api/repos/{repo.repo_id}/collaborators",
350 json={"handle": "bob", "permission": "read"},
351 headers=auth_headers,
352 )
353 assert r.status_code == 403
354
355 async def test_admin_collab_can_invite(
356 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
357 ) -> None:
358 """testuser has admin permission → can invite."""
359 repo = await _db_repo(db_session, owner="alice")
360 await _db_collab(db_session, repo.repo_id, _TEST_HANDLE, permission="admin")
361 await db_session.commit()
362
363 r = await client.post(
364 f"/api/repos/{repo.repo_id}/collaborators",
365 json={"handle": "bob", "permission": "read"},
366 headers=auth_headers,
367 )
368 assert r.status_code == 201
369
370 async def test_duplicate_invite_409(
371 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
372 ) -> None:
373 repo_id = await _api_repo(client, auth_headers)
374 await db_session.commit()
375
376 body = {"handle": "alice", "permission": "write"}
377 r1 = await client.post(
378 f"/api/repos/{repo_id}/collaborators", json=body, headers=auth_headers
379 )
380 assert r1.status_code == 201
381
382 r2 = await client.post(
383 f"/api/repos/{repo_id}/collaborators", json=body, headers=auth_headers
384 )
385 assert r2.status_code == 409
386 assert "already a collaborator" in r2.json()["detail"]
387
388 async def test_invite_unknown_repo_404(
389 self, client: AsyncClient, auth_headers: StrDict
390 ) -> None:
391 r = await client.post(
392 "/api/repos/no-such-repo/collaborators",
393 json={"handle": "alice", "permission": "write"},
394 headers=auth_headers,
395 )
396 assert r.status_code == 404
397
398 async def test_default_permission_write(
399 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
400 ) -> None:
401 repo_id = await _api_repo(client, auth_headers)
402 await db_session.commit()
403
404 r = await client.post(
405 f"/api/repos/{repo_id}/collaborators",
406 json={"handle": "alice"}, # no permission field → defaults to write
407 headers=auth_headers,
408 )
409 assert r.status_code == 201
410 assert r.json()["permission"] == "write"
411
412
413 class TestE2EUpdatePermission:
414 async def test_owner_can_update_200(
415 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
416 ) -> None:
417 repo_id = await _api_repo(client, auth_headers)
418 await _db_collab(db_session, repo_id, "alice", permission="read")
419 await db_session.commit()
420
421 r = await client.put(
422 f"/api/repos/{repo_id}/collaborators/alice/permission",
423 json={"permission": "admin"},
424 headers=auth_headers,
425 )
426 assert r.status_code == 200
427 assert r.json()["permission"] == "admin"
428
429 async def test_update_requires_auth(
430 self, client: AsyncClient, db_session: AsyncSession
431 ) -> None:
432 repo = await _db_repo(db_session)
433 await _db_collab(db_session, repo.repo_id, "alice")
434 await db_session.commit()
435
436 r = await client.put(
437 f"/api/repos/{repo.repo_id}/collaborators/alice/permission",
438 json={"permission": "admin"},
439 )
440 assert r.status_code == 401
441
442 async def test_non_admin_gets_403(
443 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
444 ) -> None:
445 repo = await _db_repo(db_session, owner="alice")
446 await _db_collab(db_session, repo.repo_id, _TEST_HANDLE, permission="write")
447 await _db_collab(db_session, repo.repo_id, "bob", permission="read")
448 await db_session.commit()
449
450 r = await client.put(
451 f"/api/repos/{repo.repo_id}/collaborators/bob/permission",
452 json={"permission": "admin"},
453 headers=auth_headers,
454 )
455 assert r.status_code == 403
456
457 async def test_update_owner_permission_403(
458 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
459 ) -> None:
460 """Cannot change owner's permission via this endpoint."""
461 repo = await _db_repo(db_session, owner="alice")
462 # testuser has admin permission
463 await _db_collab(db_session, repo.repo_id, _TEST_HANDLE, permission="admin")
464 # alice has 'owner' permission in collaborators table
465 await _db_collab(db_session, repo.repo_id, "alice", permission="owner")
466 await db_session.commit()
467
468 r = await client.put(
469 f"/api/repos/{repo.repo_id}/collaborators/alice/permission",
470 json={"permission": "write"},
471 headers=auth_headers,
472 )
473 assert r.status_code == 403
474 assert "Owner permission" in r.json()["detail"]
475
476 async def test_update_nonexistent_collab_404(
477 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
478 ) -> None:
479 repo_id = await _api_repo(client, auth_headers)
480 await db_session.commit()
481
482 r = await client.put(
483 f"/api/repos/{repo_id}/collaborators/nobody/permission",
484 json={"permission": "read"},
485 headers=auth_headers,
486 )
487 assert r.status_code == 404
488
489
490 class TestE2ERemoveCollaborator:
491 async def test_owner_can_remove_204(
492 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
493 ) -> None:
494 repo_id = await _api_repo(client, auth_headers)
495 await _db_collab(db_session, repo_id, "alice")
496 await db_session.commit()
497
498 r = await client.delete(
499 f"/api/repos/{repo_id}/collaborators/alice", headers=auth_headers
500 )
501 assert r.status_code == 204
502
503 async def test_remove_requires_auth(
504 self, client: AsyncClient, db_session: AsyncSession
505 ) -> None:
506 repo = await _db_repo(db_session)
507 await _db_collab(db_session, repo.repo_id, "alice")
508 await db_session.commit()
509
510 r = await client.delete(
511 f"/api/repos/{repo.repo_id}/collaborators/alice"
512 )
513 assert r.status_code == 401
514
515 async def test_non_admin_gets_403(
516 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
517 ) -> None:
518 repo = await _db_repo(db_session, owner="alice")
519 await _db_collab(db_session, repo.repo_id, _TEST_HANDLE, permission="write")
520 await _db_collab(db_session, repo.repo_id, "bob", permission="read")
521 await db_session.commit()
522
523 r = await client.delete(
524 f"/api/repos/{repo.repo_id}/collaborators/bob", headers=auth_headers
525 )
526 assert r.status_code == 403
527
528 async def test_remove_owner_403(
529 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
530 ) -> None:
531 """Owner-permission collaborator cannot be removed."""
532 repo = await _db_repo(db_session, owner="alice")
533 await _db_collab(db_session, repo.repo_id, _TEST_HANDLE, permission="admin")
534 await _db_collab(db_session, repo.repo_id, "alice", permission="owner")
535 await db_session.commit()
536
537 r = await client.delete(
538 f"/api/repos/{repo.repo_id}/collaborators/alice", headers=auth_headers
539 )
540 assert r.status_code == 403
541 assert "Owner cannot be removed" in r.json()["detail"]
542
543 async def test_remove_nonexistent_404(
544 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
545 ) -> None:
546 repo_id = await _api_repo(client, auth_headers)
547 await db_session.commit()
548
549 r = await client.delete(
550 f"/api/repos/{repo_id}/collaborators/nobody", headers=auth_headers
551 )
552 assert r.status_code == 404
553
554
555 class TestE2ECheckAccess:
556 async def test_owner_access_is_owner_permission(
557 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
558 ) -> None:
559 repo_id = await _api_repo(client, auth_headers)
560 await db_session.commit()
561
562 # testuser is the owner; check their own permission
563 r = await client.get(
564 f"/api/repos/{repo_id}/collaborators/{_TEST_HANDLE}/permission",
565 headers=auth_headers,
566 )
567 assert r.status_code == 200
568 body = r.json()
569 assert body["permission"] == "owner"
570
571 async def test_collab_access_returns_permission(
572 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
573 ) -> None:
574 repo_id = await _api_repo(client, auth_headers)
575 await _db_collab(db_session, repo_id, "alice", permission="admin")
576 await db_session.commit()
577
578 r = await client.get(
579 f"/api/repos/{repo_id}/collaborators/alice/permission",
580 headers=auth_headers,
581 )
582 assert r.status_code == 200
583 assert r.json()["permission"] == "admin"
584
585 async def test_non_collab_404(
586 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
587 ) -> None:
588 repo_id = await _api_repo(client, auth_headers)
589 await db_session.commit()
590
591 r = await client.get(
592 f"/api/repos/{repo_id}/collaborators/stranger/permission",
593 headers=auth_headers,
594 )
595 assert r.status_code == 404
596
597 async def test_check_requires_auth(
598 self, client: AsyncClient, db_session: AsyncSession
599 ) -> None:
600 repo = await _db_repo(db_session)
601 await db_session.commit()
602
603 r = await client.get(
604 f"/api/repos/{repo.repo_id}/collaborators/{_TEST_HANDLE}/permission"
605 )
606 assert r.status_code == 401
607
608
609 # ===========================================================================
610 # Layer 4 — Stress
611 # ===========================================================================
612
613
614 class TestStress:
615 async def test_list_50_collaborators(
616 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
617 ) -> None:
618 repo_id = await _api_repo(client, auth_headers)
619 for i in range(50):
620 await _db_collab(db_session, repo_id, f"user{i}", permission="read")
621 await db_session.commit()
622
623 r = await client.get(f"/api/repos/{repo_id}/collaborators", headers=auth_headers)
624 assert r.status_code == 200
625 assert r.json()["total"] == 50
626
627 async def test_5_concurrent_list_calls(
628 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
629 ) -> None:
630 repo_id = await _api_repo(client, auth_headers)
631 for i in range(10):
632 await _db_collab(db_session, repo_id, f"stress{i}")
633 await db_session.commit()
634
635 responses = await asyncio.gather(
636 *[
637 client.get(
638 f"/api/repos/{repo_id}/collaborators", headers=auth_headers
639 )
640 for _ in range(5)
641 ]
642 )
643 assert all(r.status_code == 200 for r in responses)
644 assert all(r.json()["total"] == 10 for r in responses)
645
646
647 # ===========================================================================
648 # Layer 5 — Data Integrity
649 # ===========================================================================
650
651
652 class TestDataIntegrity:
653 async def test_invited_by_set_correctly(
654 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
655 ) -> None:
656 repo_id = await _api_repo(client, auth_headers)
657 await db_session.commit()
658
659 r = await client.post(
660 f"/api/repos/{repo_id}/collaborators",
661 json={"handle": "alice", "permission": "read"},
662 headers=auth_headers,
663 )
664 assert r.status_code == 201
665 assert r.json()["invitedBy"] == _TEST_HANDLE
666
667 async def test_permission_persisted_correctly(
668 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
669 ) -> None:
670 repo_id = await _api_repo(client, auth_headers)
671 await db_session.commit()
672
673 await client.post(
674 f"/api/repos/{repo_id}/collaborators",
675 json={"handle": "alice", "permission": "admin"},
676 headers=auth_headers,
677 )
678 db_session.expire_all()
679
680 row = (
681 await db_session.execute(
682 select(MusehubCollaborator).where(
683 MusehubCollaborator.repo_id == repo_id,
684 MusehubCollaborator.identity_handle == "alice",
685 )
686 )
687 ).scalar_one_or_none()
688 assert row is not None
689 assert row.permission == "admin"
690
691 async def test_update_persisted_in_db(
692 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
693 ) -> None:
694 repo_id = await _api_repo(client, auth_headers)
695 await _db_collab(db_session, repo_id, "alice", permission="read")
696 await db_session.commit()
697
698 await client.put(
699 f"/api/repos/{repo_id}/collaborators/alice/permission",
700 json={"permission": "admin"},
701 headers=auth_headers,
702 )
703 db_session.expire_all()
704
705 row = (
706 await db_session.execute(
707 select(MusehubCollaborator).where(
708 MusehubCollaborator.repo_id == repo_id,
709 MusehubCollaborator.identity_handle == "alice",
710 )
711 )
712 ).scalar_one_or_none()
713 assert row is not None
714 assert row.permission == "admin"
715
716 async def test_remove_deletes_db_row(
717 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
718 ) -> None:
719 repo_id = await _api_repo(client, auth_headers)
720 await _db_collab(db_session, repo_id, "alice")
721 await db_session.commit()
722
723 await client.delete(
724 f"/api/repos/{repo_id}/collaborators/alice", headers=auth_headers
725 )
726 db_session.expire_all()
727
728 row = (
729 await db_session.execute(
730 select(MusehubCollaborator).where(
731 MusehubCollaborator.repo_id == repo_id,
732 MusehubCollaborator.identity_handle == "alice",
733 )
734 )
735 ).scalar_one_or_none()
736 assert row is None
737
738 async def test_response_total_matches_actual_count(
739 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
740 ) -> None:
741 repo_id = await _api_repo(client, auth_headers)
742 for i in range(7):
743 await _db_collab(db_session, repo_id, f"u{i}")
744 await db_session.commit()
745
746 r = await client.get(f"/api/repos/{repo_id}/collaborators", headers=auth_headers)
747 body = r.json()
748 assert body["total"] == len(body["collaborators"])
749
750
751 # ===========================================================================
752 # Layer 6 — Security
753 # ===========================================================================
754
755
756 class TestSecurity:
757 async def test_all_endpoints_require_auth(
758 self, client: AsyncClient, db_session: AsyncSession
759 ) -> None:
760 repo = await _db_repo(db_session)
761 await _db_collab(db_session, repo.repo_id, "alice")
762 await db_session.commit()
763
764 endpoints = [
765 ("GET", f"/api/repos/{repo.repo_id}/collaborators"),
766 ("POST", f"/api/repos/{repo.repo_id}/collaborators"),
767 ("PUT", f"/api/repos/{repo.repo_id}/collaborators/alice/permission"),
768 ("DELETE", f"/api/repos/{repo.repo_id}/collaborators/alice"),
769 ]
770 for method, url in endpoints:
771 r = await client.request(method, url, json={"handle": "x", "permission": "read"})
772 assert r.status_code == 401, f"{method} {url} should require auth"
773
774 async def test_read_only_collab_cannot_invite(
775 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
776 ) -> None:
777 repo = await _db_repo(db_session, owner="alice")
778 await _db_collab(db_session, repo.repo_id, _TEST_HANDLE, permission="read")
779 await db_session.commit()
780
781 r = await client.post(
782 f"/api/repos/{repo.repo_id}/collaborators",
783 json={"handle": "bob", "permission": "read"},
784 headers=auth_headers,
785 )
786 assert r.status_code == 403
787
788 async def test_write_collab_cannot_remove(
789 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
790 ) -> None:
791 repo = await _db_repo(db_session, owner="alice")
792 await _db_collab(db_session, repo.repo_id, _TEST_HANDLE, permission="write")
793 await _db_collab(db_session, repo.repo_id, "bob", permission="read")
794 await db_session.commit()
795
796 r = await client.delete(
797 f"/api/repos/{repo.repo_id}/collaborators/bob", headers=auth_headers
798 )
799 assert r.status_code == 403
800
801 async def test_owner_permission_cannot_be_updated(
802 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
803 ) -> None:
804 repo = await _db_repo(db_session, owner="alice")
805 await _db_collab(db_session, repo.repo_id, _TEST_HANDLE, permission="admin")
806 await _db_collab(db_session, repo.repo_id, "alice", permission="owner")
807 await db_session.commit()
808
809 r = await client.put(
810 f"/api/repos/{repo.repo_id}/collaborators/alice/permission",
811 json={"permission": "read"},
812 headers=auth_headers,
813 )
814 assert r.status_code == 403
815
816 async def test_owner_collab_cannot_be_removed(
817 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
818 ) -> None:
819 repo = await _db_repo(db_session, owner="alice")
820 await _db_collab(db_session, repo.repo_id, _TEST_HANDLE, permission="admin")
821 await _db_collab(db_session, repo.repo_id, "alice", permission="owner")
822 await db_session.commit()
823
824 r = await client.delete(
825 f"/api/repos/{repo.repo_id}/collaborators/alice", headers=auth_headers
826 )
827 assert r.status_code == 403
828
829 async def test_check_access_requires_auth(
830 self, client: AsyncClient, db_session: AsyncSession
831 ) -> None:
832 repo = await _db_repo(db_session)
833 await db_session.commit()
834
835 r = await client.get(
836 f"/api/repos/{repo.repo_id}/collaborators/alice/permission"
837 )
838 assert r.status_code == 401
839
840 async def test_non_admin_cannot_update_permissions(
841 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
842 ) -> None:
843 repo = await _db_repo(db_session, owner="alice")
844 await _db_collab(db_session, repo.repo_id, _TEST_HANDLE, permission="write")
845 await _db_collab(db_session, repo.repo_id, "bob", permission="read")
846 await db_session.commit()
847
848 r = await client.put(
849 f"/api/repos/{repo.repo_id}/collaborators/bob/permission",
850 json={"permission": "admin"},
851 headers=auth_headers,
852 )
853 assert r.status_code == 403
854
855
856 # ===========================================================================
857 # Layer 7 — Performance
858 # ===========================================================================
859
860
861 class TestPerformance:
862 async def test_list_20_collaborators_under_100ms(
863 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
864 ) -> None:
865 repo_id = await _api_repo(client, auth_headers)
866 for i in range(20):
867 await _db_collab(db_session, repo_id, f"perf{i}")
868 await db_session.commit()
869
870 start = time.perf_counter()
871 r = await client.get(f"/api/repos/{repo_id}/collaborators", headers=auth_headers)
872 elapsed = time.perf_counter() - start
873
874 assert r.status_code == 200
875 assert elapsed < 0.1, f"list collaborators took {elapsed:.3f}s"
876
877 def test_has_permission_1m_calls_fast(self) -> None:
878 start = time.perf_counter()
879 for _ in range(1_000_000):
880 _has_permission("admin", Permission.write)
881 elapsed = time.perf_counter() - start
882 assert elapsed < 1.0, f"1M _has_permission calls took {elapsed:.3f}s"
883
884 async def test_invite_10_collabs_under_500ms(
885 self, client: AsyncClient, auth_headers: StrDict, db_session: AsyncSession
886 ) -> None:
887 repo_id = await _api_repo(client, auth_headers)
888 await db_session.commit()
889
890 start = time.perf_counter()
891 for i in range(10):
892 r = await client.post(
893 f"/api/repos/{repo_id}/collaborators",
894 json={"handle": f"batch{i}", "permission": "read"},
895 headers=auth_headers,
896 )
897 assert r.status_code == 201
898 elapsed = time.perf_counter() - start
899 assert elapsed < 0.5, f"10 invite calls took {elapsed:.3f}s"
File History 1 commit
sha256:9590cee1e0ccd6c76528f005b95d634d80f5019f0dcb7c371e149adc31d1fb65 refactor: enforce gRPC framing on all MWP wire traffic Sonnet 4.6 minor ⚠ 156 days ago