FossilRepo
Fix xfer: handle HTTP Basic Auth for fossil push/pull/clone
Commit
c2dd86c08bde705f4f02fdfaa46100e15d07794baec85c10b04e2b7462c4c4fc
Parent
0c354aca4fcba6a…
13 files changed
+31
+41
~
accounts/__pycache__/views.cpython-314.pyc
~
config/__pycache__/settings.cpython-314.pyc
~
fossil/__pycache__/api_auth.cpython-314.pyc
~
fossil/__pycache__/api_views.cpython-314.pyc
~
fossil/__pycache__/cli.cpython-314.pyc
~
fossil/__pycache__/tasks.cpython-314.pyc
~
fossil/__pycache__/urls.cpython-314.pyc
~
fossil/__pycache__/views.cpython-314.pyc
~
fossil/views.py
~
projects/__pycache__/access.cpython-314.pyc
~
tests/__pycache__/test_agent_coordination.cpython-314-pytest-9.0.2.pyc
~
tests/__pycache__/test_releases.cpython-314-pytest-9.0.2.pyc
~
tests/test_releases.py
| --- accounts/__pycache__/views.cpython-314.pyc | ||
| +++ accounts/__pycache__/views.cpython-314.pyc | ||
| cannot compute difference between binary files | ||
| 1 | 1 |
| --- accounts/__pycache__/views.cpython-314.pyc | |
| +++ accounts/__pycache__/views.cpython-314.pyc | |
| 0 | annot compute difference between binary files |
| 1 |
| --- accounts/__pycache__/views.cpython-314.pyc | |
| +++ accounts/__pycache__/views.cpython-314.pyc | |
| 0 | annot compute difference between binary files |
| 1 |
| --- config/__pycache__/settings.cpython-314.pyc | ||
| +++ config/__pycache__/settings.cpython-314.pyc | ||
| cannot compute difference between binary files | ||
| 1 | 1 |
| --- config/__pycache__/settings.cpython-314.pyc | |
| +++ config/__pycache__/settings.cpython-314.pyc | |
| 0 | annot compute difference between binary files |
| 1 |
| --- config/__pycache__/settings.cpython-314.pyc | |
| +++ config/__pycache__/settings.cpython-314.pyc | |
| 0 | annot compute difference between binary files |
| 1 |
| --- fossil/__pycache__/api_auth.cpython-314.pyc | ||
| +++ fossil/__pycache__/api_auth.cpython-314.pyc | ||
| cannot compute difference between binary files | ||
| 1 | 1 |
| --- fossil/__pycache__/api_auth.cpython-314.pyc | |
| +++ fossil/__pycache__/api_auth.cpython-314.pyc | |
| 0 | annot compute difference between binary files |
| 1 |
| --- fossil/__pycache__/api_auth.cpython-314.pyc | |
| +++ fossil/__pycache__/api_auth.cpython-314.pyc | |
| 0 | annot compute difference between binary files |
| 1 |
| --- fossil/__pycache__/api_views.cpython-314.pyc | ||
| +++ fossil/__pycache__/api_views.cpython-314.pyc | ||
| cannot compute difference between binary files | ||
| 1 | 1 |
| --- fossil/__pycache__/api_views.cpython-314.pyc | |
| +++ fossil/__pycache__/api_views.cpython-314.pyc | |
| 0 | annot compute difference between binary files |
| 1 |
| --- fossil/__pycache__/api_views.cpython-314.pyc | |
| +++ fossil/__pycache__/api_views.cpython-314.pyc | |
| 0 | annot compute difference between binary files |
| 1 |
| --- fossil/__pycache__/cli.cpython-314.pyc | ||
| +++ fossil/__pycache__/cli.cpython-314.pyc | ||
| cannot compute difference between binary files | ||
| 1 | 1 |
| --- fossil/__pycache__/cli.cpython-314.pyc | |
| +++ fossil/__pycache__/cli.cpython-314.pyc | |
| 0 | annot compute difference between binary files |
| 1 |
| --- fossil/__pycache__/cli.cpython-314.pyc | |
| +++ fossil/__pycache__/cli.cpython-314.pyc | |
| 0 | annot compute difference between binary files |
| 1 |
| --- fossil/__pycache__/tasks.cpython-314.pyc | ||
| +++ fossil/__pycache__/tasks.cpython-314.pyc | ||
| cannot compute difference between binary files | ||
| 1 | 1 |
| --- fossil/__pycache__/tasks.cpython-314.pyc | |
| +++ fossil/__pycache__/tasks.cpython-314.pyc | |
| 0 | annot compute difference between binary files |
| 1 |
| --- fossil/__pycache__/tasks.cpython-314.pyc | |
| +++ fossil/__pycache__/tasks.cpython-314.pyc | |
| 0 | annot compute difference between binary files |
| 1 |
| --- fossil/__pycache__/urls.cpython-314.pyc | ||
| +++ fossil/__pycache__/urls.cpython-314.pyc | ||
| cannot compute difference between binary files | ||
| 1 | 1 |
| --- fossil/__pycache__/urls.cpython-314.pyc | |
| +++ fossil/__pycache__/urls.cpython-314.pyc | |
| 0 | annot compute difference between binary files |
| 1 |
| --- fossil/__pycache__/urls.cpython-314.pyc | |
| +++ fossil/__pycache__/urls.cpython-314.pyc | |
| 0 | annot compute difference between binary files |
| 1 |
| --- fossil/__pycache__/views.cpython-314.pyc | ||
| +++ fossil/__pycache__/views.cpython-314.pyc | ||
| cannot compute difference between binary files | ||
| 1 | 1 |
| --- fossil/__pycache__/views.cpython-314.pyc | |
| +++ fossil/__pycache__/views.cpython-314.pyc | |
| 0 | annot compute difference between binary files |
| 1 |
| --- fossil/__pycache__/views.cpython-314.pyc | |
| +++ fossil/__pycache__/views.cpython-314.pyc | |
| 0 | annot compute difference between binary files |
| 1 |
+31
| --- fossil/views.py | ||
| +++ fossil/views.py | ||
| @@ -1835,14 +1835,34 @@ | ||
| 1835 | 1835 | |
| 1836 | 1836 | Access control: |
| 1837 | 1837 | - Public repos: anonymous clone/pull allowed (no --localauth). |
| 1838 | 1838 | - Authenticated users with write access: full push/pull (--localauth). |
| 1839 | 1839 | - Private/internal repos: require at least read permission. |
| 1840 | + | |
| 1841 | + Supports HTTP Basic Auth for fossil CLI clients (push/pull/clone). | |
| 1840 | 1842 | """ |
| 1843 | + import base64 | |
| 1844 | + | |
| 1845 | + from django.contrib.auth import authenticate | |
| 1846 | + | |
| 1841 | 1847 | from projects.access import can_read_project, can_write_project |
| 1842 | 1848 | |
| 1843 | 1849 | from .cli import FossilCLI |
| 1850 | + | |
| 1851 | + # Fossil CLI sends HTTP Basic Auth — Django's session middleware ignores it, | |
| 1852 | + # so we authenticate manually from the Authorization header. | |
| 1853 | + if not request.user.is_authenticated: | |
| 1854 | + auth_header = request.META.get("HTTP_AUTHORIZATION", "") | |
| 1855 | + if auth_header.startswith("Basic "): | |
| 1856 | + try: | |
| 1857 | + decoded = base64.b64decode(auth_header[6:]).decode("utf-8") | |
| 1858 | + username, password = decoded.split(":", 1) | |
| 1859 | + user = authenticate(request, username=username, password=password) | |
| 1860 | + if user and user.is_active: | |
| 1861 | + request.user = user | |
| 1862 | + except (ValueError, UnicodeDecodeError): | |
| 1863 | + pass | |
| 1844 | 1864 | |
| 1845 | 1865 | project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True) |
| 1846 | 1866 | fossil_repo = get_object_or_404(FossilRepository, project=project, deleted_at__isnull=True) |
| 1847 | 1867 | |
| 1848 | 1868 | if request.method == "GET": |
| @@ -3226,10 +3246,16 @@ | ||
| 3226 | 3246 | project, fossil_repo = _get_project_and_repo(slug, request, "read") |
| 3227 | 3247 | |
| 3228 | 3248 | from fossil.releases import Release, ReleaseAsset |
| 3229 | 3249 | |
| 3230 | 3250 | release = get_object_or_404(Release, repository=fossil_repo, tag_name=tag_name, deleted_at__isnull=True) |
| 3251 | + | |
| 3252 | + if release.is_draft: | |
| 3253 | + from projects.access import require_project_write | |
| 3254 | + | |
| 3255 | + require_project_write(request, project) | |
| 3256 | + | |
| 3231 | 3257 | asset = get_object_or_404(ReleaseAsset, pk=asset_id, release=release, deleted_at__isnull=True) |
| 3232 | 3258 | |
| 3233 | 3259 | # Increment download count atomically |
| 3234 | 3260 | ReleaseAsset.objects.filter(pk=asset.pk).update(download_count=db_models.F("download_count") + 1) |
| 3235 | 3261 | |
| @@ -3244,10 +3270,15 @@ | ||
| 3244 | 3270 | |
| 3245 | 3271 | from fossil.releases import Release |
| 3246 | 3272 | |
| 3247 | 3273 | release = get_object_or_404(Release, repository=fossil_repo, tag_name=tag_name, deleted_at__isnull=True) |
| 3248 | 3274 | |
| 3275 | + if release.is_draft: | |
| 3276 | + from projects.access import require_project_write | |
| 3277 | + | |
| 3278 | + require_project_write(request, project) | |
| 3279 | + | |
| 3249 | 3280 | if not release.checkin_uuid: |
| 3250 | 3281 | raise Http404("No checkin linked to this release.") |
| 3251 | 3282 | |
| 3252 | 3283 | from .cli import FossilCLI |
| 3253 | 3284 | |
| 3254 | 3285 |
| --- fossil/views.py | |
| +++ fossil/views.py | |
| @@ -1835,14 +1835,34 @@ | |
| 1835 | |
| 1836 | Access control: |
| 1837 | - Public repos: anonymous clone/pull allowed (no --localauth). |
| 1838 | - Authenticated users with write access: full push/pull (--localauth). |
| 1839 | - Private/internal repos: require at least read permission. |
| 1840 | """ |
| 1841 | from projects.access import can_read_project, can_write_project |
| 1842 | |
| 1843 | from .cli import FossilCLI |
| 1844 | |
| 1845 | project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True) |
| 1846 | fossil_repo = get_object_or_404(FossilRepository, project=project, deleted_at__isnull=True) |
| 1847 | |
| 1848 | if request.method == "GET": |
| @@ -3226,10 +3246,16 @@ | |
| 3226 | project, fossil_repo = _get_project_and_repo(slug, request, "read") |
| 3227 | |
| 3228 | from fossil.releases import Release, ReleaseAsset |
| 3229 | |
| 3230 | release = get_object_or_404(Release, repository=fossil_repo, tag_name=tag_name, deleted_at__isnull=True) |
| 3231 | asset = get_object_or_404(ReleaseAsset, pk=asset_id, release=release, deleted_at__isnull=True) |
| 3232 | |
| 3233 | # Increment download count atomically |
| 3234 | ReleaseAsset.objects.filter(pk=asset.pk).update(download_count=db_models.F("download_count") + 1) |
| 3235 | |
| @@ -3244,10 +3270,15 @@ | |
| 3244 | |
| 3245 | from fossil.releases import Release |
| 3246 | |
| 3247 | release = get_object_or_404(Release, repository=fossil_repo, tag_name=tag_name, deleted_at__isnull=True) |
| 3248 | |
| 3249 | if not release.checkin_uuid: |
| 3250 | raise Http404("No checkin linked to this release.") |
| 3251 | |
| 3252 | from .cli import FossilCLI |
| 3253 | |
| 3254 |
| --- fossil/views.py | |
| +++ fossil/views.py | |
| @@ -1835,14 +1835,34 @@ | |
| 1835 | |
| 1836 | Access control: |
| 1837 | - Public repos: anonymous clone/pull allowed (no --localauth). |
| 1838 | - Authenticated users with write access: full push/pull (--localauth). |
| 1839 | - Private/internal repos: require at least read permission. |
| 1840 | |
| 1841 | Supports HTTP Basic Auth for fossil CLI clients (push/pull/clone). |
| 1842 | """ |
| 1843 | import base64 |
| 1844 | |
| 1845 | from django.contrib.auth import authenticate |
| 1846 | |
| 1847 | from projects.access import can_read_project, can_write_project |
| 1848 | |
| 1849 | from .cli import FossilCLI |
| 1850 | |
| 1851 | # Fossil CLI sends HTTP Basic Auth — Django's session middleware ignores it, |
| 1852 | # so we authenticate manually from the Authorization header. |
| 1853 | if not request.user.is_authenticated: |
| 1854 | auth_header = request.META.get("HTTP_AUTHORIZATION", "") |
| 1855 | if auth_header.startswith("Basic "): |
| 1856 | try: |
| 1857 | decoded = base64.b64decode(auth_header[6:]).decode("utf-8") |
| 1858 | username, password = decoded.split(":", 1) |
| 1859 | user = authenticate(request, username=username, password=password) |
| 1860 | if user and user.is_active: |
| 1861 | request.user = user |
| 1862 | except (ValueError, UnicodeDecodeError): |
| 1863 | pass |
| 1864 | |
| 1865 | project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True) |
| 1866 | fossil_repo = get_object_or_404(FossilRepository, project=project, deleted_at__isnull=True) |
| 1867 | |
| 1868 | if request.method == "GET": |
| @@ -3226,10 +3246,16 @@ | |
| 3246 | project, fossil_repo = _get_project_and_repo(slug, request, "read") |
| 3247 | |
| 3248 | from fossil.releases import Release, ReleaseAsset |
| 3249 | |
| 3250 | release = get_object_or_404(Release, repository=fossil_repo, tag_name=tag_name, deleted_at__isnull=True) |
| 3251 | |
| 3252 | if release.is_draft: |
| 3253 | from projects.access import require_project_write |
| 3254 | |
| 3255 | require_project_write(request, project) |
| 3256 | |
| 3257 | asset = get_object_or_404(ReleaseAsset, pk=asset_id, release=release, deleted_at__isnull=True) |
| 3258 | |
| 3259 | # Increment download count atomically |
| 3260 | ReleaseAsset.objects.filter(pk=asset.pk).update(download_count=db_models.F("download_count") + 1) |
| 3261 | |
| @@ -3244,10 +3270,15 @@ | |
| 3270 | |
| 3271 | from fossil.releases import Release |
| 3272 | |
| 3273 | release = get_object_or_404(Release, repository=fossil_repo, tag_name=tag_name, deleted_at__isnull=True) |
| 3274 | |
| 3275 | if release.is_draft: |
| 3276 | from projects.access import require_project_write |
| 3277 | |
| 3278 | require_project_write(request, project) |
| 3279 | |
| 3280 | if not release.checkin_uuid: |
| 3281 | raise Http404("No checkin linked to this release.") |
| 3282 | |
| 3283 | from .cli import FossilCLI |
| 3284 | |
| 3285 |
| --- projects/__pycache__/access.cpython-314.pyc | ||
| +++ projects/__pycache__/access.cpython-314.pyc | ||
| cannot compute difference between binary files | ||
| 1 | 1 |
| --- projects/__pycache__/access.cpython-314.pyc | |
| +++ projects/__pycache__/access.cpython-314.pyc | |
| 0 | annot compute difference between binary files |
| 1 |
| --- projects/__pycache__/access.cpython-314.pyc | |
| +++ projects/__pycache__/access.cpython-314.pyc | |
| 0 | annot compute difference between binary files |
| 1 |
| --- tests/__pycache__/test_agent_coordination.cpython-314-pytest-9.0.2.pyc | ||
| +++ tests/__pycache__/test_agent_coordination.cpython-314-pytest-9.0.2.pyc | ||
| cannot compute difference between binary files | ||
| 1 | 1 |
| --- tests/__pycache__/test_agent_coordination.cpython-314-pytest-9.0.2.pyc | |
| +++ tests/__pycache__/test_agent_coordination.cpython-314-pytest-9.0.2.pyc | |
| 0 | annot compute difference between binary files |
| 1 |
| --- tests/__pycache__/test_agent_coordination.cpython-314-pytest-9.0.2.pyc | |
| +++ tests/__pycache__/test_agent_coordination.cpython-314-pytest-9.0.2.pyc | |
| 0 | annot compute difference between binary files |
| 1 |
| --- tests/__pycache__/test_releases.cpython-314-pytest-9.0.2.pyc | ||
| +++ tests/__pycache__/test_releases.cpython-314-pytest-9.0.2.pyc | ||
| cannot compute difference between binary files | ||
| 1 | 1 |
| --- tests/__pycache__/test_releases.cpython-314-pytest-9.0.2.pyc | |
| +++ tests/__pycache__/test_releases.cpython-314-pytest-9.0.2.pyc | |
| 0 | annot compute difference between binary files |
| 1 |
| --- tests/__pycache__/test_releases.cpython-314-pytest-9.0.2.pyc | |
| +++ tests/__pycache__/test_releases.cpython-314-pytest-9.0.2.pyc | |
| 0 | annot compute difference between binary files |
| 1 |
+41
| --- tests/test_releases.py | ||
| +++ tests/test_releases.py | ||
| @@ -58,10 +58,25 @@ | ||
| 58 | 58 | file_size_bytes=len(b"fake-tarball-content"), |
| 59 | 59 | content_type="application/gzip", |
| 60 | 60 | created_by=admin_user, |
| 61 | 61 | ) |
| 62 | 62 | |
| 63 | + | |
| 64 | +@pytest.fixture | |
| 65 | +def draft_release_asset(draft_release, admin_user, tmp_path, settings): | |
| 66 | + settings.STORAGES = _TEST_STORAGES | |
| 67 | + settings.MEDIA_ROOT = str(tmp_path / "media") | |
| 68 | + uploaded = SimpleUploadedFile("beta-build.tar.gz", b"draft-content", content_type="application/gzip") | |
| 69 | + return ReleaseAsset.objects.create( | |
| 70 | + release=draft_release, | |
| 71 | + name="beta-build.tar.gz", | |
| 72 | + file=uploaded, | |
| 73 | + file_size_bytes=len(b"draft-content"), | |
| 74 | + content_type="application/gzip", | |
| 75 | + created_by=admin_user, | |
| 76 | + ) | |
| 77 | + | |
| 63 | 78 | |
| 64 | 79 | @pytest.mark.django_db |
| 65 | 80 | class TestReleaseModel: |
| 66 | 81 | def test_create_release(self, release): |
| 67 | 82 | assert release.pk is not None |
| @@ -326,5 +341,31 @@ | ||
| 326 | 341 | assert release_asset.download_count == 2 |
| 327 | 342 | |
| 328 | 343 | def test_download_denied_on_private_for_no_perm(self, no_perm_client, sample_project, release, release_asset): |
| 329 | 344 | response = no_perm_client.get(f"/projects/{sample_project.slug}/fossil/releases/{release.tag_name}/assets/{release_asset.pk}/") |
| 330 | 345 | assert response.status_code == 403 |
| 346 | + | |
| 347 | + def test_draft_asset_download_denied_for_non_writer(self, no_perm_client, sample_project, draft_release, draft_release_asset): | |
| 348 | + """Non-writers cannot download assets from draft releases even on public projects.""" | |
| 349 | + sample_project.visibility = "public" | |
| 350 | + sample_project.save() | |
| 351 | + response = no_perm_client.get( | |
| 352 | + f"/projects/{sample_project.slug}/fossil/releases/{draft_release.tag_name}/assets/{draft_release_asset.pk}/" | |
| 353 | + ) | |
| 354 | + assert response.status_code == 403 | |
| 355 | + | |
| 356 | + def test_draft_asset_download_allowed_for_writer(self, admin_client, sample_project, draft_release, draft_release_asset): | |
| 357 | + """Writers can download assets from draft releases.""" | |
| 358 | + response = admin_client.get( | |
| 359 | + f"/projects/{sample_project.slug}/fossil/releases/{draft_release.tag_name}/assets/{draft_release_asset.pk}/" | |
| 360 | + ) | |
| 361 | + assert response.status_code == 200 | |
| 362 | + | |
| 363 | + | |
| 364 | +@pytest.mark.django_db | |
| 365 | +class TestDraftSourceArchiveAccess: | |
| 366 | + def test_draft_source_archive_denied_for_non_writer(self, no_perm_client, sample_project, draft_release): | |
| 367 | + """Non-writers cannot download source archives for draft releases.""" | |
| 368 | + sample_project.visibility = "public" | |
| 369 | + sample_project.save() | |
| 370 | + response = no_perm_client.get(f"/projects/{sample_project.slug}/fossil/releases/{draft_release.tag_name}/source/tar.gz") | |
| 371 | + assert response.status_code == 403 | |
| 331 | 372 |
| --- tests/test_releases.py | |
| +++ tests/test_releases.py | |
| @@ -58,10 +58,25 @@ | |
| 58 | file_size_bytes=len(b"fake-tarball-content"), |
| 59 | content_type="application/gzip", |
| 60 | created_by=admin_user, |
| 61 | ) |
| 62 | |
| 63 | |
| 64 | @pytest.mark.django_db |
| 65 | class TestReleaseModel: |
| 66 | def test_create_release(self, release): |
| 67 | assert release.pk is not None |
| @@ -326,5 +341,31 @@ | |
| 326 | assert release_asset.download_count == 2 |
| 327 | |
| 328 | def test_download_denied_on_private_for_no_perm(self, no_perm_client, sample_project, release, release_asset): |
| 329 | response = no_perm_client.get(f"/projects/{sample_project.slug}/fossil/releases/{release.tag_name}/assets/{release_asset.pk}/") |
| 330 | assert response.status_code == 403 |
| 331 |
| --- tests/test_releases.py | |
| +++ tests/test_releases.py | |
| @@ -58,10 +58,25 @@ | |
| 58 | file_size_bytes=len(b"fake-tarball-content"), |
| 59 | content_type="application/gzip", |
| 60 | created_by=admin_user, |
| 61 | ) |
| 62 | |
| 63 | |
| 64 | @pytest.fixture |
| 65 | def draft_release_asset(draft_release, admin_user, tmp_path, settings): |
| 66 | settings.STORAGES = _TEST_STORAGES |
| 67 | settings.MEDIA_ROOT = str(tmp_path / "media") |
| 68 | uploaded = SimpleUploadedFile("beta-build.tar.gz", b"draft-content", content_type="application/gzip") |
| 69 | return ReleaseAsset.objects.create( |
| 70 | release=draft_release, |
| 71 | name="beta-build.tar.gz", |
| 72 | file=uploaded, |
| 73 | file_size_bytes=len(b"draft-content"), |
| 74 | content_type="application/gzip", |
| 75 | created_by=admin_user, |
| 76 | ) |
| 77 | |
| 78 | |
| 79 | @pytest.mark.django_db |
| 80 | class TestReleaseModel: |
| 81 | def test_create_release(self, release): |
| 82 | assert release.pk is not None |
| @@ -326,5 +341,31 @@ | |
| 341 | assert release_asset.download_count == 2 |
| 342 | |
| 343 | def test_download_denied_on_private_for_no_perm(self, no_perm_client, sample_project, release, release_asset): |
| 344 | response = no_perm_client.get(f"/projects/{sample_project.slug}/fossil/releases/{release.tag_name}/assets/{release_asset.pk}/") |
| 345 | assert response.status_code == 403 |
| 346 | |
| 347 | def test_draft_asset_download_denied_for_non_writer(self, no_perm_client, sample_project, draft_release, draft_release_asset): |
| 348 | """Non-writers cannot download assets from draft releases even on public projects.""" |
| 349 | sample_project.visibility = "public" |
| 350 | sample_project.save() |
| 351 | response = no_perm_client.get( |
| 352 | f"/projects/{sample_project.slug}/fossil/releases/{draft_release.tag_name}/assets/{draft_release_asset.pk}/" |
| 353 | ) |
| 354 | assert response.status_code == 403 |
| 355 | |
| 356 | def test_draft_asset_download_allowed_for_writer(self, admin_client, sample_project, draft_release, draft_release_asset): |
| 357 | """Writers can download assets from draft releases.""" |
| 358 | response = admin_client.get( |
| 359 | f"/projects/{sample_project.slug}/fossil/releases/{draft_release.tag_name}/assets/{draft_release_asset.pk}/" |
| 360 | ) |
| 361 | assert response.status_code == 200 |
| 362 | |
| 363 | |
| 364 | @pytest.mark.django_db |
| 365 | class TestDraftSourceArchiveAccess: |
| 366 | def test_draft_source_archive_denied_for_non_writer(self, no_perm_client, sample_project, draft_release): |
| 367 | """Non-writers cannot download source archives for draft releases.""" |
| 368 | sample_project.visibility = "public" |
| 369 | sample_project.save() |
| 370 | response = no_perm_client.get(f"/projects/{sample_project.slug}/fossil/releases/{draft_release.tag_name}/source/tar.gz") |
| 371 | assert response.status_code == 403 |
| 372 |