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