FossilRepo

fossilrepo / tests / test_ticket_reports.py
Blame History Raw 325 lines
1
import pytest
2
from django.contrib.auth.models import User
3
from django.test import Client
4
5
from fossil.models import FossilRepository
6
from fossil.ticket_reports import TicketReport
7
from organization.models import Team
8
from projects.models import ProjectTeam
9
10
11
@pytest.fixture
12
def fossil_repo_obj(sample_project):
13
"""Return the auto-created FossilRepository for sample_project."""
14
return FossilRepository.objects.get(project=sample_project, deleted_at__isnull=True)
15
16
17
@pytest.fixture
18
def public_report(fossil_repo_obj, admin_user):
19
return TicketReport.objects.create(
20
repository=fossil_repo_obj,
21
title="Open Tickets",
22
description="All open tickets",
23
sql_query="SELECT tkt_uuid, title, status FROM ticket WHERE status = 'Open'",
24
is_public=True,
25
created_by=admin_user,
26
)
27
28
29
@pytest.fixture
30
def private_report(fossil_repo_obj, admin_user):
31
return TicketReport.objects.create(
32
repository=fossil_repo_obj,
33
title="Internal Metrics",
34
description="Admin-only report",
35
sql_query="SELECT COUNT(*) as total FROM ticket",
36
is_public=False,
37
created_by=admin_user,
38
)
39
40
41
@pytest.fixture
42
def writer_user(db, admin_user, sample_project):
43
"""User with write access but not admin."""
44
writer = User.objects.create_user(username="writer", password="testpass123")
45
team = Team.objects.create(name="Writers", organization=sample_project.organization, created_by=admin_user)
46
team.members.add(writer)
47
ProjectTeam.objects.create(project=sample_project, team=team, role="write", created_by=admin_user)
48
return writer
49
50
51
@pytest.fixture
52
def writer_client(writer_user):
53
client = Client()
54
client.login(username="writer", password="testpass123")
55
return client
56
57
58
@pytest.fixture
59
def reader_user(db, admin_user, sample_project):
60
"""User with read access only."""
61
reader = User.objects.create_user(username="reader", password="testpass123")
62
team = Team.objects.create(name="Readers", organization=sample_project.organization, created_by=admin_user)
63
team.members.add(reader)
64
ProjectTeam.objects.create(project=sample_project, team=team, role="read", created_by=admin_user)
65
return reader
66
67
68
@pytest.fixture
69
def reader_client(reader_user):
70
client = Client()
71
client.login(username="reader", password="testpass123")
72
return client
73
74
75
# --- SQL Validation Tests ---
76
77
78
@pytest.mark.django_db
79
class TestTicketReportSQLValidation:
80
def test_valid_select(self):
81
assert TicketReport.validate_sql("SELECT * FROM ticket") is None
82
83
def test_valid_select_with_where(self):
84
assert TicketReport.validate_sql("SELECT title, status FROM ticket WHERE status = 'Open'") is None
85
86
def test_valid_select_with_join(self):
87
assert TicketReport.validate_sql("SELECT t.title, tc.icomment FROM ticket t JOIN ticketchng tc ON t.tkt_id = tc.tkt_id") is None
88
89
def test_reject_empty(self):
90
assert TicketReport.validate_sql("") is not None
91
assert "empty" in TicketReport.validate_sql("").lower()
92
93
def test_reject_insert(self):
94
error = TicketReport.validate_sql("INSERT INTO ticket (title) VALUES ('hack')")
95
assert error is not None
96
assert "select" in error.lower() or "forbidden" in error.lower()
97
98
def test_reject_update(self):
99
error = TicketReport.validate_sql("UPDATE ticket SET title = 'hacked'")
100
assert error is not None
101
102
def test_reject_delete(self):
103
error = TicketReport.validate_sql("DELETE FROM ticket")
104
assert error is not None
105
106
def test_reject_drop(self):
107
error = TicketReport.validate_sql("DROP TABLE ticket")
108
assert error is not None
109
110
def test_reject_alter(self):
111
error = TicketReport.validate_sql("ALTER TABLE ticket ADD COLUMN evil TEXT")
112
assert error is not None
113
114
def test_reject_create(self):
115
error = TicketReport.validate_sql("CREATE TABLE evil (id INTEGER)")
116
assert error is not None
117
118
def test_reject_attach(self):
119
error = TicketReport.validate_sql("ATTACH DATABASE ':memory:' AS evil")
120
assert error is not None
121
122
def test_reject_pragma(self):
123
error = TicketReport.validate_sql("PRAGMA table_info(ticket)")
124
assert error is not None
125
126
def test_reject_multiple_statements(self):
127
error = TicketReport.validate_sql("SELECT 1; DROP TABLE ticket")
128
assert error is not None
129
# May be caught by forbidden keyword or multiple statement check
130
assert "multiple" in error.lower() or "forbidden" in error.lower()
131
132
def test_reject_multiple_statements_pure(self):
133
"""Semicolons without forbidden keywords should also be rejected."""
134
error = TicketReport.validate_sql("SELECT 1; SELECT 2")
135
assert error is not None
136
assert "multiple" in error.lower()
137
138
def test_reject_non_select_start(self):
139
error = TicketReport.validate_sql("WITH cte AS (DELETE FROM ticket) SELECT * FROM cte")
140
assert error is not None
141
assert "SELECT" in error
142
143
144
# --- Model Tests ---
145
146
147
@pytest.mark.django_db
148
class TestTicketReportModel:
149
def test_create_report(self, public_report):
150
assert public_report.pk is not None
151
assert str(public_report) == "Open Tickets"
152
153
def test_soft_delete(self, public_report, admin_user):
154
public_report.soft_delete(user=admin_user)
155
assert public_report.is_deleted
156
assert TicketReport.objects.filter(pk=public_report.pk).count() == 0
157
assert TicketReport.all_objects.filter(pk=public_report.pk).count() == 1
158
159
def test_ordering(self, fossil_repo_obj, admin_user):
160
r_b = TicketReport.objects.create(repository=fossil_repo_obj, title="B Report", sql_query="SELECT 1", created_by=admin_user)
161
r_a = TicketReport.objects.create(repository=fossil_repo_obj, title="A Report", sql_query="SELECT 1", created_by=admin_user)
162
reports = list(TicketReport.objects.filter(repository=fossil_repo_obj))
163
assert reports[0] == r_a # alphabetical
164
assert reports[1] == r_b
165
166
167
# --- List View Tests ---
168
169
170
@pytest.mark.django_db
171
class TestTicketReportsListView:
172
def test_list_reports_admin(self, admin_client, sample_project, public_report, private_report):
173
response = admin_client.get(f"/projects/{sample_project.slug}/fossil/tickets/reports/")
174
assert response.status_code == 200
175
content = response.content.decode()
176
assert "Open Tickets" in content
177
assert "Internal Metrics" in content # admin sees private reports
178
179
def test_list_reports_reader_hides_private(self, reader_client, sample_project, public_report, private_report):
180
response = reader_client.get(f"/projects/{sample_project.slug}/fossil/tickets/reports/")
181
assert response.status_code == 200
182
content = response.content.decode()
183
assert "Open Tickets" in content
184
assert "Internal Metrics" not in content # reader cannot see private reports
185
186
def test_list_empty(self, admin_client, sample_project, fossil_repo_obj):
187
response = admin_client.get(f"/projects/{sample_project.slug}/fossil/tickets/reports/")
188
assert response.status_code == 200
189
assert "No ticket reports defined" in response.content.decode()
190
191
def test_list_denied_for_anon(self, client, sample_project):
192
response = client.get(f"/projects/{sample_project.slug}/fossil/tickets/reports/")
193
# Private project: anonymous user gets 403 from require_project_read
194
assert response.status_code == 403
195
196
197
# --- Create View Tests ---
198
199
200
@pytest.mark.django_db
201
class TestTicketReportCreateView:
202
def test_get_form(self, admin_client, sample_project, fossil_repo_obj):
203
response = admin_client.get(f"/projects/{sample_project.slug}/fossil/tickets/reports/create/")
204
assert response.status_code == 200
205
assert "Create Ticket Report" in response.content.decode()
206
207
def test_create_report(self, admin_client, sample_project, fossil_repo_obj):
208
response = admin_client.post(
209
f"/projects/{sample_project.slug}/fossil/tickets/reports/create/",
210
{
211
"title": "Critical Bugs",
212
"description": "All critical severity tickets",
213
"sql_query": "SELECT tkt_uuid, title FROM ticket WHERE severity = 'Critical'",
214
"is_public": "on",
215
},
216
)
217
assert response.status_code == 302
218
report = TicketReport.objects.get(title="Critical Bugs")
219
assert report.is_public is True
220
assert "Critical" in report.sql_query
221
222
def test_create_report_rejects_dangerous_sql(self, admin_client, sample_project, fossil_repo_obj):
223
response = admin_client.post(
224
f"/projects/{sample_project.slug}/fossil/tickets/reports/create/",
225
{
226
"title": "Evil Report",
227
"sql_query": "DROP TABLE ticket",
228
},
229
)
230
assert response.status_code == 200 # re-renders form with error
231
assert TicketReport.objects.filter(title="Evil Report").count() == 0
232
233
def test_create_denied_for_writer(self, writer_client, sample_project):
234
response = writer_client.post(
235
f"/projects/{sample_project.slug}/fossil/tickets/reports/create/",
236
{"title": "Hack", "sql_query": "SELECT 1"},
237
)
238
assert response.status_code == 403
239
240
def test_create_denied_for_reader(self, reader_client, sample_project):
241
response = reader_client.post(
242
f"/projects/{sample_project.slug}/fossil/tickets/reports/create/",
243
{"title": "Hack", "sql_query": "SELECT 1"},
244
)
245
assert response.status_code == 403
246
247
248
# --- Edit View Tests ---
249
250
251
@pytest.mark.django_db
252
class TestTicketReportEditView:
253
def test_get_edit_form(self, admin_client, sample_project, public_report):
254
response = admin_client.get(f"/projects/{sample_project.slug}/fossil/tickets/reports/{public_report.pk}/edit/")
255
assert response.status_code == 200
256
content = response.content.decode()
257
assert "Open Tickets" in content
258
259
def test_edit_report(self, admin_client, sample_project, public_report):
260
response = admin_client.post(
261
f"/projects/{sample_project.slug}/fossil/tickets/reports/{public_report.pk}/edit/",
262
{
263
"title": "Open Tickets (Updated)",
264
"description": "Updated description",
265
"sql_query": "SELECT tkt_uuid, title, status FROM ticket WHERE status = 'Open' ORDER BY tkt_ctime DESC",
266
"is_public": "on",
267
},
268
)
269
assert response.status_code == 302
270
public_report.refresh_from_db()
271
assert public_report.title == "Open Tickets (Updated)"
272
273
def test_edit_rejects_dangerous_sql(self, admin_client, sample_project, public_report):
274
response = admin_client.post(
275
f"/projects/{sample_project.slug}/fossil/tickets/reports/{public_report.pk}/edit/",
276
{
277
"title": "Open Tickets",
278
"sql_query": "DELETE FROM ticket",
279
},
280
)
281
assert response.status_code == 200 # re-renders form
282
public_report.refresh_from_db()
283
assert "DELETE" not in public_report.sql_query
284
285
def test_edit_denied_for_writer(self, writer_client, sample_project, public_report):
286
response = writer_client.post(
287
f"/projects/{sample_project.slug}/fossil/tickets/reports/{public_report.pk}/edit/",
288
{"title": "Hacked", "sql_query": "SELECT 1"},
289
)
290
assert response.status_code == 403
291
292
def test_edit_nonexistent(self, admin_client, sample_project, fossil_repo_obj):
293
response = admin_client.get(f"/projects/{sample_project.slug}/fossil/tickets/reports/99999/edit/")
294
assert response.status_code == 404
295
296
297
# --- Run View Tests ---
298
299
300
@pytest.mark.django_db
301
class TestTicketReportRunView:
302
def test_run_public_report_as_reader(self, reader_client, sample_project, public_report):
303
"""Readers can run public reports, but report may error if .fossil file not on disk."""
304
response = reader_client.get(f"/projects/{sample_project.slug}/fossil/tickets/reports/{public_report.pk}/")
305
# The .fossil file does not exist on disk in test env, so we get a database error
306
# but the view itself should not raise a 403/404 — it renders the error in-page
307
assert response.status_code == 200
308
309
def test_run_private_report_denied_for_reader(self, reader_client, sample_project, private_report):
310
response = reader_client.get(f"/projects/{sample_project.slug}/fossil/tickets/reports/{private_report.pk}/")
311
assert response.status_code == 403
312
313
def test_run_private_report_allowed_for_admin(self, admin_client, sample_project, private_report):
314
response = admin_client.get(f"/projects/{sample_project.slug}/fossil/tickets/reports/{private_report.pk}/")
315
assert response.status_code == 200
316
317
def test_run_nonexistent_report(self, admin_client, sample_project, fossil_repo_obj):
318
response = admin_client.get(f"/projects/{sample_project.slug}/fossil/tickets/reports/99999/")
319
assert response.status_code == 404
320
321
def test_run_denied_for_anon(self, client, sample_project, public_report):
322
response = client.get(f"/projects/{sample_project.slug}/fossil/tickets/reports/{public_report.pk}/")
323
# Private project: anonymous user gets 403 from require_project_read
324
assert response.status_code == 403
325

Keyboard Shortcuts

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