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