FossilRepo

fossilrepo / tests / test_releases.py
Source Blame History 371 lines
c588255… ragelink 1 import pytest
c588255… ragelink 2 from django.core.files.uploadedfile import SimpleUploadedFile
c588255… ragelink 3
c588255… ragelink 4 from fossil.models import FossilRepository
c588255… ragelink 5 from fossil.releases import Release, ReleaseAsset
c588255… ragelink 6
c588255… ragelink 7 # File storage settings for tests -- the project only configures STORAGES["default"]
c588255… ragelink 8 # when USE_S3=true, so tests that use FileField need a local filesystem backend.
c588255… ragelink 9 _TEST_STORAGES = {
c588255… ragelink 10 "default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
c588255… ragelink 11 "staticfiles": {"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"},
c588255… ragelink 12 }
c588255… ragelink 13
c588255… ragelink 14
c588255… ragelink 15 @pytest.fixture
c588255… ragelink 16 def fossil_repo_obj(sample_project):
c588255… ragelink 17 """Return the auto-created FossilRepository for sample_project."""
c588255… ragelink 18 return FossilRepository.objects.get(project=sample_project, deleted_at__isnull=True)
c588255… ragelink 19
c588255… ragelink 20
c588255… ragelink 21 @pytest.fixture
c588255… ragelink 22 def release(fossil_repo_obj, admin_user):
c588255… ragelink 23 return Release.objects.create(
c588255… ragelink 24 repository=fossil_repo_obj,
c588255… ragelink 25 tag_name="v1.0.0",
c588255… ragelink 26 name="Version 1.0.0",
c588255… ragelink 27 body="## Changelog\n\n- Initial release",
c588255… ragelink 28 is_prerelease=False,
c588255… ragelink 29 is_draft=False,
c588255… ragelink 30 published_at="2026-04-01T00:00:00Z",
c588255… ragelink 31 created_by=admin_user,
c588255… ragelink 32 )
c588255… ragelink 33
c588255… ragelink 34
c588255… ragelink 35 @pytest.fixture
c588255… ragelink 36 def draft_release(fossil_repo_obj, admin_user):
c588255… ragelink 37 return Release.objects.create(
c588255… ragelink 38 repository=fossil_repo_obj,
c588255… ragelink 39 tag_name="v2.0.0-beta",
c588255… ragelink 40 name="Version 2.0.0 Beta",
c588255… ragelink 41 body="Draft notes",
c588255… ragelink 42 is_prerelease=True,
c588255… ragelink 43 is_draft=True,
c588255… ragelink 44 published_at=None,
c588255… ragelink 45 created_by=admin_user,
c588255… ragelink 46 )
c588255… ragelink 47
c588255… ragelink 48
c588255… ragelink 49 @pytest.fixture
c588255… ragelink 50 def release_asset(release, admin_user, tmp_path, settings):
c588255… ragelink 51 settings.STORAGES = _TEST_STORAGES
c588255… ragelink 52 settings.MEDIA_ROOT = str(tmp_path / "media")
c588255… ragelink 53 uploaded = SimpleUploadedFile("app-v1.0.0.tar.gz", b"fake-tarball-content", content_type="application/gzip")
c588255… ragelink 54 return ReleaseAsset.objects.create(
c588255… ragelink 55 release=release,
c588255… ragelink 56 name="app-v1.0.0.tar.gz",
c588255… ragelink 57 file=uploaded,
c588255… ragelink 58 file_size_bytes=len(b"fake-tarball-content"),
c588255… ragelink 59 content_type="application/gzip",
c588255… ragelink 60 created_by=admin_user,
c588255… ragelink 61 )
c588255… ragelink 62
c588255… ragelink 63
c2dd86c… ragelink 64 @pytest.fixture
c2dd86c… ragelink 65 def draft_release_asset(draft_release, admin_user, tmp_path, settings):
c2dd86c… ragelink 66 settings.STORAGES = _TEST_STORAGES
c2dd86c… ragelink 67 settings.MEDIA_ROOT = str(tmp_path / "media")
c2dd86c… ragelink 68 uploaded = SimpleUploadedFile("beta-build.tar.gz", b"draft-content", content_type="application/gzip")
c2dd86c… ragelink 69 return ReleaseAsset.objects.create(
c2dd86c… ragelink 70 release=draft_release,
c2dd86c… ragelink 71 name="beta-build.tar.gz",
c2dd86c… ragelink 72 file=uploaded,
c2dd86c… ragelink 73 file_size_bytes=len(b"draft-content"),
c2dd86c… ragelink 74 content_type="application/gzip",
c2dd86c… ragelink 75 created_by=admin_user,
c2dd86c… ragelink 76 )
c2dd86c… ragelink 77
c2dd86c… ragelink 78
c588255… ragelink 79 @pytest.mark.django_db
c588255… ragelink 80 class TestReleaseModel:
c588255… ragelink 81 def test_create_release(self, release):
c588255… ragelink 82 assert release.pk is not None
c588255… ragelink 83 assert str(release) == "v1.0.0: Version 1.0.0"
c588255… ragelink 84
c588255… ragelink 85 def test_unique_tag_per_repo(self, fossil_repo_obj, admin_user):
c588255… ragelink 86 Release.objects.create(
c588255… ragelink 87 repository=fossil_repo_obj,
c588255… ragelink 88 tag_name="v3.0.0",
c588255… ragelink 89 name="First",
c588255… ragelink 90 created_by=admin_user,
c588255… ragelink 91 )
c588255… ragelink 92 from django.db import IntegrityError
c588255… ragelink 93
c588255… ragelink 94 with pytest.raises(IntegrityError):
c588255… ragelink 95 Release.objects.create(
c588255… ragelink 96 repository=fossil_repo_obj,
c588255… ragelink 97 tag_name="v3.0.0",
c588255… ragelink 98 name="Duplicate",
c588255… ragelink 99 created_by=admin_user,
c588255… ragelink 100 )
c588255… ragelink 101
c588255… ragelink 102 def test_soft_delete(self, release, admin_user):
c588255… ragelink 103 release.soft_delete(user=admin_user)
c588255… ragelink 104 assert release.is_deleted
c588255… ragelink 105 assert Release.objects.filter(pk=release.pk).count() == 0
c588255… ragelink 106 assert Release.all_objects.filter(pk=release.pk).count() == 1
c588255… ragelink 107
c588255… ragelink 108 def test_ordering(self, fossil_repo_obj, admin_user):
c588255… ragelink 109 r1 = Release.objects.create(
c588255… ragelink 110 repository=fossil_repo_obj,
c588255… ragelink 111 tag_name="v0.1.0",
c588255… ragelink 112 name="Old",
c588255… ragelink 113 published_at="2025-01-01T00:00:00Z",
c588255… ragelink 114 created_by=admin_user,
c588255… ragelink 115 )
c588255… ragelink 116 r2 = Release.objects.create(
c588255… ragelink 117 repository=fossil_repo_obj,
c588255… ragelink 118 tag_name="v0.2.0",
c588255… ragelink 119 name="Newer",
c588255… ragelink 120 published_at="2026-06-01T00:00:00Z",
c588255… ragelink 121 created_by=admin_user,
c588255… ragelink 122 )
c588255… ragelink 123 releases = list(Release.objects.filter(repository=fossil_repo_obj))
c588255… ragelink 124 assert releases[0] == r2
c588255… ragelink 125 assert releases[-1] == r1
c588255… ragelink 126
c588255… ragelink 127
c588255… ragelink 128 @pytest.mark.django_db
c588255… ragelink 129 class TestReleaseAssetModel:
c588255… ragelink 130 def test_create_asset(self, release_asset):
c588255… ragelink 131 assert release_asset.pk is not None
c588255… ragelink 132 assert str(release_asset) == "app-v1.0.0.tar.gz"
c588255… ragelink 133 assert release_asset.file_size_bytes == len(b"fake-tarball-content")
c588255… ragelink 134
c588255… ragelink 135 def test_soft_delete(self, release_asset, admin_user):
c588255… ragelink 136 release_asset.soft_delete(user=admin_user)
c588255… ragelink 137 assert release_asset.is_deleted
c588255… ragelink 138 assert ReleaseAsset.objects.filter(pk=release_asset.pk).count() == 0
c588255… ragelink 139 assert ReleaseAsset.all_objects.filter(pk=release_asset.pk).count() == 1
c588255… ragelink 140
c588255… ragelink 141
c588255… ragelink 142 @pytest.mark.django_db
c588255… ragelink 143 class TestReleaseListView:
c588255… ragelink 144 def test_list_releases(self, admin_client, sample_project, release):
c588255… ragelink 145 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/releases/")
c588255… ragelink 146 assert response.status_code == 200
c588255… ragelink 147 content = response.content.decode()
c588255… ragelink 148 assert "v1.0.0" in content
c588255… ragelink 149 assert "Version 1.0.0" in content
c588255… ragelink 150
c588255… ragelink 151 def test_list_empty(self, admin_client, sample_project, fossil_repo_obj):
c588255… ragelink 152 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/releases/")
c588255… ragelink 153 assert response.status_code == 200
c588255… ragelink 154 assert "No releases yet" in response.content.decode()
c588255… ragelink 155
c588255… ragelink 156 def test_drafts_hidden_from_non_writers(self, no_perm_client, sample_project, draft_release):
c588255… ragelink 157 # Make project public so no_perm_user can read it
c588255… ragelink 158 sample_project.visibility = "public"
c588255… ragelink 159 sample_project.save()
c588255… ragelink 160 response = no_perm_client.get(f"/projects/{sample_project.slug}/fossil/releases/")
c588255… ragelink 161 assert response.status_code == 200
c588255… ragelink 162 assert "v2.0.0-beta" not in response.content.decode()
c588255… ragelink 163
c588255… ragelink 164 def test_drafts_visible_to_writers(self, admin_client, sample_project, draft_release):
c588255… ragelink 165 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/releases/")
c588255… ragelink 166 assert response.status_code == 200
c588255… ragelink 167 assert "v2.0.0-beta" in response.content.decode()
c588255… ragelink 168
c588255… ragelink 169 def test_list_denied_for_no_perm_on_private(self, no_perm_client, sample_project):
c588255… ragelink 170 response = no_perm_client.get(f"/projects/{sample_project.slug}/fossil/releases/")
c588255… ragelink 171 assert response.status_code == 403
c588255… ragelink 172
c588255… ragelink 173
c588255… ragelink 174 @pytest.mark.django_db
c588255… ragelink 175 class TestReleaseDetailView:
c588255… ragelink 176 def test_detail(self, admin_client, sample_project, release):
c588255… ragelink 177 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/releases/{release.tag_name}/")
c588255… ragelink 178 assert response.status_code == 200
c588255… ragelink 179 content = response.content.decode()
c588255… ragelink 180 assert "v1.0.0" in content
c588255… ragelink 181 assert "Changelog" in content
c588255… ragelink 182
c588255… ragelink 183 def test_detail_with_assets(self, admin_client, sample_project, release, release_asset):
c588255… ragelink 184 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/releases/{release.tag_name}/")
c588255… ragelink 185 assert response.status_code == 200
c588255… ragelink 186 content = response.content.decode()
c588255… ragelink 187 assert "app-v1.0.0.tar.gz" in content
c588255… ragelink 188 assert "Download" in content
c588255… ragelink 189
c588255… ragelink 190 def test_draft_detail_denied_for_non_writer(self, no_perm_client, sample_project, draft_release):
c588255… ragelink 191 sample_project.visibility = "public"
c588255… ragelink 192 sample_project.save()
c588255… ragelink 193 response = no_perm_client.get(f"/projects/{sample_project.slug}/fossil/releases/{draft_release.tag_name}/")
c588255… ragelink 194 assert response.status_code == 403
c588255… ragelink 195
c588255… ragelink 196 def test_detail_denied_for_no_perm_on_private(self, no_perm_client, sample_project, release):
c588255… ragelink 197 response = no_perm_client.get(f"/projects/{sample_project.slug}/fossil/releases/{release.tag_name}/")
c588255… ragelink 198 assert response.status_code == 403
c588255… ragelink 199
c588255… ragelink 200
c588255… ragelink 201 @pytest.mark.django_db
c588255… ragelink 202 class TestReleaseCreateView:
c588255… ragelink 203 def test_get_form(self, admin_client, sample_project):
c588255… ragelink 204 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/releases/create/")
c588255… ragelink 205 assert response.status_code == 200
c588255… ragelink 206 assert "Create Release" in response.content.decode()
c588255… ragelink 207
c588255… ragelink 208 def test_create_release(self, admin_client, sample_project, fossil_repo_obj):
c588255… ragelink 209 response = admin_client.post(
c588255… ragelink 210 f"/projects/{sample_project.slug}/fossil/releases/create/",
c588255… ragelink 211 {"tag_name": "v5.0.0", "name": "Big Release", "body": "notes", "is_prerelease": "", "is_draft": ""},
c588255… ragelink 212 )
c588255… ragelink 213 assert response.status_code == 302
c588255… ragelink 214 release = Release.objects.get(tag_name="v5.0.0")
c588255… ragelink 215 assert release.name == "Big Release"
c588255… ragelink 216 assert release.published_at is not None
c588255… ragelink 217 assert release.is_draft is False
c588255… ragelink 218
c588255… ragelink 219 def test_create_draft_release(self, admin_client, sample_project, fossil_repo_obj):
c588255… ragelink 220 response = admin_client.post(
c588255… ragelink 221 f"/projects/{sample_project.slug}/fossil/releases/create/",
c588255… ragelink 222 {"tag_name": "v6.0.0-rc1", "name": "RC", "body": "", "is_draft": "on"},
c588255… ragelink 223 )
c588255… ragelink 224 assert response.status_code == 302
c588255… ragelink 225 release = Release.objects.get(tag_name="v6.0.0-rc1")
c588255… ragelink 226 assert release.is_draft is True
c588255… ragelink 227 assert release.published_at is None
c588255… ragelink 228
c588255… ragelink 229 def test_create_denied_for_no_perm(self, no_perm_client, sample_project):
c588255… ragelink 230 response = no_perm_client.post(
c588255… ragelink 231 f"/projects/{sample_project.slug}/fossil/releases/create/",
c588255… ragelink 232 {"tag_name": "v9.0.0", "name": "Nope"},
c588255… ragelink 233 )
c588255… ragelink 234 assert response.status_code == 403
c588255… ragelink 235
c588255… ragelink 236 def test_create_denied_for_anon(self, client, sample_project):
c588255… ragelink 237 response = client.get(f"/projects/{sample_project.slug}/fossil/releases/create/")
c588255… ragelink 238 assert response.status_code == 302 # redirect to login
c588255… ragelink 239
c588255… ragelink 240
c588255… ragelink 241 @pytest.mark.django_db
c588255… ragelink 242 class TestReleaseEditView:
c588255… ragelink 243 def test_get_edit_form(self, admin_client, sample_project, release):
c588255… ragelink 244 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/releases/{release.tag_name}/edit/")
c588255… ragelink 245 assert response.status_code == 200
c588255… ragelink 246 content = response.content.decode()
c588255… ragelink 247 assert "v1.0.0" in content
c588255… ragelink 248 assert "Update Release" in content
c588255… ragelink 249
c588255… ragelink 250 def test_edit_release(self, admin_client, sample_project, release):
c588255… ragelink 251 response = admin_client.post(
c588255… ragelink 252 f"/projects/{sample_project.slug}/fossil/releases/{release.tag_name}/edit/",
c588255… ragelink 253 {"tag_name": "v1.0.1", "name": "Patched", "body": "fix", "is_prerelease": ""},
c588255… ragelink 254 )
c588255… ragelink 255 assert response.status_code == 302
c588255… ragelink 256 release.refresh_from_db()
c588255… ragelink 257 assert release.tag_name == "v1.0.1"
c588255… ragelink 258 assert release.name == "Patched"
c588255… ragelink 259
c588255… ragelink 260 def test_edit_denied_for_no_perm(self, no_perm_client, sample_project, release):
c588255… ragelink 261 response = no_perm_client.post(
c588255… ragelink 262 f"/projects/{sample_project.slug}/fossil/releases/{release.tag_name}/edit/",
c588255… ragelink 263 {"tag_name": "v1.0.1", "name": "Hacked"},
c588255… ragelink 264 )
c588255… ragelink 265 assert response.status_code == 403
c588255… ragelink 266
c588255… ragelink 267
c588255… ragelink 268 @pytest.mark.django_db
c588255… ragelink 269 class TestReleaseDeleteView:
c588255… ragelink 270 def test_delete_release(self, admin_client, sample_project, release):
c588255… ragelink 271 response = admin_client.post(f"/projects/{sample_project.slug}/fossil/releases/{release.tag_name}/delete/")
c588255… ragelink 272 assert response.status_code == 302
c588255… ragelink 273 release.refresh_from_db()
c588255… ragelink 274 assert release.is_deleted
c588255… ragelink 275
c588255… ragelink 276 def test_delete_get_redirects(self, admin_client, sample_project, release):
c588255… ragelink 277 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/releases/{release.tag_name}/delete/")
c588255… ragelink 278 assert response.status_code == 302 # GET redirects to detail
c588255… ragelink 279
c588255… ragelink 280 def test_delete_denied_for_writer(self, admin_client, sample_project, release, admin_user):
c588255… ragelink 281 """Delete requires admin, not just write. Admin user is superuser so they have admin.
c588255… ragelink 282 We test with a write-only user instead."""
c588255… ragelink 283 from django.contrib.auth.models import User
c588255… ragelink 284
c588255… ragelink 285 from organization.models import Team
c588255… ragelink 286 from projects.models import ProjectTeam
c588255… ragelink 287
c588255… ragelink 288 writer = User.objects.create_user(username="writer", password="testpass123")
c588255… ragelink 289 team = Team.objects.create(name="Writers", organization=sample_project.organization, created_by=admin_user)
c588255… ragelink 290 team.members.add(writer)
c588255… ragelink 291 ProjectTeam.objects.create(project=sample_project, team=team, role="write", created_by=admin_user)
c588255… ragelink 292
c588255… ragelink 293 from django.test import Client
c588255… ragelink 294
c588255… ragelink 295 writer_client = Client()
c588255… ragelink 296 writer_client.login(username="writer", password="testpass123")
c588255… ragelink 297
c588255… ragelink 298 response = writer_client.post(f"/projects/{sample_project.slug}/fossil/releases/{release.tag_name}/delete/")
c588255… ragelink 299 assert response.status_code == 403
c588255… ragelink 300
c588255… ragelink 301
c588255… ragelink 302 @pytest.mark.django_db
c588255… ragelink 303 class TestReleaseAssetUploadView:
c588255… ragelink 304 def test_upload_asset(self, admin_client, sample_project, release, tmp_path, settings):
c588255… ragelink 305 settings.STORAGES = _TEST_STORAGES
c588255… ragelink 306 settings.MEDIA_ROOT = str(tmp_path / "media")
c588255… ragelink 307 fake_file = SimpleUploadedFile("binary.zip", b"zipdata", content_type="application/zip")
c588255… ragelink 308 response = admin_client.post(
c588255… ragelink 309 f"/projects/{sample_project.slug}/fossil/releases/{release.tag_name}/upload/",
c588255… ragelink 310 {"file": fake_file},
c588255… ragelink 311 )
c588255… ragelink 312 assert response.status_code == 302
c588255… ragelink 313 asset = ReleaseAsset.objects.get(release=release, name="binary.zip")
c588255… ragelink 314 assert asset.file_size_bytes == len(b"zipdata")
c588255… ragelink 315 assert asset.content_type == "application/zip"
c588255… ragelink 316
c588255… ragelink 317 def test_upload_denied_for_no_perm(self, no_perm_client, sample_project, release):
c588255… ragelink 318 fake_file = SimpleUploadedFile("evil.zip", b"data", content_type="application/zip")
c588255… ragelink 319 response = no_perm_client.post(
c588255… ragelink 320 f"/projects/{sample_project.slug}/fossil/releases/{release.tag_name}/upload/",
c588255… ragelink 321 {"file": fake_file},
c588255… ragelink 322 )
c588255… ragelink 323 assert response.status_code == 403
c588255… ragelink 324
c588255… ragelink 325
c588255… ragelink 326 @pytest.mark.django_db
c588255… ragelink 327 class TestReleaseAssetDownloadView:
c588255… ragelink 328 def test_download_asset(self, admin_client, sample_project, release, release_asset):
c588255… ragelink 329 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/releases/{release.tag_name}/assets/{release_asset.pk}/")
c588255… ragelink 330 assert response.status_code == 200
c588255… ragelink 331 assert response["Content-Disposition"]
c588255… ragelink 332 # Verify download count incremented
c588255… ragelink 333 release_asset.refresh_from_db()
c588255… ragelink 334 assert release_asset.download_count == 1
c588255… ragelink 335
c588255… ragelink 336 def test_download_increments_count(self, admin_client, sample_project, release, release_asset):
c588255… ragelink 337 url = f"/projects/{sample_project.slug}/fossil/releases/{release.tag_name}/assets/{release_asset.pk}/"
c588255… ragelink 338 admin_client.get(url)
c588255… ragelink 339 admin_client.get(url)
c588255… ragelink 340 release_asset.refresh_from_db()
c588255… ragelink 341 assert release_asset.download_count == 2
c588255… ragelink 342
c588255… ragelink 343 def test_download_denied_on_private_for_no_perm(self, no_perm_client, sample_project, release, release_asset):
c588255… ragelink 344 response = no_perm_client.get(f"/projects/{sample_project.slug}/fossil/releases/{release.tag_name}/assets/{release_asset.pk}/")
c2dd86c… ragelink 345 assert response.status_code == 403
c2dd86c… ragelink 346
c2dd86c… ragelink 347 def test_draft_asset_download_denied_for_non_writer(self, no_perm_client, sample_project, draft_release, draft_release_asset):
c2dd86c… ragelink 348 """Non-writers cannot download assets from draft releases even on public projects."""
c2dd86c… ragelink 349 sample_project.visibility = "public"
c2dd86c… ragelink 350 sample_project.save()
c2dd86c… ragelink 351 response = no_perm_client.get(
c2dd86c… ragelink 352 f"/projects/{sample_project.slug}/fossil/releases/{draft_release.tag_name}/assets/{draft_release_asset.pk}/"
c2dd86c… ragelink 353 )
c2dd86c… ragelink 354 assert response.status_code == 403
c2dd86c… ragelink 355
c2dd86c… ragelink 356 def test_draft_asset_download_allowed_for_writer(self, admin_client, sample_project, draft_release, draft_release_asset):
c2dd86c… ragelink 357 """Writers can download assets from draft releases."""
c2dd86c… ragelink 358 response = admin_client.get(
c2dd86c… ragelink 359 f"/projects/{sample_project.slug}/fossil/releases/{draft_release.tag_name}/assets/{draft_release_asset.pk}/"
c2dd86c… ragelink 360 )
c2dd86c… ragelink 361 assert response.status_code == 200
c2dd86c… ragelink 362
c2dd86c… ragelink 363
c2dd86c… ragelink 364 @pytest.mark.django_db
c2dd86c… ragelink 365 class TestDraftSourceArchiveAccess:
c2dd86c… ragelink 366 def test_draft_source_archive_denied_for_non_writer(self, no_perm_client, sample_project, draft_release):
c2dd86c… ragelink 367 """Non-writers cannot download source archives for draft releases."""
c2dd86c… ragelink 368 sample_project.visibility = "public"
c2dd86c… ragelink 369 sample_project.save()
313537c… ragelink 370 response = no_perm_client.get(f"/projects/{sample_project.slug}/fossil/releases/{draft_release.tag_name}/source.tar.gz")
c588255… ragelink 371 assert response.status_code == 403

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button