FossilRepo

fossilrepo / tests / test_git_mirrors.py
Blame History Raw 383 lines
1
"""Tests for Git mirror multi-remote sync UI views."""
2
3
from unittest.mock import MagicMock, patch
4
5
import pytest
6
from django.contrib.auth.models import User
7
from django.test import Client
8
9
from fossil.models import FossilRepository
10
from fossil.sync_models import GitMirror
11
from organization.models import Team
12
from projects.models import ProjectTeam
13
14
# Reusable patch that makes FossilRepository.exists_on_disk return True
15
_disk_patch = patch("fossil.models.FossilRepository.exists_on_disk", new_callable=lambda: property(lambda self: True))
16
17
18
def _make_reader_mock(**methods):
19
"""Create a MagicMock that replaces FossilReader as a class."""
20
mock_cls = MagicMock()
21
instance = MagicMock()
22
mock_cls.return_value = instance
23
instance.__enter__ = MagicMock(return_value=instance)
24
instance.__exit__ = MagicMock(return_value=False)
25
for name, val in methods.items():
26
getattr(instance, name).return_value = val
27
return mock_cls
28
29
30
@pytest.fixture
31
def fossil_repo_obj(sample_project):
32
"""Return the auto-created FossilRepository for sample_project."""
33
return FossilRepository.objects.get(project=sample_project, deleted_at__isnull=True)
34
35
36
@pytest.fixture
37
def mirror(fossil_repo_obj, admin_user):
38
return GitMirror.objects.create(
39
repository=fossil_repo_obj,
40
git_remote_url="https://github.com/org/repo.git",
41
auth_method="token",
42
auth_credential="ghp_test123",
43
sync_direction="push",
44
sync_mode="scheduled",
45
sync_schedule="*/15 * * * *",
46
git_branch="main",
47
fossil_branch="trunk",
48
created_by=admin_user,
49
)
50
51
52
@pytest.fixture
53
def second_mirror(fossil_repo_obj, admin_user):
54
return GitMirror.objects.create(
55
repository=fossil_repo_obj,
56
git_remote_url="https://gitlab.com/org/repo.git",
57
auth_method="oauth_gitlab",
58
sync_direction="both",
59
sync_mode="both",
60
sync_schedule="0 */6 * * *",
61
git_branch="main",
62
fossil_branch="trunk",
63
created_by=admin_user,
64
)
65
66
67
@pytest.fixture
68
def writer_user(db, admin_user, sample_project):
69
"""User with write access but not admin."""
70
writer = User.objects.create_user(username="mirror_writer", password="testpass123")
71
team = Team.objects.create(name="Mirror Writers", organization=sample_project.organization, created_by=admin_user)
72
team.members.add(writer)
73
ProjectTeam.objects.create(project=sample_project, team=team, role="write", created_by=admin_user)
74
return writer
75
76
77
@pytest.fixture
78
def writer_client(writer_user):
79
client = Client()
80
client.login(username="mirror_writer", password="testpass123")
81
return client
82
83
84
# --- GitMirror Model Tests ---
85
86
87
@pytest.mark.django_db
88
class TestGitMirrorModel:
89
def test_create_mirror(self, mirror):
90
assert mirror.pk is not None
91
assert mirror.git_remote_url == "https://github.com/org/repo.git"
92
assert mirror.sync_direction == "push"
93
94
def test_str_representation(self, mirror):
95
assert "github.com/org/repo.git" in str(mirror)
96
97
def test_soft_delete(self, mirror, admin_user):
98
mirror.soft_delete(user=admin_user)
99
assert mirror.is_deleted
100
assert GitMirror.objects.filter(pk=mirror.pk).count() == 0
101
assert GitMirror.all_objects.filter(pk=mirror.pk).count() == 1
102
103
def test_multiple_mirrors_per_repo(self, mirror, second_mirror, fossil_repo_obj):
104
mirrors = GitMirror.objects.filter(repository=fossil_repo_obj)
105
assert mirrors.count() == 2
106
107
def test_ordering(self, mirror, second_mirror):
108
"""Mirrors are ordered newest first."""
109
mirrors = list(GitMirror.objects.all())
110
assert mirrors[0] == second_mirror
111
assert mirrors[1] == mirror
112
113
114
# --- Sync Page (sync.html) showing mirrors ---
115
116
117
@pytest.mark.django_db
118
class TestSyncPageMirrorListing:
119
def test_sync_page_shows_mirrors(self, admin_client, sample_project, fossil_repo_obj, mirror, second_mirror):
120
mock = _make_reader_mock()
121
with _disk_patch, patch("fossil.views.FossilReader", mock):
122
response = admin_client.get(f"/projects/{sample_project.slug}/fossil/sync/")
123
assert response.status_code == 200
124
content = response.content.decode()
125
assert "github.com/org/repo.git" in content
126
assert "gitlab.com/org/repo.git" in content
127
assert "Git Mirrors" in content
128
129
def test_sync_page_shows_empty_state(self, admin_client, sample_project, fossil_repo_obj):
130
mock = _make_reader_mock()
131
with _disk_patch, patch("fossil.views.FossilReader", mock):
132
response = admin_client.get(f"/projects/{sample_project.slug}/fossil/sync/")
133
assert response.status_code == 200
134
content = response.content.decode()
135
assert "No Git mirrors configured" in content
136
137
def test_sync_page_shows_add_mirror_button(self, admin_client, sample_project, fossil_repo_obj):
138
mock = _make_reader_mock()
139
with _disk_patch, patch("fossil.views.FossilReader", mock):
140
response = admin_client.get(f"/projects/{sample_project.slug}/fossil/sync/")
141
assert response.status_code == 200
142
assert "Add Mirror" in response.content.decode()
143
144
def test_sync_page_shows_mirror_direction_badges(self, admin_client, sample_project, fossil_repo_obj, mirror, second_mirror):
145
mock = _make_reader_mock()
146
with _disk_patch, patch("fossil.views.FossilReader", mock):
147
response = admin_client.get(f"/projects/{sample_project.slug}/fossil/sync/")
148
content = response.content.decode()
149
# Check direction labels rendered
150
assert "Push" in content
151
assert "Bidirectional" in content
152
153
def test_sync_page_shows_edit_delete_links(self, admin_client, sample_project, fossil_repo_obj, mirror):
154
mock = _make_reader_mock()
155
with _disk_patch, patch("fossil.views.FossilReader", mock):
156
response = admin_client.get(f"/projects/{sample_project.slug}/fossil/sync/")
157
content = response.content.decode()
158
assert "Edit" in content
159
assert "Delete" in content
160
assert "Run Now" in content
161
162
def test_sync_page_denied_for_anon(self, client, sample_project):
163
response = client.get(f"/projects/{sample_project.slug}/fossil/sync/")
164
assert response.status_code == 302 # redirect to login
165
166
167
# --- Git Mirror Config View (Add) ---
168
169
170
@pytest.mark.django_db
171
class TestGitMirrorAddView:
172
def test_get_add_form(self, admin_client, sample_project, fossil_repo_obj):
173
mock = _make_reader_mock()
174
with _disk_patch, patch("fossil.views.FossilReader", mock):
175
response = admin_client.get(f"/projects/{sample_project.slug}/fossil/sync/git/")
176
assert response.status_code == 200
177
content = response.content.decode()
178
assert "Add Git Mirror" in content
179
assert "Quick Connect" in content
180
181
def test_create_mirror(self, admin_client, sample_project, fossil_repo_obj):
182
mock = _make_reader_mock()
183
with _disk_patch, patch("fossil.views.FossilReader", mock):
184
response = admin_client.post(
185
f"/projects/{sample_project.slug}/fossil/sync/git/",
186
{
187
"action": "create",
188
"git_remote_url": "https://github.com/test/new-repo.git",
189
"auth_method": "token",
190
"auth_credential": "ghp_newtoken",
191
"sync_direction": "push",
192
"sync_mode": "scheduled",
193
"sync_schedule": "*/30 * * * *",
194
"git_branch": "main",
195
"fossil_branch": "trunk",
196
},
197
)
198
assert response.status_code == 302
199
mirror = GitMirror.objects.get(git_remote_url="https://github.com/test/new-repo.git")
200
assert mirror.sync_direction == "push"
201
assert mirror.sync_schedule == "*/30 * * * *"
202
assert mirror.fossil_branch == "trunk"
203
204
def test_create_mirror_with_sync_tickets(self, admin_client, sample_project, fossil_repo_obj):
205
mock = _make_reader_mock()
206
with _disk_patch, patch("fossil.views.FossilReader", mock):
207
response = admin_client.post(
208
f"/projects/{sample_project.slug}/fossil/sync/git/",
209
{
210
"action": "create",
211
"git_remote_url": "https://github.com/test/tickets-repo.git",
212
"auth_method": "token",
213
"sync_direction": "push",
214
"sync_mode": "scheduled",
215
"sync_schedule": "*/15 * * * *",
216
"git_branch": "main",
217
"fossil_branch": "trunk",
218
"sync_tickets": "on",
219
"sync_wiki": "on",
220
},
221
)
222
assert response.status_code == 302
223
mirror = GitMirror.objects.get(git_remote_url="https://github.com/test/tickets-repo.git")
224
assert mirror.sync_tickets is True
225
assert mirror.sync_wiki is True
226
227
def test_create_denied_for_writer(self, writer_client, sample_project, fossil_repo_obj):
228
response = writer_client.post(
229
f"/projects/{sample_project.slug}/fossil/sync/git/",
230
{"action": "create", "git_remote_url": "https://evil.com/repo.git"},
231
)
232
assert response.status_code == 403
233
234
def test_create_denied_for_anon(self, client, sample_project):
235
response = client.post(
236
f"/projects/{sample_project.slug}/fossil/sync/git/",
237
{"action": "create", "git_remote_url": "https://example.com/repo.git"},
238
)
239
assert response.status_code == 302 # redirect to login
240
241
242
# --- Git Mirror Config View (Edit) ---
243
244
245
@pytest.mark.django_db
246
class TestGitMirrorEditView:
247
def test_get_edit_form(self, admin_client, sample_project, fossil_repo_obj, mirror):
248
mock = _make_reader_mock()
249
with _disk_patch, patch("fossil.views.FossilReader", mock):
250
response = admin_client.get(f"/projects/{sample_project.slug}/fossil/sync/git/{mirror.pk}/edit/")
251
assert response.status_code == 200
252
content = response.content.decode()
253
assert "Edit Git Mirror" in content
254
assert "github.com/org/repo.git" in content
255
# Should NOT show quick connect section when editing
256
assert "Quick Connect" not in content
257
258
def test_edit_mirror(self, admin_client, sample_project, fossil_repo_obj, mirror):
259
mock = _make_reader_mock()
260
with _disk_patch, patch("fossil.views.FossilReader", mock):
261
response = admin_client.post(
262
f"/projects/{sample_project.slug}/fossil/sync/git/{mirror.pk}/edit/",
263
{
264
"action": "update",
265
"git_remote_url": "https://github.com/org/updated-repo.git",
266
"auth_method": "ssh",
267
"sync_direction": "both",
268
"sync_mode": "both",
269
"sync_schedule": "0 */2 * * *",
270
"git_branch": "develop",
271
"fossil_branch": "trunk",
272
},
273
)
274
assert response.status_code == 302
275
mirror.refresh_from_db()
276
assert mirror.git_remote_url == "https://github.com/org/updated-repo.git"
277
assert mirror.auth_method == "ssh"
278
assert mirror.sync_direction == "both"
279
assert mirror.sync_schedule == "0 */2 * * *"
280
assert mirror.git_branch == "develop"
281
282
def test_edit_preserves_credential_when_blank(self, admin_client, sample_project, fossil_repo_obj, mirror):
283
"""Editing without providing a new credential should keep the old one."""
284
old_credential = mirror.auth_credential
285
mock = _make_reader_mock()
286
with _disk_patch, patch("fossil.views.FossilReader", mock):
287
response = admin_client.post(
288
f"/projects/{sample_project.slug}/fossil/sync/git/{mirror.pk}/edit/",
289
{
290
"action": "update",
291
"git_remote_url": "https://github.com/org/repo.git",
292
"auth_method": "token",
293
"auth_credential": "",
294
"sync_direction": "push",
295
"sync_mode": "scheduled",
296
"sync_schedule": "*/15 * * * *",
297
"git_branch": "main",
298
"fossil_branch": "trunk",
299
},
300
)
301
assert response.status_code == 302
302
mirror.refresh_from_db()
303
assert mirror.auth_credential == old_credential
304
305
def test_edit_nonexistent_mirror(self, admin_client, sample_project, fossil_repo_obj):
306
mock = _make_reader_mock()
307
with _disk_patch, patch("fossil.views.FossilReader", mock):
308
response = admin_client.get(f"/projects/{sample_project.slug}/fossil/sync/git/99999/edit/")
309
assert response.status_code == 404
310
311
def test_edit_denied_for_writer(self, writer_client, sample_project, fossil_repo_obj, mirror):
312
response = writer_client.post(
313
f"/projects/{sample_project.slug}/fossil/sync/git/{mirror.pk}/edit/",
314
{"action": "update", "git_remote_url": "https://evil.com/repo.git"},
315
)
316
assert response.status_code == 403
317
318
319
# --- Git Mirror Delete View ---
320
321
322
@pytest.mark.django_db
323
class TestGitMirrorDeleteView:
324
def test_get_delete_confirmation(self, admin_client, sample_project, fossil_repo_obj, mirror):
325
mock = _make_reader_mock()
326
with _disk_patch, patch("fossil.views.FossilReader", mock):
327
response = admin_client.get(f"/projects/{sample_project.slug}/fossil/sync/git/{mirror.pk}/delete/")
328
assert response.status_code == 200
329
content = response.content.decode()
330
assert "Delete Git Mirror" in content
331
assert "github.com/org/repo.git" in content
332
333
def test_delete_mirror(self, admin_client, sample_project, fossil_repo_obj, mirror):
334
mock = _make_reader_mock()
335
with _disk_patch, patch("fossil.views.FossilReader", mock):
336
response = admin_client.post(f"/projects/{sample_project.slug}/fossil/sync/git/{mirror.pk}/delete/")
337
assert response.status_code == 302
338
mirror.refresh_from_db()
339
assert mirror.is_deleted
340
341
def test_delete_removes_from_active_queries(self, admin_client, sample_project, fossil_repo_obj, mirror):
342
mock = _make_reader_mock()
343
with _disk_patch, patch("fossil.views.FossilReader", mock):
344
admin_client.post(f"/projects/{sample_project.slug}/fossil/sync/git/{mirror.pk}/delete/")
345
assert GitMirror.objects.filter(pk=mirror.pk).count() == 0
346
assert GitMirror.all_objects.filter(pk=mirror.pk).count() == 1
347
348
def test_delete_nonexistent_mirror(self, admin_client, sample_project, fossil_repo_obj):
349
mock = _make_reader_mock()
350
with _disk_patch, patch("fossil.views.FossilReader", mock):
351
response = admin_client.post(f"/projects/{sample_project.slug}/fossil/sync/git/99999/delete/")
352
assert response.status_code == 404
353
354
def test_delete_denied_for_writer(self, writer_client, sample_project, fossil_repo_obj, mirror):
355
response = writer_client.post(f"/projects/{sample_project.slug}/fossil/sync/git/{mirror.pk}/delete/")
356
assert response.status_code == 403
357
358
def test_delete_denied_for_anon(self, client, sample_project, mirror):
359
response = client.post(f"/projects/{sample_project.slug}/fossil/sync/git/{mirror.pk}/delete/")
360
assert response.status_code == 302 # redirect to login
361
362
363
# --- Git Mirror Run View ---
364
365
366
@pytest.mark.django_db
367
class TestGitMirrorRunView:
368
def test_run_mirror(self, admin_client, sample_project, fossil_repo_obj, mirror):
369
mock_reader = _make_reader_mock()
370
mock_task = MagicMock()
371
with _disk_patch, patch("fossil.views.FossilReader", mock_reader), patch("fossil.tasks.run_git_sync", mock_task):
372
response = admin_client.post(f"/projects/{sample_project.slug}/fossil/sync/git/{mirror.pk}/run/")
373
assert response.status_code == 302
374
mock_task.delay.assert_called_once_with(mirror.pk)
375
376
def test_run_denied_for_writer(self, writer_client, sample_project, fossil_repo_obj, mirror):
377
response = writer_client.post(f"/projects/{sample_project.slug}/fossil/sync/git/{mirror.pk}/run/")
378
assert response.status_code == 403
379
380
def test_run_denied_for_anon(self, client, sample_project, mirror):
381
response = client.post(f"/projects/{sample_project.slug}/fossil/sync/git/{mirror.pk}/run/")
382
assert response.status_code == 302 # redirect to login
383

Keyboard Shortcuts

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