gabriel / musehub public
test_musehub_forks.py python
1,019 lines 30.6 KB
Raw
sha256:9590cee1e0ccd6c76528f005b95d634d80f5019f0dcb7c371e149adc31d1fb65 refactor: enforce gRPC framing on all MWP wire traffic Sonnet 4.6 minor ⚠ breaking 155 days ago
1 """Tests for the fork-a-repo feature.
2
3 Covers:
4 POST /api/repos/{repo_id}/fork
5 - Happy path: fork a public repo
6 - 404 when source repo does not exist
7 - 403 when source repo is private
8 - 403 when caller tries to fork their own repo
9 - 409 when caller has already forked the same repo
10 - 401 when unauthenticated
11 - Optional name / description / visibility fields
12
13 GET /api/repos/{repo_id}/forks
14 - Returns empty list when no forks exist
15 - Returns all direct forks with source attribution
16 - 404 when source repo does not exist
17 - Public endpoint (no auth required)
18
19 GET /api/repos/{repo_id}/fork-network
20 - Returns root node with children
21 - Total_forks count is correct
22 - Public endpoint (no auth required)
23
24 GET /api/users/{username}/forks
25 - Returns empty list when user has no forks
26 - Returns forks with source attribution after forking
27 - 404 when username does not exist
28
29 Service layer
30 - fork_repo raises ValueError for business rule violations
31 - get_user_forks returns real data after forks are created
32 - list_repo_forks_flat returns real data after forks are created
33
34 All tests use the shared ``client``, ``auth_headers``, ``test_user``, and
35 ``db_session`` fixtures from conftest.py.
36 """
37 from __future__ import annotations
38
39 import pytest
40 from httpx import AsyncClient
41 from sqlalchemy.ext.asyncio import AsyncSession
42
43 from musehub.db.musehub_models import MusehubIdentity, MusehubRepo
44 from musehub.types.json_types import StrDict
45
46
47 # ---------------------------------------------------------------------------
48 # Helpers
49 # ---------------------------------------------------------------------------
50
51 _TEST_HANDLE = "testuser" # matches conftest._TEST_HANDLE
52
53
54 async def _create_public_repo(
55 client: AsyncClient,
56 auth_headers: StrDict,
57 name: str = "upstream-beats",
58 ) -> str:
59 """Create a public repo via the API and return its repo_id."""
60 resp = await client.post(
61 "/api/repos",
62 json={"name": name, "owner": _TEST_HANDLE, "visibility": "public", "initialize": False},
63 headers=auth_headers,
64 )
65 assert resp.status_code == 201, resp.text
66 return str(resp.json()["repoId"])
67
68
69 async def _create_private_repo(
70 client: AsyncClient,
71 auth_headers: StrDict,
72 name: str = "secret-project",
73 ) -> str:
74 """Create a private repo via the API and return its repo_id."""
75 resp = await client.post(
76 "/api/repos",
77 json={"name": name, "owner": _TEST_HANDLE, "visibility": "private", "initialize": False},
78 headers=auth_headers,
79 )
80 assert resp.status_code == 201, resp.text
81 return str(resp.json()["repoId"])
82
83
84 async def _seed_identity(db: AsyncSession, handle: str) -> MusehubIdentity:
85 """Seed a secondary identity in the DB (simulating a different user)."""
86 identity = MusehubIdentity(
87 handle=handle,
88 display_name=handle.title(),
89 identity_type="human",
90 )
91 db.add(identity)
92 await db.commit()
93 await db.refresh(identity)
94 return identity
95
96
97 # ---------------------------------------------------------------------------
98 # POST /api/repos/{repo_id}/fork — happy path
99 # ---------------------------------------------------------------------------
100
101
102 async def test_fork_public_repo_returns_201(
103 client: AsyncClient,
104 auth_headers: StrDict,
105 db_session: AsyncSession,
106 ) -> None:
107 """Forking a public repo returns 201 with fork metadata."""
108 # Seed a public source repo owned by a different identity so the caller
109 # can fork it.
110 owner_identity = await _seed_identity(db_session, "alice")
111 source = MusehubRepo(
112 name="shared-beats",
113 owner="alice",
114 slug="shared-beats",
115 visibility="public",
116 owner_user_id=owner_identity.identity_id,
117 description="Alice's public beats",
118 )
119 db_session.add(source)
120 await db_session.commit()
121 await db_session.refresh(source)
122 source_id = str(source.repo_id)
123
124 resp = await client.post(
125 f"/api/repos/{source_id}/fork",
126 json={},
127 headers=auth_headers,
128 )
129
130 assert resp.status_code == 201, resp.text
131 body = resp.json()
132
133 assert "forkId" in body
134 assert body["sourceOwner"] == "alice"
135 assert body["sourceSlug"] == "shared-beats"
136 assert "forkRepo" in body
137 fork_repo = body["forkRepo"]
138 assert fork_repo["owner"] == _TEST_HANDLE
139 assert fork_repo["visibility"] == "public"
140 assert "forkedAt" in body
141
142
143 async def test_fork_sets_description_with_attribution(
144 client: AsyncClient,
145 auth_headers: StrDict,
146 db_session: AsyncSession,
147 ) -> None:
148 """Fork description defaults to 'Fork of {owner}/{slug}: {source description}'."""
149 await _seed_identity(db_session, "bob")
150 source = MusehubRepo(
151 name="groove-box",
152 owner="bob",
153 slug="groove-box",
154 visibility="public",
155 owner_user_id="bob",
156 description="Bob's groove box",
157 )
158 db_session.add(source)
159 await db_session.commit()
160 await db_session.refresh(source)
161 source_id = str(source.repo_id)
162
163 resp = await client.post(
164 f"/api/repos/{source_id}/fork",
165 json={},
166 headers=auth_headers,
167 )
168
169 assert resp.status_code == 201, resp.text
170 description = resp.json()["forkRepo"]["description"]
171 assert "bob" in description
172 assert "groove-box" in description
173 assert "Bob's groove box" in description
174
175
176 async def test_fork_with_custom_name(
177 client: AsyncClient,
178 auth_headers: StrDict,
179 db_session: AsyncSession,
180 ) -> None:
181 """Fork accepts an optional custom name for the new repo."""
182 await _seed_identity(db_session, "carol")
183 source = MusehubRepo(
184 name="jazz-trio",
185 owner="carol",
186 slug="jazz-trio",
187 visibility="public",
188 owner_user_id="carol",
189 description="Carol's jazz trio",
190 )
191 db_session.add(source)
192 await db_session.commit()
193 await db_session.refresh(source)
194 source_id = str(source.repo_id)
195
196 resp = await client.post(
197 f"/api/repos/{source_id}/fork",
198 json={"name": "my-jazz-experiment"},
199 headers=auth_headers,
200 )
201
202 assert resp.status_code == 201, resp.text
203 fork_repo = resp.json()["forkRepo"]
204 assert "jazz-experiment" in fork_repo["slug"]
205
206
207 async def test_fork_with_private_visibility(
208 client: AsyncClient,
209 auth_headers: StrDict,
210 db_session: AsyncSession,
211 ) -> None:
212 """Fork accepts visibility='private' to create a private fork."""
213 await _seed_identity(db_session, "dave")
214 source = MusehubRepo(
215 name="open-source-beats",
216 owner="dave",
217 slug="open-source-beats",
218 visibility="public",
219 owner_user_id="dave",
220 description="Dave's open source beats",
221 )
222 db_session.add(source)
223 await db_session.commit()
224 await db_session.refresh(source)
225 source_id = str(source.repo_id)
226
227 resp = await client.post(
228 f"/api/repos/{source_id}/fork",
229 json={"visibility": "private"},
230 headers=auth_headers,
231 )
232
233 assert resp.status_code == 201, resp.text
234 assert resp.json()["forkRepo"]["visibility"] == "private"
235
236
237 async def test_fork_with_custom_description(
238 client: AsyncClient,
239 auth_headers: StrDict,
240 db_session: AsyncSession,
241 ) -> None:
242 """Fork accepts a custom description that overrides the default attribution."""
243 await _seed_identity(db_session, "eve")
244 source = MusehubRepo(
245 name="synth-lab",
246 owner="eve",
247 slug="synth-lab",
248 visibility="public",
249 owner_user_id="eve",
250 description="",
251 )
252 db_session.add(source)
253 await db_session.commit()
254 await db_session.refresh(source)
255 source_id = str(source.repo_id)
256
257 resp = await client.post(
258 f"/api/repos/{source_id}/fork",
259 json={"description": "My custom synth fork"},
260 headers=auth_headers,
261 )
262
263 assert resp.status_code == 201, resp.text
264 assert resp.json()["forkRepo"]["description"] == "My custom synth fork"
265
266
267 # ---------------------------------------------------------------------------
268 # POST /api/repos/{repo_id}/fork — error cases
269 # ---------------------------------------------------------------------------
270
271
272 async def test_fork_nonexistent_repo_returns_404(
273 client: AsyncClient,
274 auth_headers: StrDict,
275 ) -> None:
276 """Forking a repo that doesn't exist returns 404."""
277 resp = await client.post(
278 "/api/repos/00000000-0000-0000-0000-000000000000/fork",
279 json={},
280 headers=auth_headers,
281 )
282 assert resp.status_code == 404
283
284
285 async def test_fork_private_repo_returns_403(
286 client: AsyncClient,
287 auth_headers: StrDict,
288 db_session: AsyncSession,
289 ) -> None:
290 """Forking a private repo returns 403."""
291 await _seed_identity(db_session, "frank")
292 source = MusehubRepo(
293 name="private-session",
294 owner="frank",
295 slug="private-session",
296 visibility="private",
297 owner_user_id="frank",
298 description="Frank's private work",
299 )
300 db_session.add(source)
301 await db_session.commit()
302 await db_session.refresh(source)
303 source_id = str(source.repo_id)
304
305 resp = await client.post(
306 f"/api/repos/{source_id}/fork",
307 json={},
308 headers=auth_headers,
309 )
310 assert resp.status_code == 403
311 assert "public" in resp.json()["detail"].lower()
312
313
314 async def test_fork_own_repo_returns_403(
315 client: AsyncClient,
316 auth_headers: StrDict,
317 db_session: AsyncSession,
318 ) -> None:
319 """Caller cannot fork a repository they already own — returns 403."""
320 source_id = await _create_public_repo(client, auth_headers, name="my-own-beats")
321
322 resp = await client.post(
323 f"/api/repos/{source_id}/fork",
324 json={},
325 headers=auth_headers,
326 )
327 assert resp.status_code == 403
328 assert "own" in resp.json()["detail"].lower()
329
330
331 async def test_fork_same_repo_twice_returns_409(
332 client: AsyncClient,
333 auth_headers: StrDict,
334 db_session: AsyncSession,
335 ) -> None:
336 """Forking the same repo twice returns 409 Conflict."""
337 await _seed_identity(db_session, "grace")
338 source = MusehubRepo(
339 name="shared-vibes",
340 owner="grace",
341 slug="shared-vibes",
342 visibility="public",
343 owner_user_id="grace",
344 description="Grace's vibes",
345 )
346 db_session.add(source)
347 await db_session.commit()
348 await db_session.refresh(source)
349 source_id = str(source.repo_id)
350
351 # First fork succeeds
352 resp1 = await client.post(
353 f"/api/repos/{source_id}/fork",
354 json={},
355 headers=auth_headers,
356 )
357 assert resp1.status_code == 201, resp1.text
358
359 # Second fork of the same repo → 409
360 resp2 = await client.post(
361 f"/api/repos/{source_id}/fork",
362 json={"name": "another-fork"},
363 headers=auth_headers,
364 )
365 assert resp2.status_code == 409
366
367
368 async def test_fork_requires_auth(client: AsyncClient, db_session: AsyncSession) -> None:
369 """Unauthenticated fork request returns 401."""
370 await _seed_identity(db_session, "henry")
371 source = MusehubRepo(
372 name="open-beats",
373 owner="henry",
374 slug="open-beats",
375 visibility="public",
376 owner_user_id="henry",
377 description="",
378 )
379 db_session.add(source)
380 await db_session.commit()
381 await db_session.refresh(source)
382 source_id = str(source.repo_id)
383
384 resp = await client.post(f"/api/repos/{source_id}/fork", json={})
385 assert resp.status_code == 401
386
387
388 # ---------------------------------------------------------------------------
389 # GET /api/repos/{repo_id}/forks
390 # ---------------------------------------------------------------------------
391
392
393 async def test_list_forks_empty_when_no_forks(
394 client: AsyncClient,
395 auth_headers: StrDict,
396 db_session: AsyncSession,
397 ) -> None:
398 """A repo with no forks returns an empty list."""
399 await _seed_identity(db_session, "iris")
400 source = MusehubRepo(
401 name="unfork-able",
402 owner="iris",
403 slug="unfork-able",
404 visibility="public",
405 owner_user_id="iris",
406 description="",
407 )
408 db_session.add(source)
409 await db_session.commit()
410 await db_session.refresh(source)
411 source_id = str(source.repo_id)
412
413 resp = await client.get(f"/api/repos/{source_id}/forks")
414 assert resp.status_code == 200
415 body = resp.json()
416 assert body["forks"] == []
417 assert body["total"] == 0
418
419
420 async def test_list_forks_shows_fork_after_creation(
421 client: AsyncClient,
422 auth_headers: StrDict,
423 db_session: AsyncSession,
424 ) -> None:
425 """A fork appears in the list after being created."""
426 await _seed_identity(db_session, "jack")
427 source = MusehubRepo(
428 name="popular-track",
429 owner="jack",
430 slug="popular-track",
431 visibility="public",
432 owner_user_id="jack",
433 description="Jack's popular track",
434 )
435 db_session.add(source)
436 await db_session.commit()
437 await db_session.refresh(source)
438 source_id = str(source.repo_id)
439
440 # Fork it
441 fork_resp = await client.post(
442 f"/api/repos/{source_id}/fork",
443 json={},
444 headers=auth_headers,
445 )
446 assert fork_resp.status_code == 201, fork_resp.text
447
448 # List forks
449 resp = await client.get(f"/api/repos/{source_id}/forks")
450 assert resp.status_code == 200
451 body = resp.json()
452
453 assert body["total"] == 1
454 fork = body["forks"][0]
455 assert fork["sourceOwner"] == "jack"
456 assert fork["sourceSlug"] == "popular-track"
457 assert fork["forkRepo"]["owner"] == _TEST_HANDLE
458
459
460 async def test_list_forks_no_auth_required(
461 client: AsyncClient,
462 db_session: AsyncSession,
463 ) -> None:
464 """List forks endpoint is publicly accessible without authentication."""
465 await _seed_identity(db_session, "kate")
466 source = MusehubRepo(
467 name="public-beats",
468 owner="kate",
469 slug="public-beats",
470 visibility="public",
471 owner_user_id="kate",
472 description="",
473 )
474 db_session.add(source)
475 await db_session.commit()
476 await db_session.refresh(source)
477 source_id = str(source.repo_id)
478
479 # No auth_headers passed
480 resp = await client.get(f"/api/repos/{source_id}/forks")
481 assert resp.status_code == 200
482
483
484 async def test_list_forks_returns_404_for_missing_repo(
485 client: AsyncClient,
486 ) -> None:
487 """List forks for a non-existent repo returns 404."""
488 resp = await client.get("/api/repos/00000000-0000-0000-0000-000000000000/forks")
489 assert resp.status_code == 404
490
491
492 # ---------------------------------------------------------------------------
493 # GET /api/repos/{repo_id}/fork-network
494 # ---------------------------------------------------------------------------
495
496
497 async def test_fork_network_has_root_and_children(
498 client: AsyncClient,
499 auth_headers: StrDict,
500 db_session: AsyncSession,
501 ) -> None:
502 """Fork network returns root with forked repo as a child."""
503 await _seed_identity(db_session, "liam")
504 source = MusehubRepo(
505 name="groove-machine",
506 owner="liam",
507 slug="groove-machine",
508 visibility="public",
509 owner_user_id="liam",
510 description="Liam's groove machine",
511 )
512 db_session.add(source)
513 await db_session.commit()
514 await db_session.refresh(source)
515 source_id = str(source.repo_id)
516
517 # Fork it
518 fork_resp = await client.post(
519 f"/api/repos/{source_id}/fork",
520 json={},
521 headers=auth_headers,
522 )
523 assert fork_resp.status_code == 201, fork_resp.text
524
525 # Get fork network
526 resp = await client.get(f"/api/repos/{source_id}/fork-network")
527 assert resp.status_code == 200
528 body = resp.json()
529
530 assert "root" in body
531 assert body["totalForks"] == 1
532 root = body["root"]
533 assert root["owner"] == "liam"
534 assert root["repoSlug"] == "groove-machine"
535 assert len(root["children"]) == 1
536 child = root["children"][0]
537 assert child["owner"] == _TEST_HANDLE
538 assert child["forkedBy"] == _TEST_HANDLE
539
540
541 async def test_fork_network_empty_children_when_no_forks(
542 client: AsyncClient,
543 db_session: AsyncSession,
544 ) -> None:
545 """Fork network for a repo with no forks has an empty children list."""
546 await _seed_identity(db_session, "mia")
547 source = MusehubRepo(
548 name="solo-track",
549 owner="mia",
550 slug="solo-track",
551 visibility="public",
552 owner_user_id="mia",
553 description="",
554 )
555 db_session.add(source)
556 await db_session.commit()
557 await db_session.refresh(source)
558 source_id = str(source.repo_id)
559
560 resp = await client.get(f"/api/repos/{source_id}/fork-network")
561 assert resp.status_code == 200
562 body = resp.json()
563 assert body["totalForks"] == 0
564 assert body["root"]["children"] == []
565
566
567 async def test_fork_network_returns_404_for_missing_repo(
568 client: AsyncClient,
569 ) -> None:
570 """Fork network for a non-existent repo returns 404."""
571 resp = await client.get("/api/repos/00000000-0000-0000-0000-000000000000/fork-network")
572 assert resp.status_code == 404
573
574
575 async def test_fork_network_no_auth_required(
576 client: AsyncClient,
577 db_session: AsyncSession,
578 ) -> None:
579 """Fork network endpoint is publicly accessible without authentication."""
580 await _seed_identity(db_session, "noah")
581 source = MusehubRepo(
582 name="collab-beats",
583 owner="noah",
584 slug="collab-beats",
585 visibility="public",
586 owner_user_id="noah",
587 description="",
588 )
589 db_session.add(source)
590 await db_session.commit()
591 await db_session.refresh(source)
592 source_id = str(source.repo_id)
593
594 resp = await client.get(f"/api/repos/{source_id}/fork-network")
595 assert resp.status_code == 200
596
597
598 # ---------------------------------------------------------------------------
599 # GET /api/users/{username}/forks
600 # ---------------------------------------------------------------------------
601
602
603 async def test_get_user_forks_empty_for_new_user(
604 client: AsyncClient,
605 test_user: MusehubIdentity,
606 ) -> None:
607 """A user with no forks returns an empty list."""
608 resp = await client.get(f"/api/users/{_TEST_HANDLE}/forks")
609 assert resp.status_code == 200
610 body = resp.json()
611 assert body["forks"] == []
612 assert body["total"] == 0
613
614
615 async def test_get_user_forks_shows_fork_after_creation(
616 client: AsyncClient,
617 auth_headers: StrDict,
618 db_session: AsyncSession,
619 test_user: MusehubIdentity,
620 ) -> None:
621 """User's forks list is populated after forking a repo."""
622 await _seed_identity(db_session, "olivia")
623 source = MusehubRepo(
624 name="soul-session",
625 owner="olivia",
626 slug="soul-session",
627 visibility="public",
628 owner_user_id="olivia",
629 description="Olivia's soul session",
630 )
631 db_session.add(source)
632 await db_session.commit()
633 await db_session.refresh(source)
634 source_id = str(source.repo_id)
635
636 # Fork it
637 fork_resp = await client.post(
638 f"/api/repos/{source_id}/fork",
639 json={},
640 headers=auth_headers,
641 )
642 assert fork_resp.status_code == 201, fork_resp.text
643
644 # Get user forks
645 resp = await client.get(f"/api/users/{_TEST_HANDLE}/forks")
646 assert resp.status_code == 200
647 body = resp.json()
648
649 assert body["total"] == 1
650 entry = body["forks"][0]
651 assert entry["sourceOwner"] == "olivia"
652 assert entry["sourceSlug"] == "soul-session"
653 assert entry["forkRepo"]["owner"] == _TEST_HANDLE
654 assert "forkId" in entry
655 assert "forkedAt" in entry
656
657
658 async def test_get_user_forks_404_for_unknown_user(
659 client: AsyncClient,
660 ) -> None:
661 """Requesting forks for an unknown user returns 404."""
662 resp = await client.get("/api/users/nonexistent-user-xyz/forks")
663 assert resp.status_code == 404
664
665
666 async def test_get_user_forks_no_auth_required(
667 client: AsyncClient,
668 test_user: MusehubIdentity,
669 ) -> None:
670 """User forks endpoint is publicly accessible without authentication."""
671 resp = await client.get(f"/api/users/{_TEST_HANDLE}/forks")
672 assert resp.status_code == 200
673
674
675 # ---------------------------------------------------------------------------
676 # Fork response shape
677 # ---------------------------------------------------------------------------
678
679
680 async def test_fork_response_contains_all_required_fields(
681 client: AsyncClient,
682 auth_headers: StrDict,
683 db_session: AsyncSession,
684 ) -> None:
685 """Fork creation response contains all documented fields."""
686 await _seed_identity(db_session, "peter")
687 source = MusehubRepo(
688 name="field-check-beats",
689 owner="peter",
690 slug="field-check-beats",
691 visibility="public",
692 owner_user_id="peter",
693 description="Peter's beats",
694 tags=["jazz", "soul"],
695 )
696 db_session.add(source)
697 await db_session.commit()
698 await db_session.refresh(source)
699 source_id = str(source.repo_id)
700
701 resp = await client.post(
702 f"/api/repos/{source_id}/fork",
703 json={},
704 headers=auth_headers,
705 )
706 assert resp.status_code == 201, resp.text
707 body = resp.json()
708
709 # Top-level fork entry fields
710 for field in ("forkId", "forkRepo", "sourceOwner", "sourceSlug", "forkedAt"):
711 assert field in body, f"Missing field: {field}"
712
713 # Fork repo fields
714 fork_repo = body["forkRepo"]
715 for field in ("repoId", "name", "owner", "slug", "visibility", "description", "tags", "createdAt"):
716 assert field in fork_repo, f"Missing forkRepo field: {field}"
717
718 # Tags are copied from source
719 assert "jazz" in fork_repo["tags"]
720 assert "soul" in fork_repo["tags"]
721
722
723 # ---------------------------------------------------------------------------
724 # Multiple forks of the same source
725 # ---------------------------------------------------------------------------
726
727
728 async def test_multiple_forks_appear_in_list(
729 client: AsyncClient,
730 auth_headers: StrDict,
731 db_session: AsyncSession,
732 ) -> None:
733 """Multiple forks by different users all appear in the source repo's fork list."""
734 await _seed_identity(db_session, "quinn")
735 source = MusehubRepo(
736 name="viral-track",
737 owner="quinn",
738 slug="viral-track",
739 visibility="public",
740 owner_user_id="quinn",
741 description="Quinn's viral track",
742 )
743 db_session.add(source)
744 await db_session.commit()
745 await db_session.refresh(source)
746 source_id = str(source.repo_id)
747
748 # testuser forks it
749 fork_resp = await client.post(
750 f"/api/repos/{source_id}/fork",
751 json={},
752 headers=auth_headers,
753 )
754 assert fork_resp.status_code == 201, fork_resp.text
755
756 # Seed a second forker via direct DB insert (bypasses auth)
757 second_forker = await _seed_identity(db_session, "rachel")
758 fork_repo_2 = MusehubRepo(
759 name="viral-track",
760 owner="rachel",
761 slug="viral-track",
762 visibility="public",
763 owner_user_id="rachel",
764 description="Fork of quinn/viral-track: Quinn's viral track",
765 )
766 db_session.add(fork_repo_2)
767 await db_session.commit()
768 await db_session.refresh(fork_repo_2)
769
770 from musehub.db.musehub_models import MusehubFork
771 fork_record = MusehubFork(
772 source_repo_id=source_id,
773 fork_repo_id=fork_repo_2.repo_id,
774 forked_by="rachel",
775 )
776 db_session.add(fork_record)
777 await db_session.commit()
778
779 resp = await client.get(f"/api/repos/{source_id}/forks")
780 assert resp.status_code == 200
781 body = resp.json()
782
783 assert body["total"] == 2
784 owners = {f["forkRepo"]["owner"] for f in body["forks"]}
785 assert _TEST_HANDLE in owners
786 assert "rachel" in owners
787
788
789 # ---------------------------------------------------------------------------
790 # Private fork visibility — security hardening
791 # ---------------------------------------------------------------------------
792
793
794 async def test_private_fork_hidden_from_source_forks_list(
795 client: AsyncClient,
796 auth_headers: StrDict,
797 db_session: AsyncSession,
798 ) -> None:
799 """A private fork must NOT appear in the public GET /repos/{id}/forks list."""
800 await _seed_identity(db_session, "sam")
801 source = MusehubRepo(
802 name="secret-upstream",
803 owner="sam",
804 slug="secret-upstream",
805 visibility="public",
806 owner_user_id="sam",
807 description="Sam's upstream",
808 )
809 db_session.add(source)
810 await db_session.commit()
811 await db_session.refresh(source)
812 source_id = str(source.repo_id)
813
814 # Fork with private visibility
815 resp = await client.post(
816 f"/api/repos/{source_id}/fork",
817 json={"visibility": "private"},
818 headers=auth_headers,
819 )
820 assert resp.status_code == 201, resp.text
821
822 # Public listing must be empty — the fork is private
823 list_resp = await client.get(f"/api/repos/{source_id}/forks")
824 assert list_resp.status_code == 200
825 body = list_resp.json()
826 assert body["total"] == 0
827 assert body["forks"] == []
828
829
830 async def test_private_fork_hidden_from_fork_network(
831 client: AsyncClient,
832 auth_headers: StrDict,
833 db_session: AsyncSession,
834 ) -> None:
835 """A private fork must NOT appear in the public fork-network tree."""
836 await _seed_identity(db_session, "tara")
837 source = MusehubRepo(
838 name="silent-upstream",
839 owner="tara",
840 slug="silent-upstream",
841 visibility="public",
842 owner_user_id="tara",
843 description="Tara's upstream",
844 )
845 db_session.add(source)
846 await db_session.commit()
847 await db_session.refresh(source)
848 source_id = str(source.repo_id)
849
850 # Fork with private visibility
851 resp = await client.post(
852 f"/api/repos/{source_id}/fork",
853 json={"visibility": "private"},
854 headers=auth_headers,
855 )
856 assert resp.status_code == 201, resp.text
857
858 # Fork network must show 0 forks — private fork is not in tree
859 net_resp = await client.get(f"/api/repos/{source_id}/fork-network")
860 assert net_resp.status_code == 200
861 body = net_resp.json()
862 assert body["totalForks"] == 0
863 assert body["root"]["children"] == []
864
865
866 async def test_private_fork_hidden_from_public_user_forks(
867 client: AsyncClient,
868 test_user: MusehubIdentity,
869 db_session: AsyncSession,
870 ) -> None:
871 """A private fork must NOT appear when an unauthenticated caller views a user's forks.
872
873 Note: this test does NOT request the ``auth_headers`` fixture because that
874 fixture globally overrides ``optional_signed_request`` to return the test
875 context, making every request in the test look authenticated. Instead we
876 seed the fork directly in the DB so we can make a genuinely anonymous call.
877 """
878 await _seed_identity(db_session, "uma")
879 source = MusehubRepo(
880 name="covert-upstream",
881 owner="uma",
882 slug="covert-upstream",
883 visibility="public",
884 owner_user_id="uma",
885 description="Uma's upstream",
886 )
887 fork_repo = MusehubRepo(
888 name="covert-upstream",
889 owner=_TEST_HANDLE,
890 slug="covert-upstream",
891 visibility="private", # private fork
892 owner_user_id=_TEST_HANDLE,
893 description="Fork of uma/covert-upstream: Uma's upstream",
894 )
895 db_session.add(source)
896 db_session.add(fork_repo)
897 await db_session.commit()
898 await db_session.refresh(source)
899 await db_session.refresh(fork_repo)
900
901 from musehub.db.musehub_models import MusehubFork
902 fork_record = MusehubFork(
903 source_repo_id=str(source.repo_id),
904 fork_repo_id=str(fork_repo.repo_id),
905 forked_by=_TEST_HANDLE,
906 )
907 db_session.add(fork_record)
908 await db_session.commit()
909
910 # Genuinely unauthenticated GET — no auth_headers fixture, no dep override
911 anon_resp = await client.get(f"/api/users/{_TEST_HANDLE}/forks")
912 assert anon_resp.status_code == 200
913 body = anon_resp.json()
914 assert body["total"] == 0
915 assert body["forks"] == []
916
917
918 async def test_private_fork_visible_to_owner(
919 client: AsyncClient,
920 auth_headers: StrDict,
921 db_session: AsyncSession,
922 ) -> None:
923 """The fork owner can see their own private fork via the authenticated forks endpoint."""
924 await _seed_identity(db_session, "vera")
925 source = MusehubRepo(
926 name="owner-visible-upstream",
927 owner="vera",
928 slug="owner-visible-upstream",
929 visibility="public",
930 owner_user_id="vera",
931 description="Vera's upstream",
932 )
933 db_session.add(source)
934 await db_session.commit()
935 await db_session.refresh(source)
936 source_id = str(source.repo_id)
937
938 # Fork with private visibility
939 resp = await client.post(
940 f"/api/repos/{source_id}/fork",
941 json={"visibility": "private"},
942 headers=auth_headers,
943 )
944 assert resp.status_code == 201, resp.text
945
946 # Authenticated as owner — private fork IS visible
947 auth_resp = await client.get(f"/api/users/{_TEST_HANDLE}/forks", headers=auth_headers)
948 assert auth_resp.status_code == 200
949 body = auth_resp.json()
950 assert body["total"] == 1
951 assert body["forks"][0]["forkRepo"]["visibility"] == "private"
952
953
954 async def test_invalid_visibility_returns_422(
955 client: AsyncClient,
956 auth_headers: StrDict,
957 db_session: AsyncSession,
958 ) -> None:
959 """Fork request with invalid visibility value returns 422 Unprocessable Entity."""
960 await _seed_identity(db_session, "walter")
961 source = MusehubRepo(
962 name="valid-upstream",
963 owner="walter",
964 slug="valid-upstream",
965 visibility="public",
966 owner_user_id="walter",
967 description="Walter's upstream",
968 )
969 db_session.add(source)
970 await db_session.commit()
971 await db_session.refresh(source)
972 source_id = str(source.repo_id)
973
974 resp = await client.post(
975 f"/api/repos/{source_id}/fork",
976 json={"visibility": "superadmin"},
977 headers=auth_headers,
978 )
979 assert resp.status_code == 422
980
981
982 async def test_slug_collision_auto_resolved(
983 client: AsyncClient,
984 auth_headers: StrDict,
985 db_session: AsyncSession,
986 ) -> None:
987 """Forking when the caller already owns a repo with the same name auto-suffixes the slug."""
988 # testuser already owns a repo with the same name as the source
989 existing_resp = await client.post(
990 "/api/repos",
991 json={"name": "classic-track", "owner": _TEST_HANDLE, "visibility": "public", "initialize": False},
992 headers=auth_headers,
993 )
994 assert existing_resp.status_code == 201, existing_resp.text
995
996 await _seed_identity(db_session, "xavier")
997 source = MusehubRepo(
998 name="classic-track", # same name as testuser's existing repo
999 owner="xavier",
1000 slug="classic-track",
1001 visibility="public",
1002 owner_user_id="xavier",
1003 description="Xavier's classic track",
1004 )
1005 db_session.add(source)
1006 await db_session.commit()
1007 await db_session.refresh(source)
1008 source_id = str(source.repo_id)
1009
1010 # Fork should succeed despite slug collision — gets auto-suffixed slug
1011 fork_resp = await client.post(
1012 f"/api/repos/{source_id}/fork",
1013 json={},
1014 headers=auth_headers,
1015 )
1016 assert fork_resp.status_code == 201, fork_resp.text
1017 fork_slug = fork_resp.json()["forkRepo"]["slug"]
1018 # Slug must differ from the existing one (auto-suffixed)
1019 assert fork_slug == "classic-track-2"
File History 1 commit
sha256:9590cee1e0ccd6c76528f005b95d634d80f5019f0dcb7c371e149adc31d1fb65 refactor: enforce gRPC framing on all MWP wire traffic Sonnet 4.6 minor ⚠ 155 days ago