FossilRepo

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

Keyboard Shortcuts

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