gabriel / musehub public
test_musehub_forks.py python
808 lines 26.5 KB
Raw
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef debug(push/stream): instrument O-frame decode path with INF… Sonnet 4.6 patch 121 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 from datetime import datetime, timezone
40
41 import pytest
42 from httpx import AsyncClient
43 from sqlalchemy.ext.asyncio import AsyncSession
44
45 from musehub.core.genesis import compute_fork_id, compute_identity_id, compute_repo_id
46 from musehub.db.musehub_models import MusehubIdentity, MusehubRepo
47 from musehub.types.json_types import StrDict
48
49
50 # ---------------------------------------------------------------------------
51 # Helpers
52 # ---------------------------------------------------------------------------
53
54 _TEST_HANDLE = "testuser" # matches conftest._TEST_HANDLE
55
56
57 async def _create_public_repo(
58 client: AsyncClient,
59 auth_headers: StrDict,
60 name: str = "upstream-beats",
61 ) -> str:
62 """Create a public repo via the API and return its repo_id."""
63 resp = await client.post(
64 "/api/repos",
65 json={"name": name, "owner": _TEST_HANDLE, "visibility": "public", "initialize": False},
66 headers=auth_headers,
67 )
68 assert resp.status_code == 201, resp.text
69 return str(resp.json()["repoId"])
70
71
72 async def _create_private_repo(
73 client: AsyncClient,
74 auth_headers: StrDict,
75 name: str = "secret-project",
76 ) -> str:
77 """Create a private repo via the API and return its repo_id."""
78 resp = await client.post(
79 "/api/repos",
80 json={"name": name, "owner": _TEST_HANDLE, "visibility": "private", "initialize": False},
81 headers=auth_headers,
82 )
83 assert resp.status_code == 201, resp.text
84 return str(resp.json()["repoId"])
85
86
87 async def _seed_identity(db: AsyncSession, handle: str) -> MusehubIdentity:
88 """Seed a secondary identity in the DB (simulating a different user)."""
89 identity = MusehubIdentity(
90 identity_id=compute_identity_id(handle.encode()),
91 handle=handle,
92 display_name=handle.title(),
93 identity_type="human",
94 )
95 db.add(identity)
96 await db.commit()
97 await db.refresh(identity)
98 return identity
99
100
101 async def _seed_source_repo(
102 db: AsyncSession,
103 owner: str,
104 slug: str,
105 visibility: str = "public",
106 **kwargs: str | list[str],
107 ) -> str:
108 """Seed owner identity + source repo; return repo_id string."""
109 await _seed_identity(db, owner)
110 created_at = datetime.now(tz=timezone.utc)
111 owner_id = compute_identity_id(owner.encode())
112 repo = MusehubRepo(
113 repo_id=compute_repo_id(owner_id, slug, "code", created_at.isoformat()),
114 name=slug,
115 owner=owner,
116 slug=slug,
117 visibility=visibility,
118 owner_user_id=owner_id,
119 created_at=created_at,
120 updated_at=created_at,
121 **kwargs,
122 )
123 db.add(repo)
124 await db.commit()
125 await db.refresh(repo)
126 return str(repo.repo_id)
127
128
129 # ---------------------------------------------------------------------------
130 # POST /api/repos/{repo_id}/fork — happy path
131 # ---------------------------------------------------------------------------
132
133
134 async def test_fork_public_repo_returns_201(
135 client: AsyncClient,
136 auth_headers: StrDict,
137 db_session: AsyncSession,
138 ) -> None:
139 """Forking a public repo returns 201 with fork metadata."""
140 # Seed a public source repo owned by a different identity so the caller
141 # can fork it.
142 source_id = await _seed_source_repo(db_session, "alice", "shared-beats", description="Alice's public beats")
143
144 resp = await client.post(
145 f"/api/repos/{source_id}/fork",
146 json={},
147 headers=auth_headers,
148 )
149
150 assert resp.status_code == 201, resp.text
151 body = resp.json()
152
153 assert "forkId" in body
154 assert body["sourceOwner"] == "alice"
155 assert body["sourceSlug"] == "shared-beats"
156 assert "forkRepo" in body
157 fork_repo = body["forkRepo"]
158 assert fork_repo["owner"] == _TEST_HANDLE
159 assert fork_repo["visibility"] == "public"
160 assert "forkedAt" in body
161
162
163 async def test_fork_sets_description_with_attribution(
164 client: AsyncClient,
165 auth_headers: StrDict,
166 db_session: AsyncSession,
167 ) -> None:
168 """Fork description defaults to 'Fork of {owner}/{slug}: {source description}'."""
169 source_id = await _seed_source_repo(db_session, "bob", "groove-box", description="Bob's groove box")
170
171 resp = await client.post(
172 f"/api/repos/{source_id}/fork",
173 json={},
174 headers=auth_headers,
175 )
176
177 assert resp.status_code == 201, resp.text
178 description = resp.json()["forkRepo"]["description"]
179 assert "bob" in description
180 assert "groove-box" in description
181 assert "Bob's groove box" in description
182
183
184 async def test_fork_with_custom_name(
185 client: AsyncClient,
186 auth_headers: StrDict,
187 db_session: AsyncSession,
188 ) -> None:
189 """Fork accepts an optional custom name for the new repo."""
190 source_id = await _seed_source_repo(db_session, "carol", "jazz-trio", description="Carol's jazz trio")
191
192 resp = await client.post(
193 f"/api/repos/{source_id}/fork",
194 json={"name": "my-jazz-experiment"},
195 headers=auth_headers,
196 )
197
198 assert resp.status_code == 201, resp.text
199 fork_repo = resp.json()["forkRepo"]
200 assert "jazz-experiment" in fork_repo["slug"]
201
202
203 async def test_fork_with_private_visibility(
204 client: AsyncClient,
205 auth_headers: StrDict,
206 db_session: AsyncSession,
207 ) -> None:
208 """Fork accepts visibility='private' to create a private fork."""
209 source_id = await _seed_source_repo(db_session, "dave", "open-source-beats", description="Dave's open source beats")
210
211 resp = await client.post(
212 f"/api/repos/{source_id}/fork",
213 json={"visibility": "private"},
214 headers=auth_headers,
215 )
216
217 assert resp.status_code == 201, resp.text
218 assert resp.json()["forkRepo"]["visibility"] == "private"
219
220
221 async def test_fork_with_custom_description(
222 client: AsyncClient,
223 auth_headers: StrDict,
224 db_session: AsyncSession,
225 ) -> None:
226 """Fork accepts a custom description that overrides the default attribution."""
227 source_id = await _seed_source_repo(db_session, "eve", "synth-lab", description="")
228
229 resp = await client.post(
230 f"/api/repos/{source_id}/fork",
231 json={"description": "My custom synth fork"},
232 headers=auth_headers,
233 )
234
235 assert resp.status_code == 201, resp.text
236 assert resp.json()["forkRepo"]["description"] == "My custom synth fork"
237
238
239 # ---------------------------------------------------------------------------
240 # POST /api/repos/{repo_id}/fork — error cases
241 # ---------------------------------------------------------------------------
242
243
244 async def test_fork_nonexistent_repo_returns_404(
245 client: AsyncClient,
246 auth_headers: StrDict,
247 ) -> None:
248 """Forking a repo that doesn't exist returns 404."""
249 resp = await client.post(
250 "/api/repos/00000000-0000-0000-0000-000000000000/fork",
251 json={},
252 headers=auth_headers,
253 )
254 assert resp.status_code == 404
255
256
257 async def test_fork_private_repo_returns_403(
258 client: AsyncClient,
259 auth_headers: StrDict,
260 db_session: AsyncSession,
261 ) -> None:
262 """Forking a private repo returns 403."""
263 source_id = await _seed_source_repo(db_session, "frank", "private-session", visibility="private", description="Frank's private work")
264
265 resp = await client.post(
266 f"/api/repos/{source_id}/fork",
267 json={},
268 headers=auth_headers,
269 )
270 assert resp.status_code == 403
271 assert "public" in resp.json()["detail"].lower()
272
273
274 async def test_fork_own_repo_returns_403(
275 client: AsyncClient,
276 auth_headers: StrDict,
277 db_session: AsyncSession,
278 ) -> None:
279 """Caller cannot fork a repository they already own — returns 403."""
280 source_id = await _create_public_repo(client, auth_headers, name="my-own-beats")
281
282 resp = await client.post(
283 f"/api/repos/{source_id}/fork",
284 json={},
285 headers=auth_headers,
286 )
287 assert resp.status_code == 403
288 assert "own" in resp.json()["detail"].lower()
289
290
291 async def test_fork_same_repo_twice_returns_409(
292 client: AsyncClient,
293 auth_headers: StrDict,
294 db_session: AsyncSession,
295 ) -> None:
296 """Forking the same repo twice returns 409 Conflict."""
297 source_id = await _seed_source_repo(db_session, "grace", "shared-vibes", description="Grace's vibes")
298
299 # First fork succeeds
300 resp1 = await client.post(
301 f"/api/repos/{source_id}/fork",
302 json={},
303 headers=auth_headers,
304 )
305 assert resp1.status_code == 201, resp1.text
306
307 # Second fork of the same repo → 409
308 resp2 = await client.post(
309 f"/api/repos/{source_id}/fork",
310 json={"name": "another-fork"},
311 headers=auth_headers,
312 )
313 assert resp2.status_code == 409
314
315
316 async def test_fork_requires_auth(client: AsyncClient, db_session: AsyncSession) -> None:
317 """Unauthenticated fork request returns 401."""
318 source_id = await _seed_source_repo(db_session, "henry", "open-beats", description="")
319
320 resp = await client.post(f"/api/repos/{source_id}/fork", json={})
321 assert resp.status_code == 401
322
323
324 # ---------------------------------------------------------------------------
325 # GET /api/repos/{repo_id}/forks
326 # ---------------------------------------------------------------------------
327
328
329 async def test_list_forks_empty_when_no_forks(
330 client: AsyncClient,
331 auth_headers: StrDict,
332 db_session: AsyncSession,
333 ) -> None:
334 """A repo with no forks returns an empty list."""
335 source_id = await _seed_source_repo(db_session, "iris", "unfork-able", description="")
336
337 resp = await client.get(f"/api/repos/{source_id}/forks")
338 assert resp.status_code == 200
339 body = resp.json()
340 assert body["forks"] == []
341 assert body["total"] == 0
342
343
344 async def test_list_forks_shows_fork_after_creation(
345 client: AsyncClient,
346 auth_headers: StrDict,
347 db_session: AsyncSession,
348 ) -> None:
349 """A fork appears in the list after being created."""
350 source_id = await _seed_source_repo(db_session, "jack", "popular-track", description="Jack's popular track")
351
352 # Fork it
353 fork_resp = await client.post(
354 f"/api/repos/{source_id}/fork",
355 json={},
356 headers=auth_headers,
357 )
358 assert fork_resp.status_code == 201, fork_resp.text
359
360 # List forks
361 resp = await client.get(f"/api/repos/{source_id}/forks")
362 assert resp.status_code == 200
363 body = resp.json()
364
365 assert body["total"] == 1
366 fork = body["forks"][0]
367 assert fork["sourceOwner"] == "jack"
368 assert fork["sourceSlug"] == "popular-track"
369 assert fork["forkRepo"]["owner"] == _TEST_HANDLE
370
371
372 async def test_list_forks_no_auth_required(
373 client: AsyncClient,
374 db_session: AsyncSession,
375 ) -> None:
376 """List forks endpoint is publicly accessible without authentication."""
377 source_id = await _seed_source_repo(db_session, "kate", "public-beats", description="")
378
379 # No auth_headers passed
380 resp = await client.get(f"/api/repos/{source_id}/forks")
381 assert resp.status_code == 200
382
383
384 async def test_list_forks_returns_404_for_missing_repo(
385 client: AsyncClient,
386 ) -> None:
387 """List forks for a non-existent repo returns 404."""
388 resp = await client.get("/api/repos/00000000-0000-0000-0000-000000000000/forks")
389 assert resp.status_code == 404
390
391
392 # ---------------------------------------------------------------------------
393 # GET /api/repos/{repo_id}/fork-network
394 # ---------------------------------------------------------------------------
395
396
397 async def test_fork_network_has_root_and_children(
398 client: AsyncClient,
399 auth_headers: StrDict,
400 db_session: AsyncSession,
401 ) -> None:
402 """Fork network returns root with forked repo as a child."""
403 source_id = await _seed_source_repo(db_session, "liam", "groove-machine", description="Liam's groove machine")
404
405 # Fork it
406 fork_resp = await client.post(
407 f"/api/repos/{source_id}/fork",
408 json={},
409 headers=auth_headers,
410 )
411 assert fork_resp.status_code == 201, fork_resp.text
412
413 # Get fork network
414 resp = await client.get(f"/api/repos/{source_id}/fork-network")
415 assert resp.status_code == 200
416 body = resp.json()
417
418 assert "root" in body
419 assert body["totalForks"] == 1
420 root = body["root"]
421 assert root["owner"] == "liam"
422 assert root["repoSlug"] == "groove-machine"
423 assert len(root["children"]) == 1
424 child = root["children"][0]
425 assert child["owner"] == _TEST_HANDLE
426 assert child["forkedBy"] == _TEST_HANDLE
427
428
429 async def test_fork_network_empty_children_when_no_forks(
430 client: AsyncClient,
431 db_session: AsyncSession,
432 ) -> None:
433 """Fork network for a repo with no forks has an empty children list."""
434 source_id = await _seed_source_repo(db_session, "mia", "solo-track", description="")
435
436 resp = await client.get(f"/api/repos/{source_id}/fork-network")
437 assert resp.status_code == 200
438 body = resp.json()
439 assert body["totalForks"] == 0
440 assert body["root"]["children"] == []
441
442
443 async def test_fork_network_returns_404_for_missing_repo(
444 client: AsyncClient,
445 ) -> None:
446 """Fork network for a non-existent repo returns 404."""
447 resp = await client.get("/api/repos/00000000-0000-0000-0000-000000000000/fork-network")
448 assert resp.status_code == 404
449
450
451 async def test_fork_network_no_auth_required(
452 client: AsyncClient,
453 db_session: AsyncSession,
454 ) -> None:
455 """Fork network endpoint is publicly accessible without authentication."""
456 source_id = await _seed_source_repo(db_session, "noah", "collab-beats", description="")
457
458 resp = await client.get(f"/api/repos/{source_id}/fork-network")
459 assert resp.status_code == 200
460
461
462 # ---------------------------------------------------------------------------
463 # GET /api/users/{username}/forks
464 # ---------------------------------------------------------------------------
465
466
467 async def test_get_user_forks_empty_for_new_user(
468 client: AsyncClient,
469 test_user: MusehubIdentity,
470 ) -> None:
471 """A user with no forks returns an empty list."""
472 resp = await client.get(f"/api/users/{_TEST_HANDLE}/forks")
473 assert resp.status_code == 200
474 body = resp.json()
475 assert body["forks"] == []
476 assert body["total"] == 0
477
478
479 async def test_get_user_forks_shows_fork_after_creation(
480 client: AsyncClient,
481 auth_headers: StrDict,
482 db_session: AsyncSession,
483 test_user: MusehubIdentity,
484 ) -> None:
485 """User's forks list is populated after forking a repo."""
486 source_id = await _seed_source_repo(db_session, "olivia", "soul-session", description="Olivia's soul session")
487
488 # Fork it
489 fork_resp = await client.post(
490 f"/api/repos/{source_id}/fork",
491 json={},
492 headers=auth_headers,
493 )
494 assert fork_resp.status_code == 201, fork_resp.text
495
496 # Get user forks
497 resp = await client.get(f"/api/users/{_TEST_HANDLE}/forks")
498 assert resp.status_code == 200
499 body = resp.json()
500
501 assert body["total"] == 1
502 entry = body["forks"][0]
503 assert entry["sourceOwner"] == "olivia"
504 assert entry["sourceSlug"] == "soul-session"
505 assert entry["forkRepo"]["owner"] == _TEST_HANDLE
506 assert "forkId" in entry
507 assert "forkedAt" in entry
508
509
510 async def test_get_user_forks_404_for_unknown_user(
511 client: AsyncClient,
512 ) -> None:
513 """Requesting forks for an unknown user returns 404."""
514 resp = await client.get("/api/users/nonexistent-user-xyz/forks")
515 assert resp.status_code == 404
516
517
518 async def test_get_user_forks_no_auth_required(
519 client: AsyncClient,
520 test_user: MusehubIdentity,
521 ) -> None:
522 """User forks endpoint is publicly accessible without authentication."""
523 resp = await client.get(f"/api/users/{_TEST_HANDLE}/forks")
524 assert resp.status_code == 200
525
526
527 # ---------------------------------------------------------------------------
528 # Fork response shape
529 # ---------------------------------------------------------------------------
530
531
532 async def test_fork_response_contains_all_required_fields(
533 client: AsyncClient,
534 auth_headers: StrDict,
535 db_session: AsyncSession,
536 ) -> None:
537 """Fork creation response contains all documented fields."""
538 source_id = await _seed_source_repo(db_session, "peter", "field-check-beats", description="Peter's beats", tags=["jazz", "soul"])
539
540 resp = await client.post(
541 f"/api/repos/{source_id}/fork",
542 json={},
543 headers=auth_headers,
544 )
545 assert resp.status_code == 201, resp.text
546 body = resp.json()
547
548 # Top-level fork entry fields
549 for field in ("forkId", "forkRepo", "sourceOwner", "sourceSlug", "forkedAt"):
550 assert field in body, f"Missing field: {field}"
551
552 # Fork repo fields
553 fork_repo = body["forkRepo"]
554 for field in ("repoId", "name", "owner", "slug", "visibility", "description", "tags", "createdAt"):
555 assert field in fork_repo, f"Missing forkRepo field: {field}"
556
557 # Tags are copied from source
558 assert "jazz" in fork_repo["tags"]
559 assert "soul" in fork_repo["tags"]
560
561
562 # ---------------------------------------------------------------------------
563 # Multiple forks of the same source
564 # ---------------------------------------------------------------------------
565
566
567 async def test_multiple_forks_appear_in_list(
568 client: AsyncClient,
569 auth_headers: StrDict,
570 db_session: AsyncSession,
571 ) -> None:
572 """Multiple forks by different users all appear in the source repo's fork list."""
573 source_id = await _seed_source_repo(db_session, "quinn", "viral-track", description="Quinn's viral track")
574
575 # testuser forks it
576 fork_resp = await client.post(
577 f"/api/repos/{source_id}/fork",
578 json={},
579 headers=auth_headers,
580 )
581 assert fork_resp.status_code == 201, fork_resp.text
582
583 # Seed a second forker via direct DB insert (bypasses auth)
584 _rachel_id = compute_identity_id(b"rachel")
585 await _seed_identity(db_session, "rachel")
586 _fr2_created = datetime.now(tz=timezone.utc)
587 _fr2_id = compute_repo_id(_rachel_id, "viral-track", "code", _fr2_created.isoformat())
588 fork_repo_2 = MusehubRepo(
589 repo_id=_fr2_id,
590 name="viral-track",
591 owner="rachel",
592 slug="viral-track",
593 visibility="public",
594 owner_user_id=_rachel_id,
595 description="Fork of quinn/viral-track: Quinn's viral track",
596 created_at=_fr2_created,
597 updated_at=_fr2_created,
598 )
599 db_session.add(fork_repo_2)
600 await db_session.commit()
601 await db_session.refresh(fork_repo_2)
602
603 from musehub.db.musehub_models import MusehubFork
604 _fork_now = datetime.now(tz=timezone.utc)
605 fork_record = MusehubFork(
606 fork_id=compute_fork_id(source_id, _fr2_id, _fork_now.isoformat()),
607 source_repo_id=source_id,
608 fork_repo_id=fork_repo_2.repo_id,
609 forked_by="rachel",
610 )
611 db_session.add(fork_record)
612 await db_session.commit()
613
614 resp = await client.get(f"/api/repos/{source_id}/forks")
615 assert resp.status_code == 200
616 body = resp.json()
617
618 assert body["total"] == 2
619 owners = {f["forkRepo"]["owner"] for f in body["forks"]}
620 assert _TEST_HANDLE in owners
621 assert "rachel" in owners
622
623
624 # ---------------------------------------------------------------------------
625 # Private fork visibility — security hardening
626 # ---------------------------------------------------------------------------
627
628
629 async def test_private_fork_hidden_from_source_forks_list(
630 client: AsyncClient,
631 auth_headers: StrDict,
632 db_session: AsyncSession,
633 ) -> None:
634 """A private fork must NOT appear in the public GET /repos/{id}/forks list."""
635 source_id = await _seed_source_repo(db_session, "sam", "secret-upstream", description="Sam's upstream")
636
637 # Fork with private visibility
638 resp = await client.post(
639 f"/api/repos/{source_id}/fork",
640 json={"visibility": "private"},
641 headers=auth_headers,
642 )
643 assert resp.status_code == 201, resp.text
644
645 # Public listing must be empty — the fork is private
646 list_resp = await client.get(f"/api/repos/{source_id}/forks")
647 assert list_resp.status_code == 200
648 body = list_resp.json()
649 assert body["total"] == 0
650 assert body["forks"] == []
651
652
653 async def test_private_fork_hidden_from_fork_network(
654 client: AsyncClient,
655 auth_headers: StrDict,
656 db_session: AsyncSession,
657 ) -> None:
658 """A private fork must NOT appear in the public fork-network tree."""
659 source_id = await _seed_source_repo(db_session, "tara", "silent-upstream", description="Tara's upstream")
660
661 # Fork with private visibility
662 resp = await client.post(
663 f"/api/repos/{source_id}/fork",
664 json={"visibility": "private"},
665 headers=auth_headers,
666 )
667 assert resp.status_code == 201, resp.text
668
669 # Fork network must show 0 forks — private fork is not in tree
670 net_resp = await client.get(f"/api/repos/{source_id}/fork-network")
671 assert net_resp.status_code == 200
672 body = net_resp.json()
673 assert body["totalForks"] == 0
674 assert body["root"]["children"] == []
675
676
677 async def test_private_fork_hidden_from_public_user_forks(
678 client: AsyncClient,
679 test_user: MusehubIdentity,
680 db_session: AsyncSession,
681 ) -> None:
682 """A private fork must NOT appear when an unauthenticated caller views a user's forks.
683
684 Note: this test does NOT request the ``auth_headers`` fixture because that
685 fixture globally overrides ``optional_signed_request`` to return the test
686 context, making every request in the test look authenticated. Instead we
687 seed the fork directly in the DB so we can make a genuinely anonymous call.
688 """
689 _uma_id = compute_identity_id(b"uma")
690 _test_id = compute_identity_id(_TEST_HANDLE.encode())
691 await _seed_identity(db_session, "uma")
692 _src_ts = datetime.now(tz=timezone.utc)
693 _src_id = compute_repo_id(_uma_id, "covert-upstream", "code", _src_ts.isoformat())
694 source = MusehubRepo(
695 repo_id=_src_id,
696 name="covert-upstream",
697 owner="uma",
698 slug="covert-upstream",
699 visibility="public",
700 owner_user_id=_uma_id,
701 description="Uma's upstream",
702 created_at=_src_ts,
703 updated_at=_src_ts,
704 )
705 _frk_ts = datetime.now(tz=timezone.utc)
706 _frk_id = compute_repo_id(_test_id, "covert-upstream", "code", _frk_ts.isoformat())
707 fork_repo = MusehubRepo(
708 repo_id=_frk_id,
709 name="covert-upstream",
710 owner=_TEST_HANDLE,
711 slug="covert-upstream",
712 visibility="private", # private fork
713 owner_user_id=_test_id,
714 description="Fork of uma/covert-upstream: Uma's upstream",
715 created_at=_frk_ts,
716 updated_at=_frk_ts,
717 )
718 db_session.add(source)
719 db_session.add(fork_repo)
720 await db_session.commit()
721 await db_session.refresh(source)
722 await db_session.refresh(fork_repo)
723
724 from musehub.db.musehub_models import MusehubFork
725 _fk_ts = datetime.now(tz=timezone.utc)
726 fork_record = MusehubFork(
727 fork_id=compute_fork_id(_src_id, _frk_id, _fk_ts.isoformat()),
728 source_repo_id=str(source.repo_id),
729 fork_repo_id=str(fork_repo.repo_id),
730 forked_by=_TEST_HANDLE,
731 )
732 db_session.add(fork_record)
733 await db_session.commit()
734
735 # Genuinely unauthenticated GET — no auth_headers fixture, no dep override
736 anon_resp = await client.get(f"/api/users/{_TEST_HANDLE}/forks")
737 assert anon_resp.status_code == 200
738 body = anon_resp.json()
739 assert body["total"] == 0
740 assert body["forks"] == []
741
742
743 async def test_private_fork_visible_to_owner(
744 client: AsyncClient,
745 auth_headers: StrDict,
746 db_session: AsyncSession,
747 ) -> None:
748 """The fork owner can see their own private fork via the authenticated forks endpoint."""
749 source_id = await _seed_source_repo(db_session, "vera", "owner-visible-upstream", description="Vera's upstream")
750
751 # Fork with private visibility
752 resp = await client.post(
753 f"/api/repos/{source_id}/fork",
754 json={"visibility": "private"},
755 headers=auth_headers,
756 )
757 assert resp.status_code == 201, resp.text
758
759 # Authenticated as owner — private fork IS visible
760 auth_resp = await client.get(f"/api/users/{_TEST_HANDLE}/forks", headers=auth_headers)
761 assert auth_resp.status_code == 200
762 body = auth_resp.json()
763 assert body["total"] == 1
764 assert body["forks"][0]["forkRepo"]["visibility"] == "private"
765
766
767 async def test_invalid_visibility_returns_422(
768 client: AsyncClient,
769 auth_headers: StrDict,
770 db_session: AsyncSession,
771 ) -> None:
772 """Fork request with invalid visibility value returns 422 Unprocessable Entity."""
773 source_id = await _seed_source_repo(db_session, "walter", "valid-upstream", description="Walter's upstream")
774
775 resp = await client.post(
776 f"/api/repos/{source_id}/fork",
777 json={"visibility": "superadmin"},
778 headers=auth_headers,
779 )
780 assert resp.status_code == 422
781
782
783 async def test_slug_collision_auto_resolved(
784 client: AsyncClient,
785 auth_headers: StrDict,
786 db_session: AsyncSession,
787 ) -> None:
788 """Forking when the caller already owns a repo with the same name auto-suffixes the slug."""
789 # testuser already owns a repo with the same name as the source
790 existing_resp = await client.post(
791 "/api/repos",
792 json={"name": "classic-track", "owner": _TEST_HANDLE, "visibility": "public", "initialize": False},
793 headers=auth_headers,
794 )
795 assert existing_resp.status_code == 201, existing_resp.text
796
797 source_id = await _seed_source_repo(db_session, "xavier", "classic-track", description="Xavier's classic track")
798
799 # Fork should succeed despite slug collision — gets auto-suffixed slug
800 fork_resp = await client.post(
801 f"/api/repos/{source_id}/fork",
802 json={},
803 headers=auth_headers,
804 )
805 assert fork_resp.status_code == 201, fork_resp.text
806 fork_slug = fork_resp.json()["forkRepo"]["slug"]
807 # Slug must differ from the existing one (auto-suffixed)
808 assert fork_slug == "classic-track-2"
File History 1 commit
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef debug(push/stream): instrument O-frame decode path with INF… Sonnet 4.6 patch 121 days ago