FossilRepo

fossilrepo / tests / test_anonymous_access.py
Blame History Raw 464 lines
1
"""Tests for anonymous (unauthenticated) access to public projects.
2
3
Verifies that:
4
- Anonymous users can browse public project listings, details, and fossil views.
5
- Anonymous users are denied access to private projects.
6
- Anonymous users are denied write operations even on public projects.
7
- Authenticated users retain full access as before.
8
"""
9
10
from unittest.mock import MagicMock, PropertyMock, patch
11
12
import pytest
13
from django.contrib.auth.models import User
14
from django.test import Client
15
16
from fossil.models import FossilRepository
17
from organization.models import Team
18
from pages.models import Page
19
from projects.models import Project, ProjectTeam
20
21
# ---------------------------------------------------------------------------
22
# Fixtures
23
# ---------------------------------------------------------------------------
24
25
26
@pytest.fixture
27
def anon_client():
28
"""Unauthenticated client."""
29
return Client()
30
31
32
@pytest.fixture
33
def public_project(db, org, admin_user, sample_team):
34
"""A public project visible to anonymous users."""
35
project = Project.objects.create(
36
name="Public Repo",
37
organization=org,
38
visibility="public",
39
created_by=admin_user,
40
)
41
ProjectTeam.objects.create(project=project, team=sample_team, role="write", created_by=admin_user)
42
return project
43
44
45
@pytest.fixture
46
def internal_project(db, org, admin_user, sample_team):
47
"""An internal project visible only to authenticated users."""
48
project = Project.objects.create(
49
name="Internal Repo",
50
organization=org,
51
visibility="internal",
52
created_by=admin_user,
53
)
54
ProjectTeam.objects.create(project=project, team=sample_team, role="write", created_by=admin_user)
55
return project
56
57
58
@pytest.fixture
59
def private_project(sample_project):
60
"""The default sample_project is private."""
61
return sample_project
62
63
64
@pytest.fixture
65
def published_page(db, org, admin_user):
66
"""A published knowledge base page."""
67
return Page.objects.create(
68
name="Public Guide",
69
content="# Public Guide\n\nThis is visible to everyone.",
70
organization=org,
71
is_published=True,
72
created_by=admin_user,
73
)
74
75
76
@pytest.fixture
77
def draft_page(db, org, admin_user):
78
"""An unpublished draft page."""
79
return Page.objects.create(
80
name="Draft Guide",
81
content="# Draft\n\nThis is a draft.",
82
organization=org,
83
is_published=False,
84
created_by=admin_user,
85
)
86
87
88
@pytest.fixture
89
def public_fossil_repo(public_project):
90
"""Return the auto-created FossilRepository for the public project."""
91
return FossilRepository.objects.get(project=public_project, deleted_at__isnull=True)
92
93
94
@pytest.fixture
95
def private_fossil_repo(private_project):
96
"""Return the auto-created FossilRepository for the private project."""
97
return FossilRepository.objects.get(project=private_project, deleted_at__isnull=True)
98
99
100
@pytest.fixture
101
def writer_for_public(db, admin_user, public_project):
102
"""User with write access to the public project."""
103
writer = User.objects.create_user(username="pub_writer", password="testpass123")
104
team = Team.objects.create(name="Pub Writers", organization=public_project.organization, created_by=admin_user)
105
team.members.add(writer)
106
ProjectTeam.objects.create(project=public_project, team=team, role="write", created_by=admin_user)
107
return writer
108
109
110
@pytest.fixture
111
def writer_client_for_public(writer_for_public):
112
client = Client()
113
client.login(username="pub_writer", password="testpass123")
114
return client
115
116
117
# ---------------------------------------------------------------------------
118
# Helper: mock FossilReader for views that open the .fossil file
119
# ---------------------------------------------------------------------------
120
121
122
def _mock_fossil_reader():
123
"""Return a context-manager mock that satisfies _get_repo_and_reader."""
124
reader = MagicMock()
125
reader.__enter__ = MagicMock(return_value=reader)
126
reader.__exit__ = MagicMock(return_value=False)
127
reader.get_latest_checkin_uuid.return_value = "abc123"
128
reader.get_files_at_checkin.return_value = []
129
reader.get_metadata.return_value = MagicMock(
130
checkin_count=5, project_name="Test", project_code="abc", ticket_count=0, wiki_page_count=0
131
)
132
reader.get_timeline.return_value = []
133
reader.get_tickets.return_value = []
134
reader.get_wiki_pages.return_value = []
135
reader.get_wiki_page.return_value = None
136
reader.get_branches.return_value = []
137
reader.get_tags.return_value = []
138
reader.get_technotes.return_value = []
139
reader.get_forum_posts.return_value = []
140
reader.get_unversioned_files.return_value = []
141
reader.get_commit_activity.return_value = []
142
reader.get_top_contributors.return_value = []
143
reader.get_repo_statistics.return_value = {}
144
reader.search.return_value = []
145
reader.get_checkin_count.return_value = 5
146
return reader
147
148
149
def _patch_fossil_on_disk():
150
"""Patch exists_on_disk to True and FossilReader to our mock."""
151
reader = _mock_fossil_reader()
152
return (
153
patch.object(FossilRepository, "exists_on_disk", new_callable=PropertyMock, return_value=True),
154
patch("fossil.views.FossilReader", return_value=reader),
155
reader,
156
)
157
158
159
# ===========================================================================
160
# Project List
161
# ===========================================================================
162
163
164
@pytest.mark.django_db
165
class TestAnonymousProjectList:
166
def test_anonymous_sees_public_projects(self, anon_client, public_project):
167
response = anon_client.get("/projects/")
168
assert response.status_code == 200
169
assert public_project.name in response.content.decode()
170
171
def test_anonymous_does_not_see_private_projects(self, anon_client, private_project, public_project):
172
response = anon_client.get("/projects/")
173
assert response.status_code == 200
174
body = response.content.decode()
175
assert public_project.name in body
176
assert private_project.name not in body
177
178
def test_anonymous_does_not_see_internal_projects(self, anon_client, internal_project, public_project):
179
response = anon_client.get("/projects/")
180
assert response.status_code == 200
181
body = response.content.decode()
182
assert public_project.name in body
183
assert internal_project.name not in body
184
185
def test_authenticated_sees_all_projects(self, admin_client, public_project, private_project, internal_project):
186
response = admin_client.get("/projects/")
187
assert response.status_code == 200
188
body = response.content.decode()
189
assert public_project.name in body
190
assert private_project.name in body
191
assert internal_project.name in body
192
193
194
# ===========================================================================
195
# Project Detail
196
# ===========================================================================
197
198
199
@pytest.mark.django_db
200
class TestAnonymousProjectDetail:
201
def test_anonymous_can_view_public_project(self, anon_client, public_project):
202
response = anon_client.get(f"/projects/{public_project.slug}/")
203
assert response.status_code == 200
204
assert public_project.name in response.content.decode()
205
206
def test_anonymous_denied_private_project(self, anon_client, private_project):
207
response = anon_client.get(f"/projects/{private_project.slug}/")
208
assert response.status_code == 403
209
210
def test_anonymous_denied_internal_project(self, anon_client, internal_project):
211
response = anon_client.get(f"/projects/{internal_project.slug}/")
212
assert response.status_code == 403
213
214
def test_authenticated_can_view_private_project(self, admin_client, private_project):
215
response = admin_client.get(f"/projects/{private_project.slug}/")
216
assert response.status_code == 200
217
218
219
# ===========================================================================
220
# Code Browser (fossil view, needs .fossil file mock)
221
# ===========================================================================
222
223
224
@pytest.mark.django_db
225
class TestAnonymousCodeBrowser:
226
def test_anonymous_can_view_public_code_browser(self, anon_client, public_project, public_fossil_repo):
227
disk_patch, reader_patch, reader = _patch_fossil_on_disk()
228
with disk_patch, reader_patch:
229
response = anon_client.get(f"/projects/{public_project.slug}/fossil/code/")
230
assert response.status_code == 200
231
232
def test_anonymous_denied_private_code_browser(self, anon_client, private_project, private_fossil_repo):
233
disk_patch, reader_patch, reader = _patch_fossil_on_disk()
234
with disk_patch, reader_patch:
235
response = anon_client.get(f"/projects/{private_project.slug}/fossil/code/")
236
assert response.status_code == 403
237
238
239
# ===========================================================================
240
# Timeline
241
# ===========================================================================
242
243
244
@pytest.mark.django_db
245
class TestAnonymousTimeline:
246
def test_anonymous_can_view_public_timeline(self, anon_client, public_project, public_fossil_repo):
247
disk_patch, reader_patch, reader = _patch_fossil_on_disk()
248
with disk_patch, reader_patch:
249
response = anon_client.get(f"/projects/{public_project.slug}/fossil/timeline/")
250
assert response.status_code == 200
251
252
def test_anonymous_denied_private_timeline(self, anon_client, private_project, private_fossil_repo):
253
disk_patch, reader_patch, reader = _patch_fossil_on_disk()
254
with disk_patch, reader_patch:
255
response = anon_client.get(f"/projects/{private_project.slug}/fossil/timeline/")
256
assert response.status_code == 403
257
258
259
# ===========================================================================
260
# Tickets
261
# ===========================================================================
262
263
264
@pytest.mark.django_db
265
class TestAnonymousTickets:
266
def test_anonymous_can_view_public_ticket_list(self, anon_client, public_project, public_fossil_repo):
267
disk_patch, reader_patch, reader = _patch_fossil_on_disk()
268
with disk_patch, reader_patch:
269
response = anon_client.get(f"/projects/{public_project.slug}/fossil/tickets/")
270
assert response.status_code == 200
271
272
def test_anonymous_denied_private_ticket_list(self, anon_client, private_project, private_fossil_repo):
273
disk_patch, reader_patch, reader = _patch_fossil_on_disk()
274
with disk_patch, reader_patch:
275
response = anon_client.get(f"/projects/{private_project.slug}/fossil/tickets/")
276
assert response.status_code == 403
277
278
279
# ===========================================================================
280
# Write operations require login on public projects
281
# ===========================================================================
282
283
284
@pytest.mark.django_db
285
class TestAnonymousWriteDenied:
286
"""Write operations must redirect anonymous users to login, even on public projects."""
287
288
def test_anonymous_cannot_create_ticket(self, anon_client, public_project):
289
response = anon_client.get(f"/projects/{public_project.slug}/fossil/tickets/create/")
290
# @login_required redirects to login
291
assert response.status_code == 302
292
assert "/auth/login/" in response.url
293
294
def test_anonymous_cannot_create_wiki(self, anon_client, public_project):
295
response = anon_client.get(f"/projects/{public_project.slug}/fossil/wiki/create/")
296
assert response.status_code == 302
297
assert "/auth/login/" in response.url
298
299
def test_anonymous_cannot_create_forum_thread(self, anon_client, public_project):
300
response = anon_client.get(f"/projects/{public_project.slug}/fossil/forum/create/")
301
assert response.status_code == 302
302
assert "/auth/login/" in response.url
303
304
def test_anonymous_cannot_create_release(self, anon_client, public_project):
305
response = anon_client.get(f"/projects/{public_project.slug}/fossil/releases/create/")
306
assert response.status_code == 302
307
assert "/auth/login/" in response.url
308
309
def test_anonymous_cannot_create_project(self, anon_client):
310
response = anon_client.get("/projects/create/")
311
assert response.status_code == 302
312
assert "/auth/login/" in response.url
313
314
def test_anonymous_cannot_access_repo_settings(self, anon_client, public_project):
315
response = anon_client.get(f"/projects/{public_project.slug}/fossil/settings/")
316
assert response.status_code == 302
317
assert "/auth/login/" in response.url
318
319
def test_anonymous_cannot_access_sync(self, anon_client, public_project):
320
response = anon_client.get(f"/projects/{public_project.slug}/fossil/sync/")
321
assert response.status_code == 302
322
assert "/auth/login/" in response.url
323
324
def test_anonymous_cannot_toggle_watch(self, anon_client, public_project):
325
response = anon_client.post(f"/projects/{public_project.slug}/fossil/watch/")
326
assert response.status_code == 302
327
assert "/auth/login/" in response.url
328
329
330
# ===========================================================================
331
# Additional read-only fossil views on public projects
332
# ===========================================================================
333
334
335
@pytest.mark.django_db
336
class TestAnonymousReadOnlyFossilViews:
337
"""Test that various read-only fossil views allow anonymous access on public projects."""
338
339
def test_branches(self, anon_client, public_project, public_fossil_repo):
340
disk_patch, reader_patch, _ = _patch_fossil_on_disk()
341
with disk_patch, reader_patch:
342
response = anon_client.get(f"/projects/{public_project.slug}/fossil/branches/")
343
assert response.status_code == 200
344
345
def test_tags(self, anon_client, public_project, public_fossil_repo):
346
disk_patch, reader_patch, _ = _patch_fossil_on_disk()
347
with disk_patch, reader_patch:
348
response = anon_client.get(f"/projects/{public_project.slug}/fossil/tags/")
349
assert response.status_code == 200
350
351
def test_stats(self, anon_client, public_project, public_fossil_repo):
352
disk_patch, reader_patch, _ = _patch_fossil_on_disk()
353
with disk_patch, reader_patch:
354
response = anon_client.get(f"/projects/{public_project.slug}/fossil/stats/")
355
assert response.status_code == 200
356
357
def test_search(self, anon_client, public_project, public_fossil_repo):
358
disk_patch, reader_patch, _ = _patch_fossil_on_disk()
359
with disk_patch, reader_patch:
360
response = anon_client.get(f"/projects/{public_project.slug}/fossil/search/")
361
assert response.status_code == 200
362
363
def test_wiki(self, anon_client, public_project, public_fossil_repo):
364
disk_patch, reader_patch, _ = _patch_fossil_on_disk()
365
with disk_patch, reader_patch:
366
response = anon_client.get(f"/projects/{public_project.slug}/fossil/wiki/")
367
assert response.status_code == 200
368
369
def test_releases(self, anon_client, public_project, public_fossil_repo):
370
disk_patch, reader_patch, _ = _patch_fossil_on_disk()
371
with disk_patch, reader_patch:
372
response = anon_client.get(f"/projects/{public_project.slug}/fossil/releases/")
373
assert response.status_code == 200
374
375
def test_technotes(self, anon_client, public_project, public_fossil_repo):
376
disk_patch, reader_patch, _ = _patch_fossil_on_disk()
377
with disk_patch, reader_patch:
378
response = anon_client.get(f"/projects/{public_project.slug}/fossil/technotes/")
379
assert response.status_code == 200
380
381
def test_unversioned(self, anon_client, public_project, public_fossil_repo):
382
disk_patch, reader_patch, _ = _patch_fossil_on_disk()
383
with disk_patch, reader_patch:
384
response = anon_client.get(f"/projects/{public_project.slug}/fossil/files/")
385
assert response.status_code == 200
386
387
388
# ===========================================================================
389
# Pages (knowledge base)
390
# ===========================================================================
391
392
393
@pytest.mark.django_db
394
class TestAnonymousPages:
395
def test_anonymous_can_view_published_page_list(self, anon_client, published_page):
396
response = anon_client.get("/kb/")
397
assert response.status_code == 200
398
assert published_page.name in response.content.decode()
399
400
def test_anonymous_cannot_see_draft_pages_in_list(self, anon_client, published_page, draft_page):
401
response = anon_client.get("/kb/")
402
assert response.status_code == 200
403
body = response.content.decode()
404
assert published_page.name in body
405
assert draft_page.name not in body
406
407
def test_anonymous_can_view_published_page_detail(self, anon_client, published_page):
408
response = anon_client.get(f"/kb/{published_page.slug}/")
409
assert response.status_code == 200
410
assert published_page.name in response.content.decode()
411
412
def test_anonymous_denied_draft_page_detail(self, anon_client, draft_page):
413
response = anon_client.get(f"/kb/{draft_page.slug}/")
414
assert response.status_code == 403
415
416
def test_authenticated_can_view_published_page(self, admin_client, published_page):
417
response = admin_client.get(f"/kb/{published_page.slug}/")
418
assert response.status_code == 200
419
420
def test_anonymous_cannot_create_page(self, anon_client):
421
response = anon_client.get("/kb/create/")
422
assert response.status_code == 302
423
assert "/auth/login/" in response.url
424
425
426
# ===========================================================================
427
# Explore page (already worked for anonymous)
428
# ===========================================================================
429
430
431
@pytest.mark.django_db
432
class TestAnonymousExplore:
433
def test_anonymous_can_access_explore(self, anon_client, public_project):
434
response = anon_client.get("/explore/")
435
assert response.status_code == 200
436
assert public_project.name in response.content.decode()
437
438
def test_anonymous_explore_hides_private(self, anon_client, public_project, private_project):
439
response = anon_client.get("/explore/")
440
assert response.status_code == 200
441
body = response.content.decode()
442
assert public_project.name in body
443
assert private_project.name not in body
444
445
446
# ===========================================================================
447
# Forum (read-only)
448
# ===========================================================================
449
450
451
@pytest.mark.django_db
452
class TestAnonymousForum:
453
def test_anonymous_can_view_forum_list(self, anon_client, public_project, public_fossil_repo):
454
disk_patch, reader_patch, _ = _patch_fossil_on_disk()
455
with disk_patch, reader_patch:
456
response = anon_client.get(f"/projects/{public_project.slug}/fossil/forum/")
457
assert response.status_code == 200
458
459
def test_anonymous_denied_private_forum(self, anon_client, private_project, private_fossil_repo):
460
disk_patch, reader_patch, _ = _patch_fossil_on_disk()
461
with disk_patch, reader_patch:
462
response = anon_client.get(f"/projects/{private_project.slug}/fossil/forum/")
463
assert response.status_code == 403
464

Keyboard Shortcuts

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