|
1
|
"""Tests for the repository lifecycle UI: project creation with repo source, and repo settings.""" |
|
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 organization.models import Team |
|
11
|
from projects.models import Project, ProjectTeam |
|
12
|
|
|
13
|
|
|
14
|
@pytest.fixture |
|
15
|
def fossil_repo_obj(sample_project): |
|
16
|
"""Return the auto-created FossilRepository for sample_project.""" |
|
17
|
return FossilRepository.objects.get(project=sample_project, deleted_at__isnull=True) |
|
18
|
|
|
19
|
|
|
20
|
@pytest.fixture |
|
21
|
def writer_user(db, admin_user, sample_project): |
|
22
|
"""User with write access but not admin.""" |
|
23
|
writer = User.objects.create_user(username="writer", password="testpass123") |
|
24
|
team = Team.objects.create(name="Writers", organization=sample_project.organization, created_by=admin_user) |
|
25
|
team.members.add(writer) |
|
26
|
ProjectTeam.objects.create(project=sample_project, team=team, role="write", created_by=admin_user) |
|
27
|
return writer |
|
28
|
|
|
29
|
|
|
30
|
@pytest.fixture |
|
31
|
def writer_client(writer_user): |
|
32
|
client = Client() |
|
33
|
client.login(username="writer", password="testpass123") |
|
34
|
return client |
|
35
|
|
|
36
|
|
|
37
|
@pytest.fixture |
|
38
|
def admin_team_user(db, admin_user, sample_project): |
|
39
|
"""User with admin team role on the sample project.""" |
|
40
|
admin_team_member = User.objects.create_user(username="projadmin", password="testpass123") |
|
41
|
team = Team.objects.create(name="Admins", organization=sample_project.organization, created_by=admin_user) |
|
42
|
team.members.add(admin_team_member) |
|
43
|
ProjectTeam.objects.create(project=sample_project, team=team, role="admin", created_by=admin_user) |
|
44
|
return admin_team_member |
|
45
|
|
|
46
|
|
|
47
|
@pytest.fixture |
|
48
|
def admin_team_client(admin_team_user): |
|
49
|
client = Client() |
|
50
|
client.login(username="projadmin", password="testpass123") |
|
51
|
return client |
|
52
|
|
|
53
|
|
|
54
|
# --- Project Create Form Tests --- |
|
55
|
|
|
56
|
|
|
57
|
@pytest.mark.django_db |
|
58
|
class TestProjectCreateForm: |
|
59
|
def test_create_form_shows_repo_source(self, admin_client): |
|
60
|
response = admin_client.get("/projects/create/") |
|
61
|
assert response.status_code == 200 |
|
62
|
content = response.content.decode() |
|
63
|
assert "repo_source" in content |
|
64
|
assert "Create empty repository" in content |
|
65
|
assert "Clone from Fossil URL" in content |
|
66
|
|
|
67
|
def test_create_empty_repo(self, admin_client, org): |
|
68
|
response = admin_client.post( |
|
69
|
"/projects/create/", |
|
70
|
{"name": "Empty Repo", "visibility": "private", "repo_source": "empty"}, |
|
71
|
) |
|
72
|
assert response.status_code == 302 |
|
73
|
project = Project.objects.get(name="Empty Repo") |
|
74
|
assert project is not None |
|
75
|
fossil_repo = FossilRepository.objects.get(project=project) |
|
76
|
assert fossil_repo.filename == f"{project.slug}.fossil" |
|
77
|
assert fossil_repo.remote_url == "" |
|
78
|
|
|
79
|
def test_create_with_missing_clone_url_fails(self, admin_client, org): |
|
80
|
response = admin_client.post( |
|
81
|
"/projects/create/", |
|
82
|
{"name": "Clone Fail", "visibility": "private", "repo_source": "fossil_url", "clone_url": ""}, |
|
83
|
) |
|
84
|
# Form should re-render with errors, not redirect |
|
85
|
assert response.status_code == 200 |
|
86
|
content = response.content.decode() |
|
87
|
assert "Clone URL is required" in content |
|
88
|
|
|
89
|
@patch("projects.views._clone_fossil_repo") |
|
90
|
def test_create_clone_calls_helper(self, mock_clone, admin_client, org): |
|
91
|
response = admin_client.post( |
|
92
|
"/projects/create/", |
|
93
|
{ |
|
94
|
"name": "Cloned Repo", |
|
95
|
"visibility": "private", |
|
96
|
"repo_source": "fossil_url", |
|
97
|
"clone_url": "https://fossil-scm.org/home", |
|
98
|
}, |
|
99
|
) |
|
100
|
assert response.status_code == 302 |
|
101
|
project = Project.objects.get(name="Cloned Repo") |
|
102
|
mock_clone.assert_called_once() |
|
103
|
call_args = mock_clone.call_args |
|
104
|
assert call_args[0][1] == project |
|
105
|
assert call_args[0][2] == "https://fossil-scm.org/home" |
|
106
|
|
|
107
|
def test_create_without_repo_source_defaults_to_empty(self, admin_client, org): |
|
108
|
response = admin_client.post( |
|
109
|
"/projects/create/", |
|
110
|
{"name": "Default Source", "visibility": "private"}, |
|
111
|
) |
|
112
|
assert response.status_code == 302 |
|
113
|
project = Project.objects.get(name="Default Source") |
|
114
|
fossil_repo = FossilRepository.objects.get(project=project) |
|
115
|
assert fossil_repo.remote_url == "" |
|
116
|
|
|
117
|
def test_edit_form_does_not_show_repo_source(self, admin_client, sample_project): |
|
118
|
response = admin_client.get(f"/projects/{sample_project.slug}/edit/") |
|
119
|
assert response.status_code == 200 |
|
120
|
content = response.content.decode() |
|
121
|
assert "Repository Source" not in content |
|
122
|
|
|
123
|
|
|
124
|
# --- Project Update Form Tests (no repo source fields) --- |
|
125
|
|
|
126
|
|
|
127
|
@pytest.mark.django_db |
|
128
|
class TestProjectUpdateExcludesRepoSource: |
|
129
|
def test_update_preserves_project(self, admin_client, sample_project): |
|
130
|
response = admin_client.post( |
|
131
|
f"/projects/{sample_project.slug}/edit/", |
|
132
|
{"name": "Updated Name", "visibility": "public"}, |
|
133
|
) |
|
134
|
assert response.status_code == 302 |
|
135
|
sample_project.refresh_from_db() |
|
136
|
assert sample_project.name == "Updated Name" |
|
137
|
|
|
138
|
|
|
139
|
# --- Repo Settings View Tests --- |
|
140
|
|
|
141
|
|
|
142
|
@pytest.mark.django_db |
|
143
|
class TestRepoSettingsAccess: |
|
144
|
def test_settings_denied_for_anon(self, client, sample_project, fossil_repo_obj): |
|
145
|
response = client.get(f"/projects/{sample_project.slug}/fossil/settings/") |
|
146
|
# Redirects to login for anon |
|
147
|
assert response.status_code == 302 |
|
148
|
|
|
149
|
def test_settings_denied_for_writer(self, writer_client, sample_project, fossil_repo_obj): |
|
150
|
response = writer_client.get(f"/projects/{sample_project.slug}/fossil/settings/") |
|
151
|
assert response.status_code == 403 |
|
152
|
|
|
153
|
def test_settings_allowed_for_superuser(self, admin_client, sample_project, fossil_repo_obj): |
|
154
|
response = admin_client.get(f"/projects/{sample_project.slug}/fossil/settings/") |
|
155
|
assert response.status_code == 200 |
|
156
|
|
|
157
|
def test_settings_allowed_for_project_admin(self, admin_team_client, sample_project, fossil_repo_obj): |
|
158
|
response = admin_team_client.get(f"/projects/{sample_project.slug}/fossil/settings/") |
|
159
|
assert response.status_code == 200 |
|
160
|
|
|
161
|
|
|
162
|
@pytest.mark.django_db |
|
163
|
class TestRepoSettingsContent: |
|
164
|
def test_settings_page_shows_filename(self, admin_client, sample_project, fossil_repo_obj): |
|
165
|
response = admin_client.get(f"/projects/{sample_project.slug}/fossil/settings/") |
|
166
|
content = response.content.decode() |
|
167
|
assert fossil_repo_obj.filename in content |
|
168
|
|
|
169
|
def test_settings_page_shows_remote_form(self, admin_client, sample_project, fossil_repo_obj): |
|
170
|
response = admin_client.get(f"/projects/{sample_project.slug}/fossil/settings/") |
|
171
|
content = response.content.decode() |
|
172
|
assert 'name="remote_url"' in content |
|
173
|
assert "Save Remote" in content |
|
174
|
|
|
175
|
def test_settings_page_shows_clone_urls(self, admin_client, sample_project, fossil_repo_obj): |
|
176
|
response = admin_client.get(f"/projects/{sample_project.slug}/fossil/settings/") |
|
177
|
content = response.content.decode() |
|
178
|
assert "Clone URLs" in content |
|
179
|
|
|
180
|
def test_settings_page_shows_danger_zone(self, admin_client, sample_project, fossil_repo_obj): |
|
181
|
response = admin_client.get(f"/projects/{sample_project.slug}/fossil/settings/") |
|
182
|
content = response.content.decode() |
|
183
|
assert "Danger Zone" in content |
|
184
|
|
|
185
|
def test_settings_active_tab(self, admin_client, sample_project, fossil_repo_obj): |
|
186
|
response = admin_client.get(f"/projects/{sample_project.slug}/fossil/settings/") |
|
187
|
content = response.content.decode() |
|
188
|
# The Settings tab should be active (has the active CSS classes) |
|
189
|
assert "Settings" in content |
|
190
|
|
|
191
|
|
|
192
|
@pytest.mark.django_db |
|
193
|
class TestRepoSettingsActions: |
|
194
|
def test_update_remote_url(self, admin_client, sample_project, fossil_repo_obj): |
|
195
|
response = admin_client.post( |
|
196
|
f"/projects/{sample_project.slug}/fossil/settings/", |
|
197
|
{"action": "update_remote", "remote_url": "https://fossil-scm.org/home"}, |
|
198
|
) |
|
199
|
assert response.status_code == 302 |
|
200
|
fossil_repo_obj.refresh_from_db() |
|
201
|
assert fossil_repo_obj.remote_url == "https://fossil-scm.org/home" |
|
202
|
|
|
203
|
def test_clear_remote_url(self, admin_client, sample_project, fossil_repo_obj): |
|
204
|
fossil_repo_obj.remote_url = "https://old-url.example.com" |
|
205
|
fossil_repo_obj.save() |
|
206
|
response = admin_client.post( |
|
207
|
f"/projects/{sample_project.slug}/fossil/settings/", |
|
208
|
{"action": "update_remote", "remote_url": ""}, |
|
209
|
) |
|
210
|
assert response.status_code == 302 |
|
211
|
fossil_repo_obj.refresh_from_db() |
|
212
|
assert fossil_repo_obj.remote_url == "" |
|
213
|
|
|
214
|
def test_update_remote_denied_for_writer(self, writer_client, sample_project, fossil_repo_obj): |
|
215
|
response = writer_client.post( |
|
216
|
f"/projects/{sample_project.slug}/fossil/settings/", |
|
217
|
{"action": "update_remote", "remote_url": "https://evil.example.com"}, |
|
218
|
) |
|
219
|
assert response.status_code == 403 |
|
220
|
fossil_repo_obj.refresh_from_db() |
|
221
|
assert fossil_repo_obj.remote_url != "https://evil.example.com" |
|
222
|
|
|
223
|
|
|
224
|
# --- Nav Tab Tests --- |
|
225
|
|
|
226
|
|
|
227
|
@pytest.mark.django_db |
|
228
|
class TestProjectNavSettings: |
|
229
|
def test_settings_tab_visible_for_admin(self, admin_client, sample_project, fossil_repo_obj): |
|
230
|
"""The Settings tab should appear in the nav for admins on fossil views.""" |
|
231
|
response = admin_client.get(f"/projects/{sample_project.slug}/fossil/settings/") |
|
232
|
content = response.content.decode() |
|
233
|
assert "Settings" in content |
|
234
|
assert f"/projects/{sample_project.slug}/fossil/settings/" in content |
|
235
|
|
|
236
|
|
|
237
|
# --- Signal Guard Tests --- |
|
238
|
|
|
239
|
|
|
240
|
@pytest.mark.django_db |
|
241
|
class TestSignalExistingFileGuard: |
|
242
|
@patch("fossil.cli.FossilCLI") |
|
243
|
def test_signal_skips_init_when_file_exists(self, mock_cli_cls, org, admin_user, tmp_path): |
|
244
|
"""When a .fossil file already exists, the signal should skip fossil init.""" |
|
245
|
mock_cli = MagicMock() |
|
246
|
mock_cli.is_available.return_value = True |
|
247
|
mock_cli_cls.return_value = mock_cli |
|
248
|
|
|
249
|
# Create the project -- the signal fires |
|
250
|
project = Project.objects.create(name="Pre-existing", organization=org, created_by=admin_user) |
|
251
|
|
|
252
|
# The signal creates a FossilRepository record. Since the .fossil file won't exist |
|
253
|
# on disk in tests (no real FOSSIL_DATA_DIR), the signal will attempt init via CLI. |
|
254
|
# The key assertion is that the record was created and the code path doesn't crash. |
|
255
|
assert FossilRepository.objects.filter(project=project).exists() |
|
256
|
|
|
257
|
def test_signal_creates_repo_record(self, org, admin_user): |
|
258
|
"""The signal creates a FossilRepository record when a Project is created.""" |
|
259
|
project = Project.objects.create(name="Signal Test", organization=org, created_by=admin_user) |
|
260
|
assert FossilRepository.objects.filter(project=project).exists() |
|
261
|
fossil_repo = FossilRepository.objects.get(project=project) |
|
262
|
assert fossil_repo.filename == f"{project.slug}.fossil" |
|
263
|
|
|
264
|
|
|
265
|
# --- Form Validation Tests --- |
|
266
|
|
|
267
|
|
|
268
|
@pytest.mark.django_db |
|
269
|
class TestProjectFormValidation: |
|
270
|
def test_form_valid_with_empty_source(self): |
|
271
|
from projects.forms import ProjectForm |
|
272
|
|
|
273
|
form = ProjectForm(data={"name": "Test", "visibility": "private", "repo_source": "empty"}) |
|
274
|
assert form.is_valid() |
|
275
|
|
|
276
|
def test_form_valid_with_clone_url(self): |
|
277
|
from projects.forms import ProjectForm |
|
278
|
|
|
279
|
form = ProjectForm( |
|
280
|
data={ |
|
281
|
"name": "Test Clone", |
|
282
|
"visibility": "private", |
|
283
|
"repo_source": "fossil_url", |
|
284
|
"clone_url": "https://fossil-scm.org/home", |
|
285
|
} |
|
286
|
) |
|
287
|
assert form.is_valid() |
|
288
|
|
|
289
|
def test_form_invalid_clone_without_url(self): |
|
290
|
from projects.forms import ProjectForm |
|
291
|
|
|
292
|
form = ProjectForm( |
|
293
|
data={ |
|
294
|
"name": "No URL", |
|
295
|
"visibility": "private", |
|
296
|
"repo_source": "fossil_url", |
|
297
|
"clone_url": "", |
|
298
|
} |
|
299
|
) |
|
300
|
assert not form.is_valid() |
|
301
|
assert "clone_url" in form.errors |
|
302
|
|