FossilRepo

fossilrepo / tests / test_tasks_and_accounts.py
Source Blame History 1591 lines
254b467… ragelink 1 """Tests for fossil/tasks.py and accounts/views.py uncovered lines.
254b467… ragelink 2
254b467… ragelink 3 Targets:
254b467… ragelink 4 - fossil/tasks.py (33% -> higher): sync_metadata, create_snapshot,
254b467… ragelink 5 check_upstream, run_git_sync, dispatch_notifications,
254b467… ragelink 6 sync_tickets_to_github, sync_wiki_to_github
254b467… ragelink 7 - accounts/views.py (77% -> higher): _sanitize_ssh_key, _verify_turnstile,
254b467… ragelink 8 login turnstile flow, ssh key CRUD, notification prefs HTMX,
254b467… ragelink 9 profile_token_create edge cases
254b467… ragelink 10 """
254b467… ragelink 11
254b467… ragelink 12 from datetime import UTC, datetime
254b467… ragelink 13 from unittest.mock import MagicMock, PropertyMock, patch
254b467… ragelink 14
254b467… ragelink 15 import pytest
254b467… ragelink 16
254b467… ragelink 17 from fossil.models import FossilRepository, FossilSnapshot
254b467… ragelink 18 from fossil.notifications import Notification, NotificationPreference, ProjectWatch
254b467… ragelink 19 from fossil.reader import TicketEntry, TimelineEntry, WikiPage
254b467… ragelink 20 from fossil.sync_models import GitMirror, SyncLog, TicketSyncMapping, WikiSyncMapping
254b467… ragelink 21 from fossil.webhooks import Webhook, WebhookDelivery
254b467… ragelink 22
254b467… ragelink 23 # ---------------------------------------------------------------------------
254b467… ragelink 24 # Helpers
254b467… ragelink 25 # ---------------------------------------------------------------------------
254b467… ragelink 26
254b467… ragelink 27 # Reusable patch that makes FossilRepository.exists_on_disk return True
254b467… ragelink 28 _disk_exists = patch(
254b467… ragelink 29 "fossil.models.FossilRepository.exists_on_disk",
254b467… ragelink 30 new_callable=lambda: property(lambda self: True),
254b467… ragelink 31 )
254b467… ragelink 32
254b467… ragelink 33
254b467… ragelink 34 def _make_reader_mock(**methods):
254b467… ragelink 35 """Create a context-manager-compatible FossilReader mock."""
254b467… ragelink 36 mock_cls = MagicMock()
254b467… ragelink 37 instance = MagicMock()
254b467… ragelink 38 mock_cls.return_value = instance
254b467… ragelink 39 instance.__enter__ = MagicMock(return_value=instance)
254b467… ragelink 40 instance.__exit__ = MagicMock(return_value=False)
254b467… ragelink 41 for name, val in methods.items():
254b467… ragelink 42 getattr(instance, name).return_value = val
254b467… ragelink 43 return mock_cls
254b467… ragelink 44
254b467… ragelink 45
254b467… ragelink 46 def _make_timeline_entry(**overrides):
254b467… ragelink 47 defaults = {
254b467… ragelink 48 "rid": 1,
254b467… ragelink 49 "uuid": "abc123def456",
254b467… ragelink 50 "event_type": "ci",
254b467… ragelink 51 "timestamp": datetime.now(UTC),
254b467… ragelink 52 "user": "dev",
254b467… ragelink 53 "comment": "fix typo",
254b467… ragelink 54 "branch": "trunk",
254b467… ragelink 55 }
254b467… ragelink 56 defaults.update(overrides)
254b467… ragelink 57 return TimelineEntry(**defaults)
254b467… ragelink 58
254b467… ragelink 59
254b467… ragelink 60 def _make_ticket(**overrides):
254b467… ragelink 61 defaults = {
254b467… ragelink 62 "uuid": "ticket-uuid-001",
254b467… ragelink 63 "title": "Bug report",
254b467… ragelink 64 "status": "open",
254b467… ragelink 65 "type": "bug",
254b467… ragelink 66 "created": datetime.now(UTC),
254b467… ragelink 67 "owner": "dev",
254b467… ragelink 68 "body": "Something is broken",
254b467… ragelink 69 "priority": "high",
254b467… ragelink 70 "severity": "critical",
254b467… ragelink 71 }
254b467… ragelink 72 defaults.update(overrides)
254b467… ragelink 73 return TicketEntry(**defaults)
254b467… ragelink 74
254b467… ragelink 75
254b467… ragelink 76 def _make_wiki_page(**overrides):
254b467… ragelink 77 defaults = {
254b467… ragelink 78 "name": "Home",
254b467… ragelink 79 "content": "# Welcome",
254b467… ragelink 80 "last_modified": datetime.now(UTC),
254b467… ragelink 81 "user": "dev",
254b467… ragelink 82 }
254b467… ragelink 83 defaults.update(overrides)
254b467… ragelink 84 return WikiPage(**defaults)
254b467… ragelink 85
254b467… ragelink 86
254b467… ragelink 87 # ---------------------------------------------------------------------------
254b467… ragelink 88 # Fixtures
254b467… ragelink 89 # ---------------------------------------------------------------------------
254b467… ragelink 90
254b467… ragelink 91
254b467… ragelink 92 @pytest.fixture
254b467… ragelink 93 def fossil_repo_obj(sample_project):
254b467… ragelink 94 """Return the auto-created FossilRepository for sample_project."""
254b467… ragelink 95 return FossilRepository.objects.get(project=sample_project, deleted_at__isnull=True)
254b467… ragelink 96
254b467… ragelink 97
254b467… ragelink 98 @pytest.fixture
254b467… ragelink 99 def mirror(fossil_repo_obj, admin_user):
254b467… ragelink 100 return GitMirror.objects.create(
254b467… ragelink 101 repository=fossil_repo_obj,
254b467… ragelink 102 git_remote_url="https://github.com/testorg/testrepo.git",
254b467… ragelink 103 auth_method="token",
254b467… ragelink 104 auth_credential="ghp_testtoken123",
254b467… ragelink 105 sync_direction="push",
254b467… ragelink 106 sync_mode="scheduled",
254b467… ragelink 107 sync_tickets=False,
254b467… ragelink 108 sync_wiki=False,
254b467… ragelink 109 created_by=admin_user,
254b467… ragelink 110 )
254b467… ragelink 111
254b467… ragelink 112
254b467… ragelink 113 @pytest.fixture
254b467… ragelink 114 def webhook(fossil_repo_obj, admin_user):
254b467… ragelink 115 return Webhook.objects.create(
254b467… ragelink 116 repository=fossil_repo_obj,
254b467… ragelink 117 url="https://hooks.example.com/test",
254b467… ragelink 118 secret="test-secret",
254b467… ragelink 119 events="all",
254b467… ragelink 120 is_active=True,
254b467… ragelink 121 created_by=admin_user,
254b467… ragelink 122 )
254b467… ragelink 123
254b467… ragelink 124
254b467… ragelink 125 # ===================================================================
254b467… ragelink 126 # fossil/tasks.py -- sync_repository_metadata
254b467… ragelink 127 # ===================================================================
254b467… ragelink 128
254b467… ragelink 129
254b467… ragelink 130 @pytest.mark.django_db
254b467… ragelink 131 class TestSyncRepositoryMetadata:
254b467… ragelink 132 """Test the sync_metadata periodic task."""
254b467… ragelink 133
254b467… ragelink 134 def test_updates_metadata_from_reader(self, fossil_repo_obj):
254b467… ragelink 135 """Task reads the .fossil file and updates checkin_count, file_size, project_code."""
254b467… ragelink 136 from fossil.tasks import sync_repository_metadata
254b467… ragelink 137
254b467… ragelink 138 timeline_entry = _make_timeline_entry()
254b467… ragelink 139 reader_mock = _make_reader_mock(
254b467… ragelink 140 get_checkin_count=42,
254b467… ragelink 141 get_timeline=[timeline_entry],
254b467… ragelink 142 get_project_code="abc123project",
254b467… ragelink 143 )
254b467… ragelink 144
254b467… ragelink 145 fake_stat = MagicMock()
254b467… ragelink 146 fake_stat.st_size = 98765
254b467… ragelink 147
254b467… ragelink 148 with (
254b467… ragelink 149 _disk_exists,
254b467… ragelink 150 patch("fossil.reader.FossilReader", reader_mock),
254b467… ragelink 151 patch.object(type(fossil_repo_obj), "full_path", new_callable=PropertyMock) as mock_path,
254b467… ragelink 152 ):
254b467… ragelink 153 mock_path.return_value = MagicMock()
254b467… ragelink 154 mock_path.return_value.stat.return_value = fake_stat
254b467… ragelink 155
254b467… ragelink 156 sync_repository_metadata()
254b467… ragelink 157
254b467… ragelink 158 fossil_repo_obj.refresh_from_db()
254b467… ragelink 159 assert fossil_repo_obj.checkin_count == 42
254b467… ragelink 160 assert fossil_repo_obj.file_size_bytes == 98765
254b467… ragelink 161 assert fossil_repo_obj.fossil_project_code == "abc123project"
254b467… ragelink 162 assert fossil_repo_obj.last_checkin_at == timeline_entry.timestamp
254b467… ragelink 163
254b467… ragelink 164 def test_skips_repo_not_on_disk(self, fossil_repo_obj):
254b467… ragelink 165 """Repos that don't exist on disk should be skipped without error."""
254b467… ragelink 166 from fossil.tasks import sync_repository_metadata
254b467… ragelink 167
254b467… ragelink 168 with patch(
254b467… ragelink 169 "fossil.models.FossilRepository.exists_on_disk",
254b467… ragelink 170 new_callable=lambda: property(lambda self: False),
254b467… ragelink 171 ):
254b467… ragelink 172 # Should complete without error
254b467… ragelink 173 sync_repository_metadata()
254b467… ragelink 174
254b467… ragelink 175 fossil_repo_obj.refresh_from_db()
254b467… ragelink 176 assert fossil_repo_obj.checkin_count == 0 # unchanged
254b467… ragelink 177
254b467… ragelink 178 def test_handles_empty_timeline(self, fossil_repo_obj):
254b467… ragelink 179 """When timeline is empty, last_checkin_at stays None."""
254b467… ragelink 180 from fossil.tasks import sync_repository_metadata
254b467… ragelink 181
254b467… ragelink 182 reader_mock = _make_reader_mock(
254b467… ragelink 183 get_checkin_count=0,
254b467… ragelink 184 get_timeline=[],
254b467… ragelink 185 get_project_code="proj-code",
254b467… ragelink 186 )
254b467… ragelink 187
254b467… ragelink 188 fake_stat = MagicMock()
254b467… ragelink 189 fake_stat.st_size = 1024
254b467… ragelink 190
254b467… ragelink 191 with (
254b467… ragelink 192 _disk_exists,
254b467… ragelink 193 patch("fossil.reader.FossilReader", reader_mock),
254b467… ragelink 194 patch.object(type(fossil_repo_obj), "full_path", new_callable=PropertyMock) as mock_path,
254b467… ragelink 195 ):
254b467… ragelink 196 mock_path.return_value = MagicMock()
254b467… ragelink 197 mock_path.return_value.stat.return_value = fake_stat
254b467… ragelink 198
254b467… ragelink 199 sync_repository_metadata()
254b467… ragelink 200
254b467… ragelink 201 fossil_repo_obj.refresh_from_db()
254b467… ragelink 202 assert fossil_repo_obj.last_checkin_at is None
254b467… ragelink 203
254b467… ragelink 204 def test_handles_reader_exception(self, fossil_repo_obj):
254b467… ragelink 205 """If FossilReader raises, the task logs and moves on."""
254b467… ragelink 206 from fossil.tasks import sync_repository_metadata
254b467… ragelink 207
254b467… ragelink 208 reader_mock = MagicMock(side_effect=Exception("corrupt db"))
254b467… ragelink 209
254b467… ragelink 210 with (
254b467… ragelink 211 _disk_exists,
254b467… ragelink 212 patch("fossil.reader.FossilReader", reader_mock),
254b467… ragelink 213 patch.object(type(fossil_repo_obj), "full_path", new_callable=PropertyMock) as mock_path,
254b467… ragelink 214 ):
254b467… ragelink 215 mock_path.return_value = MagicMock()
254b467… ragelink 216 mock_path.return_value.stat.side_effect = Exception("stat failed")
254b467… ragelink 217
254b467… ragelink 218 # Should not raise
254b467… ragelink 219 sync_repository_metadata()
254b467… ragelink 220
254b467… ragelink 221
254b467… ragelink 222 # ===================================================================
254b467… ragelink 223 # fossil/tasks.py -- create_snapshot
254b467… ragelink 224 # ===================================================================
254b467… ragelink 225
254b467… ragelink 226
254b467… ragelink 227 @pytest.mark.django_db
254b467… ragelink 228 class TestCreateSnapshot:
254b467… ragelink 229 """Test the create_snapshot task."""
254b467… ragelink 230
254b467… ragelink 231 def _mock_config(self, store_in_db=True):
254b467… ragelink 232 """Build a constance config mock with FOSSIL_STORE_IN_DB set."""
254b467… ragelink 233 cfg = MagicMock()
254b467… ragelink 234 cfg.FOSSIL_STORE_IN_DB = store_in_db
254b467… ragelink 235 return cfg
254b467… ragelink 236
254b467… ragelink 237 def test_creates_snapshot_when_enabled(self, fossil_repo_obj, tmp_path, settings):
254b467… ragelink 238 """Snapshot is created when FOSSIL_STORE_IN_DB is True."""
254b467… ragelink 239 from fossil.tasks import create_snapshot
254b467… ragelink 240
254b467… ragelink 241 # Ensure default file storage is configured for the test
254b467… ragelink 242 settings.STORAGES = {
254b467… ragelink 243 **settings.STORAGES,
254b467… ragelink 244 "default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
254b467… ragelink 245 }
254b467… ragelink 246 settings.MEDIA_ROOT = str(tmp_path / "media")
254b467… ragelink 247
254b467… ragelink 248 # Write a fake fossil file
254b467… ragelink 249 fossil_file = tmp_path / "test.fossil"
254b467… ragelink 250 fossil_file.write_bytes(b"FAKE FOSSIL DATA 12345")
254b467… ragelink 251
254b467… ragelink 252 with (
254b467… ragelink 253 patch("constance.config", self._mock_config(store_in_db=True)),
254b467… ragelink 254 patch.object(type(fossil_repo_obj), "full_path", new_callable=PropertyMock, return_value=fossil_file),
254b467… ragelink 255 _disk_exists,
254b467… ragelink 256 ):
254b467… ragelink 257 create_snapshot(fossil_repo_obj.pk, note="manual backup")
254b467… ragelink 258
254b467… ragelink 259 snapshot = FossilSnapshot.objects.filter(repository=fossil_repo_obj).first()
254b467… ragelink 260 assert snapshot is not None
254b467… ragelink 261 assert snapshot.note == "manual backup"
254b467… ragelink 262 assert snapshot.file_size_bytes == len(b"FAKE FOSSIL DATA 12345")
254b467… ragelink 263 assert snapshot.fossil_hash # should be a sha256 hex string
254b467… ragelink 264 assert len(snapshot.fossil_hash) == 64
254b467… ragelink 265
254b467… ragelink 266 def test_skips_when_store_in_db_disabled(self, fossil_repo_obj):
254b467… ragelink 267 """No snapshot created when FOSSIL_STORE_IN_DB is False."""
254b467… ragelink 268 from fossil.tasks import create_snapshot
254b467… ragelink 269
254b467… ragelink 270 with patch("constance.config", self._mock_config(store_in_db=False)):
254b467… ragelink 271 create_snapshot(fossil_repo_obj.pk, note="should not exist")
254b467… ragelink 272
254b467… ragelink 273 assert FossilSnapshot.objects.filter(repository=fossil_repo_obj).count() == 0
254b467… ragelink 274
254b467… ragelink 275 def test_skips_for_nonexistent_repo(self):
254b467… ragelink 276 """Returns early for a repository ID that doesn't exist."""
254b467… ragelink 277 from fossil.tasks import create_snapshot
254b467… ragelink 278
254b467… ragelink 279 with patch("constance.config", self._mock_config(store_in_db=True)):
254b467… ragelink 280 # Should not raise
254b467… ragelink 281 create_snapshot(99999, note="orphan")
254b467… ragelink 282
254b467… ragelink 283 assert FossilSnapshot.objects.count() == 0
254b467… ragelink 284
254b467… ragelink 285 def test_skips_when_not_on_disk(self, fossil_repo_obj):
254b467… ragelink 286 """Returns early when the file doesn't exist on disk."""
254b467… ragelink 287 from fossil.tasks import create_snapshot
254b467… ragelink 288
254b467… ragelink 289 with (
254b467… ragelink 290 patch("constance.config", self._mock_config(store_in_db=True)),
254b467… ragelink 291 patch(
254b467… ragelink 292 "fossil.models.FossilRepository.exists_on_disk",
254b467… ragelink 293 new_callable=lambda: property(lambda self: False),
254b467… ragelink 294 ),
254b467… ragelink 295 ):
254b467… ragelink 296 create_snapshot(fossil_repo_obj.pk)
254b467… ragelink 297
254b467… ragelink 298 assert FossilSnapshot.objects.filter(repository=fossil_repo_obj).count() == 0
254b467… ragelink 299
254b467… ragelink 300 def test_skips_duplicate_hash(self, fossil_repo_obj, tmp_path, admin_user):
254b467… ragelink 301 """If latest snapshot has the same hash, no new snapshot is created."""
254b467… ragelink 302 import hashlib
254b467… ragelink 303
254b467… ragelink 304 from fossil.tasks import create_snapshot
254b467… ragelink 305
254b467… ragelink 306 fossil_file = tmp_path / "test.fossil"
254b467… ragelink 307 data = b"SAME DATA TWICE"
254b467… ragelink 308 fossil_file.write_bytes(data)
254b467… ragelink 309 sha = hashlib.sha256(data).hexdigest()
254b467… ragelink 310
254b467… ragelink 311 # Create an existing snapshot with the same hash
254b467… ragelink 312 FossilSnapshot.objects.create(
254b467… ragelink 313 repository=fossil_repo_obj,
254b467… ragelink 314 file_size_bytes=len(data),
254b467… ragelink 315 fossil_hash=sha,
254b467… ragelink 316 note="previous",
254b467… ragelink 317 created_by=admin_user,
254b467… ragelink 318 )
254b467… ragelink 319
254b467… ragelink 320 with (
254b467… ragelink 321 patch("constance.config", self._mock_config(store_in_db=True)),
254b467… ragelink 322 patch.object(type(fossil_repo_obj), "full_path", new_callable=PropertyMock, return_value=fossil_file),
254b467… ragelink 323 _disk_exists,
254b467… ragelink 324 ):
254b467… ragelink 325 create_snapshot(fossil_repo_obj.pk, note="duplicate check")
254b467… ragelink 326
254b467… ragelink 327 # Still only one snapshot
254b467… ragelink 328 assert FossilSnapshot.objects.filter(repository=fossil_repo_obj).count() == 1
254b467… ragelink 329
254b467… ragelink 330
254b467… ragelink 331 # ===================================================================
254b467… ragelink 332 # fossil/tasks.py -- check_upstream_updates
254b467… ragelink 333 # ===================================================================
254b467… ragelink 334
254b467… ragelink 335
254b467… ragelink 336 @pytest.mark.django_db
254b467… ragelink 337 class TestCheckUpstreamUpdates:
254b467… ragelink 338 """Test the check_upstream periodic task."""
254b467… ragelink 339
254b467… ragelink 340 def test_pulls_and_updates_metadata_when_artifacts_received(self, fossil_repo_obj):
254b467… ragelink 341 """When upstream has new artifacts, metadata is updated after pull."""
254b467… ragelink 342 from fossil.tasks import check_upstream_updates
254b467… ragelink 343
254b467… ragelink 344 # Give the repo a remote URL
254b467… ragelink 345 fossil_repo_obj.remote_url = "https://fossil.example.com/repo"
254b467… ragelink 346 fossil_repo_obj.save(update_fields=["remote_url"])
254b467… ragelink 347
254b467… ragelink 348 cli_mock = MagicMock()
254b467… ragelink 349 cli_mock.is_available.return_value = True
254b467… ragelink 350 cli_mock.pull.return_value = {"success": True, "artifacts_received": 5, "message": "received: 5"}
254b467… ragelink 351
254b467… ragelink 352 timeline_entry = _make_timeline_entry()
254b467… ragelink 353 reader_mock = _make_reader_mock(
254b467… ragelink 354 get_checkin_count=50,
254b467… ragelink 355 get_timeline=[timeline_entry],
254b467… ragelink 356 )
254b467… ragelink 357
254b467… ragelink 358 fake_stat = MagicMock()
254b467… ragelink 359 fake_stat.st_size = 200000
254b467… ragelink 360
254b467… ragelink 361 with (
254b467… ragelink 362 _disk_exists,
254b467… ragelink 363 patch("fossil.cli.FossilCLI", return_value=cli_mock),
254b467… ragelink 364 patch("fossil.reader.FossilReader", reader_mock),
254b467… ragelink 365 patch.object(type(fossil_repo_obj), "full_path", new_callable=PropertyMock) as mock_path,
254b467… ragelink 366 ):
254b467… ragelink 367 mock_path.return_value = MagicMock()
254b467… ragelink 368 mock_path.return_value.stat.return_value = fake_stat
254b467… ragelink 369
254b467… ragelink 370 check_upstream_updates()
254b467… ragelink 371
254b467… ragelink 372 fossil_repo_obj.refresh_from_db()
254b467… ragelink 373 assert fossil_repo_obj.upstream_artifacts_available == 5
254b467… ragelink 374 assert fossil_repo_obj.checkin_count == 50
254b467… ragelink 375 assert fossil_repo_obj.last_sync_at is not None
254b467… ragelink 376 assert fossil_repo_obj.file_size_bytes == 200000
254b467… ragelink 377
254b467… ragelink 378 def test_zero_artifacts_resets_counter(self, fossil_repo_obj):
254b467… ragelink 379 """When pull returns zero artifacts, upstream count is reset."""
254b467… ragelink 380 from fossil.tasks import check_upstream_updates
254b467… ragelink 381
254b467… ragelink 382 fossil_repo_obj.remote_url = "https://fossil.example.com/repo"
254b467… ragelink 383 fossil_repo_obj.upstream_artifacts_available = 10
254b467… ragelink 384 fossil_repo_obj.save(update_fields=["remote_url", "upstream_artifacts_available"])
254b467… ragelink 385
254b467… ragelink 386 cli_mock = MagicMock()
254b467… ragelink 387 cli_mock.is_available.return_value = True
254b467… ragelink 388 cli_mock.pull.return_value = {"success": True, "artifacts_received": 0, "message": "received: 0"}
254b467… ragelink 389
254b467… ragelink 390 with (
254b467… ragelink 391 _disk_exists,
254b467… ragelink 392 patch("fossil.cli.FossilCLI", return_value=cli_mock),
254b467… ragelink 393 ):
254b467… ragelink 394 check_upstream_updates()
254b467… ragelink 395
254b467… ragelink 396 fossil_repo_obj.refresh_from_db()
254b467… ragelink 397 assert fossil_repo_obj.upstream_artifacts_available == 0
254b467… ragelink 398 assert fossil_repo_obj.last_sync_at is not None
254b467… ragelink 399
254b467… ragelink 400 def test_skips_when_fossil_not_available(self, fossil_repo_obj):
254b467… ragelink 401 """When fossil binary is not available, task returns early."""
254b467… ragelink 402 from fossil.tasks import check_upstream_updates
254b467… ragelink 403
254b467… ragelink 404 fossil_repo_obj.remote_url = "https://fossil.example.com/repo"
254b467… ragelink 405 fossil_repo_obj.save(update_fields=["remote_url"])
254b467… ragelink 406
254b467… ragelink 407 cli_mock = MagicMock()
254b467… ragelink 408 cli_mock.is_available.return_value = False
254b467… ragelink 409
254b467… ragelink 410 with patch("fossil.cli.FossilCLI", return_value=cli_mock):
254b467… ragelink 411 check_upstream_updates()
254b467… ragelink 412
254b467… ragelink 413 fossil_repo_obj.refresh_from_db()
254b467… ragelink 414 assert fossil_repo_obj.last_sync_at is None
254b467… ragelink 415
254b467… ragelink 416 def test_handles_pull_exception(self, fossil_repo_obj):
254b467… ragelink 417 """If pull raises an exception, the task logs and continues."""
254b467… ragelink 418 from fossil.tasks import check_upstream_updates
254b467… ragelink 419
254b467… ragelink 420 fossil_repo_obj.remote_url = "https://fossil.example.com/repo"
254b467… ragelink 421 fossil_repo_obj.save(update_fields=["remote_url"])
254b467… ragelink 422
254b467… ragelink 423 cli_mock = MagicMock()
254b467… ragelink 424 cli_mock.is_available.return_value = True
254b467… ragelink 425 cli_mock.pull.side_effect = Exception("network error")
254b467… ragelink 426
254b467… ragelink 427 with (
254b467… ragelink 428 _disk_exists,
254b467… ragelink 429 patch("fossil.cli.FossilCLI", return_value=cli_mock),
254b467… ragelink 430 ):
254b467… ragelink 431 # Should not raise
254b467… ragelink 432 check_upstream_updates()
254b467… ragelink 433
254b467… ragelink 434 def test_skips_repos_without_remote_url(self, fossil_repo_obj):
254b467… ragelink 435 """Repos with empty remote_url are excluded from the queryset."""
254b467… ragelink 436 from fossil.tasks import check_upstream_updates
254b467… ragelink 437
254b467… ragelink 438 # fossil_repo_obj.remote_url is "" by default
254b467… ragelink 439 cli_mock = MagicMock()
254b467… ragelink 440 cli_mock.is_available.return_value = True
254b467… ragelink 441
254b467… ragelink 442 with patch("fossil.cli.FossilCLI", return_value=cli_mock):
254b467… ragelink 443 check_upstream_updates()
254b467… ragelink 444
254b467… ragelink 445 # pull should never be called since no repos have remote_url
254b467… ragelink 446 cli_mock.pull.assert_not_called()
254b467… ragelink 447
254b467… ragelink 448
254b467… ragelink 449 # ===================================================================
254b467… ragelink 450 # fossil/tasks.py -- run_git_sync
254b467… ragelink 451 # ===================================================================
254b467… ragelink 452
254b467… ragelink 453
254b467… ragelink 454 @pytest.mark.django_db
254b467… ragelink 455 class TestRunGitSync:
254b467… ragelink 456 """Test the run_git_sync task for Git mirror operations."""
254b467… ragelink 457
254b467… ragelink 458 @staticmethod
254b467… ragelink 459 def _git_config():
254b467… ragelink 460 cfg = MagicMock()
254b467… ragelink 461 cfg.GIT_MIRROR_DIR = "/tmp/git-mirrors"
254b467… ragelink 462 return cfg
254b467… ragelink 463
254b467… ragelink 464 def test_successful_sync_creates_log(self, mirror, fossil_repo_obj):
254b467… ragelink 465 """A successful git export updates the mirror and creates a success log."""
254b467… ragelink 466 from fossil.tasks import run_git_sync
254b467… ragelink 467
254b467… ragelink 468 cli_mock = MagicMock()
254b467… ragelink 469 cli_mock.is_available.return_value = True
254b467… ragelink 470 cli_mock.git_export.return_value = {"success": True, "message": "Exported 10 commits"}
254b467… ragelink 471
254b467… ragelink 472 with (
254b467… ragelink 473 _disk_exists,
254b467… ragelink 474 patch("fossil.cli.FossilCLI", return_value=cli_mock),
254b467… ragelink 475 patch("constance.config", self._git_config()),
254b467… ragelink 476 ):
254b467… ragelink 477 run_git_sync(mirror_id=mirror.pk)
254b467… ragelink 478
254b467… ragelink 479 log = SyncLog.objects.get(mirror=mirror)
254b467… ragelink 480 assert log.status == "success"
254b467… ragelink 481 assert log.triggered_by == "manual"
254b467… ragelink 482 assert log.completed_at is not None
254b467… ragelink 483
254b467… ragelink 484 mirror.refresh_from_db()
254b467… ragelink 485 assert mirror.last_sync_status == "success"
254b467… ragelink 486 assert mirror.total_syncs == 1
254b467… ragelink 487
254b467… ragelink 488 def test_failed_sync_records_failure(self, mirror, fossil_repo_obj):
254b467… ragelink 489 """A failed git export records the failure in log and mirror."""
254b467… ragelink 490 from fossil.tasks import run_git_sync
254b467… ragelink 491
254b467… ragelink 492 cli_mock = MagicMock()
254b467… ragelink 493 cli_mock.is_available.return_value = True
254b467… ragelink 494 cli_mock.git_export.return_value = {"success": False, "message": "Push rejected by remote"}
254b467… ragelink 495
254b467… ragelink 496 with (
254b467… ragelink 497 _disk_exists,
254b467… ragelink 498 patch("fossil.cli.FossilCLI", return_value=cli_mock),
254b467… ragelink 499 patch("constance.config", self._git_config()),
254b467… ragelink 500 ):
254b467… ragelink 501 run_git_sync(mirror_id=mirror.pk)
254b467… ragelink 502
254b467… ragelink 503 log = SyncLog.objects.get(mirror=mirror)
254b467… ragelink 504 assert log.status == "failed"
254b467… ragelink 505
254b467… ragelink 506 mirror.refresh_from_db()
254b467… ragelink 507 assert mirror.last_sync_status == "failed"
254b467… ragelink 508
254b467… ragelink 509 def test_exception_during_sync_creates_failed_log(self, mirror, fossil_repo_obj):
254b467… ragelink 510 """An unexpected exception during sync records a failed log."""
254b467… ragelink 511 from fossil.tasks import run_git_sync
254b467… ragelink 512
254b467… ragelink 513 cli_mock = MagicMock()
254b467… ragelink 514 cli_mock.is_available.return_value = True
254b467… ragelink 515 cli_mock.git_export.side_effect = RuntimeError("subprocess crash")
254b467… ragelink 516
254b467… ragelink 517 with (
254b467… ragelink 518 _disk_exists,
254b467… ragelink 519 patch("fossil.cli.FossilCLI", return_value=cli_mock),
254b467… ragelink 520 patch("constance.config", self._git_config()),
254b467… ragelink 521 ):
254b467… ragelink 522 run_git_sync(mirror_id=mirror.pk)
254b467… ragelink 523
254b467… ragelink 524 log = SyncLog.objects.get(mirror=mirror)
254b467… ragelink 525 assert log.status == "failed"
254b467… ragelink 526 assert "Unexpected error" in log.message
254b467… ragelink 527
254b467… ragelink 528 def test_credential_redacted_from_log(self, mirror, fossil_repo_obj):
254b467… ragelink 529 """Auth credentials must not appear in sync log messages."""
254b467… ragelink 530 from fossil.tasks import run_git_sync
254b467… ragelink 531
254b467… ragelink 532 token = mirror.auth_credential
254b467… ragelink 533 cli_mock = MagicMock()
254b467… ragelink 534 cli_mock.is_available.return_value = True
254b467… ragelink 535 cli_mock.git_export.return_value = {"success": True, "message": f"Push to remote with {token} auth"}
254b467… ragelink 536
254b467… ragelink 537 with (
254b467… ragelink 538 _disk_exists,
254b467… ragelink 539 patch("fossil.cli.FossilCLI", return_value=cli_mock),
254b467… ragelink 540 patch("constance.config", self._git_config()),
254b467… ragelink 541 ):
254b467… ragelink 542 run_git_sync(mirror_id=mirror.pk)
254b467… ragelink 543
254b467… ragelink 544 log = SyncLog.objects.get(mirror=mirror)
254b467… ragelink 545 assert token not in log.message
254b467… ragelink 546 assert "[REDACTED]" in log.message
254b467… ragelink 547
254b467… ragelink 548 def test_skips_when_fossil_not_available(self, mirror):
254b467… ragelink 549 """When fossil binary is not available, task returns early."""
254b467… ragelink 550 from fossil.tasks import run_git_sync
254b467… ragelink 551
254b467… ragelink 552 cli_mock = MagicMock()
254b467… ragelink 553 cli_mock.is_available.return_value = False
254b467… ragelink 554
254b467… ragelink 555 with patch("fossil.cli.FossilCLI", return_value=cli_mock):
254b467… ragelink 556 run_git_sync(mirror_id=mirror.pk)
254b467… ragelink 557
254b467… ragelink 558 assert SyncLog.objects.count() == 0
254b467… ragelink 559
254b467… ragelink 560 def test_skips_disabled_mirrors(self, fossil_repo_obj, admin_user):
254b467… ragelink 561 """Mirrors with sync_mode='disabled' are excluded."""
254b467… ragelink 562 from fossil.tasks import run_git_sync
254b467… ragelink 563
254b467… ragelink 564 disabled_mirror = GitMirror.objects.create(
254b467… ragelink 565 repository=fossil_repo_obj,
254b467… ragelink 566 git_remote_url="https://github.com/test/disabled.git",
254b467… ragelink 567 sync_mode="disabled",
254b467… ragelink 568 created_by=admin_user,
254b467… ragelink 569 )
254b467… ragelink 570
254b467… ragelink 571 cli_mock = MagicMock()
254b467… ragelink 572 cli_mock.is_available.return_value = True
254b467… ragelink 573
254b467… ragelink 574 with (
254b467… ragelink 575 _disk_exists,
254b467… ragelink 576 patch("fossil.cli.FossilCLI", return_value=cli_mock),
254b467… ragelink 577 patch("constance.config", self._git_config()),
254b467… ragelink 578 ):
254b467… ragelink 579 run_git_sync()
254b467… ragelink 580
254b467… ragelink 581 assert SyncLog.objects.filter(mirror=disabled_mirror).count() == 0
254b467… ragelink 582
254b467… ragelink 583 def test_chains_ticket_and_wiki_sync_when_enabled(self, mirror, fossil_repo_obj):
254b467… ragelink 584 """Successful sync chains ticket/wiki sync tasks when enabled."""
254b467… ragelink 585 from fossil.tasks import run_git_sync
254b467… ragelink 586
254b467… ragelink 587 mirror.sync_tickets = True
254b467… ragelink 588 mirror.sync_wiki = True
254b467… ragelink 589 mirror.save(update_fields=["sync_tickets", "sync_wiki"])
254b467… ragelink 590
254b467… ragelink 591 cli_mock = MagicMock()
254b467… ragelink 592 cli_mock.is_available.return_value = True
254b467… ragelink 593 cli_mock.git_export.return_value = {"success": True, "message": "ok"}
254b467… ragelink 594
254b467… ragelink 595 with (
254b467… ragelink 596 _disk_exists,
254b467… ragelink 597 patch("fossil.cli.FossilCLI", return_value=cli_mock),
254b467… ragelink 598 patch("constance.config", self._git_config()),
254b467… ragelink 599 patch("fossil.tasks.sync_tickets_to_github") as mock_tickets,
254b467… ragelink 600 patch("fossil.tasks.sync_wiki_to_github") as mock_wiki,
254b467… ragelink 601 ):
254b467… ragelink 602 run_git_sync(mirror_id=mirror.pk)
254b467… ragelink 603
254b467… ragelink 604 mock_tickets.delay.assert_called_once_with(mirror.id)
254b467… ragelink 605 mock_wiki.delay.assert_called_once_with(mirror.id)
254b467… ragelink 606
254b467… ragelink 607 def test_schedule_triggered_by(self, mirror, fossil_repo_obj):
254b467… ragelink 608 """When called without mirror_id, triggered_by is 'schedule'."""
254b467… ragelink 609 from fossil.tasks import run_git_sync
254b467… ragelink 610
254b467… ragelink 611 cli_mock = MagicMock()
254b467… ragelink 612 cli_mock.is_available.return_value = True
254b467… ragelink 613 cli_mock.git_export.return_value = {"success": True, "message": "ok"}
254b467… ragelink 614
254b467… ragelink 615 with (
254b467… ragelink 616 _disk_exists,
254b467… ragelink 617 patch("fossil.cli.FossilCLI", return_value=cli_mock),
254b467… ragelink 618 patch("constance.config", self._git_config()),
254b467… ragelink 619 ):
254b467… ragelink 620 run_git_sync() # no mirror_id
254b467… ragelink 621
254b467… ragelink 622 log = SyncLog.objects.get(mirror=mirror)
254b467… ragelink 623 assert log.triggered_by == "schedule"
254b467… ragelink 624
254b467… ragelink 625
254b467… ragelink 626 # ===================================================================
254b467… ragelink 627 # fossil/tasks.py -- dispatch_notifications
254b467… ragelink 628 # ===================================================================
254b467… ragelink 629
254b467… ragelink 630
254b467… ragelink 631 @pytest.mark.django_db
254b467… ragelink 632 class TestDispatchNotifications:
254b467… ragelink 633 """Test the dispatch_notifications periodic task."""
254b467… ragelink 634
254b467… ragelink 635 def test_creates_notifications_for_recent_events(self, fossil_repo_obj, sample_project, admin_user):
254b467… ragelink 636 """Recent timeline events create notifications for project watchers."""
254b467… ragelink 637 from fossil.tasks import dispatch_notifications
254b467… ragelink 638
254b467… ragelink 639 # Create a watcher
254b467… ragelink 640 ProjectWatch.objects.create(
254b467… ragelink 641 project=sample_project,
254b467… ragelink 642 user=admin_user,
254b467… ragelink 643 email_enabled=True,
254b467… ragelink 644 created_by=admin_user,
254b467… ragelink 645 )
254b467… ragelink 646 NotificationPreference.objects.create(user=admin_user, delivery_mode="immediate")
254b467… ragelink 647
254b467… ragelink 648 recent_entry = _make_timeline_entry(
254b467… ragelink 649 event_type="ci",
254b467… ragelink 650 comment="Added new feature",
254b467… ragelink 651 user="dev",
254b467… ragelink 652 )
254b467… ragelink 653
254b467… ragelink 654 reader_mock = _make_reader_mock(get_timeline=[recent_entry])
254b467… ragelink 655
254b467… ragelink 656 with (
254b467… ragelink 657 _disk_exists,
254b467… ragelink 658 patch("fossil.reader.FossilReader", reader_mock),
254b467… ragelink 659 patch("django.core.mail.send_mail"),
254b467… ragelink 660 patch("django.template.loader.render_to_string", return_value="<html>test</html>"),
254b467… ragelink 661 ):
254b467… ragelink 662 dispatch_notifications()
254b467… ragelink 663
254b467… ragelink 664 notif = Notification.objects.filter(user=admin_user, project=sample_project).first()
254b467… ragelink 665 assert notif is not None
254b467… ragelink 666 assert "Added new feature" in notif.title or "dev" in notif.title
254b467… ragelink 667
254b467… ragelink 668 def test_skips_when_no_watched_projects(self, fossil_repo_obj):
254b467… ragelink 669 """Task returns early when nobody is watching any projects."""
254b467… ragelink 670 from fossil.tasks import dispatch_notifications
254b467… ragelink 671
254b467… ragelink 672 # No watches exist, so task should complete immediately
254b467… ragelink 673 dispatch_notifications()
254b467… ragelink 674 assert Notification.objects.count() == 0
254b467… ragelink 675
254b467… ragelink 676 def test_skips_repo_not_on_disk(self, fossil_repo_obj, sample_project, admin_user):
254b467… ragelink 677 """Repos that don't exist on disk are skipped."""
254b467… ragelink 678 from fossil.tasks import dispatch_notifications
254b467… ragelink 679
254b467… ragelink 680 ProjectWatch.objects.create(
254b467… ragelink 681 project=sample_project,
254b467… ragelink 682 user=admin_user,
254b467… ragelink 683 email_enabled=True,
254b467… ragelink 684 created_by=admin_user,
254b467… ragelink 685 )
254b467… ragelink 686
254b467… ragelink 687 with patch(
254b467… ragelink 688 "fossil.models.FossilRepository.exists_on_disk",
254b467… ragelink 689 new_callable=lambda: property(lambda self: False),
254b467… ragelink 690 ):
254b467… ragelink 691 dispatch_notifications()
254b467… ragelink 692
254b467… ragelink 693 assert Notification.objects.count() == 0
254b467… ragelink 694
254b467… ragelink 695 def test_handles_reader_exception(self, fossil_repo_obj, sample_project, admin_user):
254b467… ragelink 696 """Reader exceptions are caught and logged per-repo."""
254b467… ragelink 697 from fossil.tasks import dispatch_notifications
254b467… ragelink 698
254b467… ragelink 699 ProjectWatch.objects.create(
254b467… ragelink 700 project=sample_project,
254b467… ragelink 701 user=admin_user,
254b467… ragelink 702 email_enabled=True,
254b467… ragelink 703 created_by=admin_user,
254b467… ragelink 704 )
254b467… ragelink 705
254b467… ragelink 706 reader_mock = MagicMock(side_effect=Exception("corrupt db"))
254b467… ragelink 707
254b467… ragelink 708 with (
254b467… ragelink 709 _disk_exists,
254b467… ragelink 710 patch("fossil.reader.FossilReader", reader_mock),
254b467… ragelink 711 ):
254b467… ragelink 712 # Should not raise
254b467… ragelink 713 dispatch_notifications()
254b467… ragelink 714
254b467… ragelink 715
254b467… ragelink 716 # ===================================================================
254b467… ragelink 717 # fossil/tasks.py -- sync_tickets_to_github
254b467… ragelink 718 # ===================================================================
254b467… ragelink 719
254b467… ragelink 720
254b467… ragelink 721 @pytest.mark.django_db
254b467… ragelink 722 class TestSyncTicketsToGithub:
254b467… ragelink 723 """Test the sync_tickets_to_github task."""
254b467… ragelink 724
254b467… ragelink 725 def test_creates_new_github_issues(self, mirror, fossil_repo_obj):
254b467… ragelink 726 """Unsynced tickets create new GitHub issues with mappings."""
254b467… ragelink 727 from fossil.tasks import sync_tickets_to_github
254b467… ragelink 728
254b467… ragelink 729 ticket = _make_ticket(uuid="new-ticket-uuid-001")
254b467… ragelink 730 detail = _make_ticket(uuid="new-ticket-uuid-001")
254b467… ragelink 731
254b467… ragelink 732 reader_mock = _make_reader_mock(
254b467… ragelink 733 get_tickets=[ticket],
254b467… ragelink 734 get_ticket_detail=detail,
254b467… ragelink 735 get_ticket_comments=[],
254b467… ragelink 736 )
254b467… ragelink 737
254b467… ragelink 738 gh_client_mock = MagicMock()
254b467… ragelink 739 gh_client_mock.create_issue.return_value = {"number": 42, "url": "https://github.com/test/42", "error": ""}
254b467… ragelink 740
254b467… ragelink 741 with (
254b467… ragelink 742 _disk_exists,
254b467… ragelink 743 patch("fossil.reader.FossilReader", reader_mock),
254b467… ragelink 744 patch("fossil.github_api.GitHubClient", return_value=gh_client_mock),
254b467… ragelink 745 patch("fossil.github_api.parse_github_repo", return_value=("testorg", "testrepo")),
254b467… ragelink 746 ):
254b467… ragelink 747 sync_tickets_to_github(mirror.pk)
254b467… ragelink 748
254b467… ragelink 749 mapping = TicketSyncMapping.objects.get(mirror=mirror, fossil_ticket_uuid="new-ticket-uuid-001")
254b467… ragelink 750 assert mapping.github_issue_number == 42
254b467… ragelink 751
254b467… ragelink 752 log = SyncLog.objects.get(mirror=mirror, triggered_by="ticket_sync")
254b467… ragelink 753 assert log.status == "success"
254b467… ragelink 754 assert "1 tickets" in log.message
254b467… ragelink 755
254b467… ragelink 756 def test_updates_existing_github_issue(self, mirror, fossil_repo_obj):
254b467… ragelink 757 """Already-synced tickets with changed status update the existing issue."""
254b467… ragelink 758 from fossil.tasks import sync_tickets_to_github
254b467… ragelink 759
254b467… ragelink 760 # Pre-existing mapping with old status
254b467… ragelink 761 TicketSyncMapping.objects.create(
254b467… ragelink 762 mirror=mirror,
254b467… ragelink 763 fossil_ticket_uuid="existing-ticket-001",
254b467… ragelink 764 github_issue_number=10,
254b467… ragelink 765 fossil_status="open",
254b467… ragelink 766 )
254b467… ragelink 767
254b467… ragelink 768 ticket = _make_ticket(uuid="existing-ticket-001", status="closed")
254b467… ragelink 769 detail = _make_ticket(uuid="existing-ticket-001", status="closed")
254b467… ragelink 770
254b467… ragelink 771 reader_mock = _make_reader_mock(
254b467… ragelink 772 get_tickets=[ticket],
254b467… ragelink 773 get_ticket_detail=detail,
254b467… ragelink 774 get_ticket_comments=[],
254b467… ragelink 775 )
254b467… ragelink 776
254b467… ragelink 777 gh_client_mock = MagicMock()
254b467… ragelink 778 gh_client_mock.update_issue.return_value = {"success": True, "error": ""}
254b467… ragelink 779
254b467… ragelink 780 with (
254b467… ragelink 781 _disk_exists,
254b467… ragelink 782 patch("fossil.reader.FossilReader", reader_mock),
254b467… ragelink 783 patch("fossil.github_api.GitHubClient", return_value=gh_client_mock),
254b467… ragelink 784 patch("fossil.github_api.parse_github_repo", return_value=("testorg", "testrepo")),
254b467… ragelink 785 ):
254b467… ragelink 786 sync_tickets_to_github(mirror.pk)
254b467… ragelink 787
254b467… ragelink 788 mapping = TicketSyncMapping.objects.get(mirror=mirror, fossil_ticket_uuid="existing-ticket-001")
254b467… ragelink 789 assert mapping.fossil_status == "closed"
254b467… ragelink 790
254b467… ragelink 791 gh_client_mock.update_issue.assert_called_once()
254b467… ragelink 792
254b467… ragelink 793 def test_skips_already_synced_same_status(self, mirror, fossil_repo_obj):
254b467… ragelink 794 """Tickets already synced with the same status are skipped."""
254b467… ragelink 795 from fossil.tasks import sync_tickets_to_github
254b467… ragelink 796
254b467… ragelink 797 TicketSyncMapping.objects.create(
254b467… ragelink 798 mirror=mirror,
254b467… ragelink 799 fossil_ticket_uuid="synced-ticket-001",
254b467… ragelink 800 github_issue_number=5,
254b467… ragelink 801 fossil_status="open",
254b467… ragelink 802 )
254b467… ragelink 803
254b467… ragelink 804 ticket = _make_ticket(uuid="synced-ticket-001", status="open")
254b467… ragelink 805
254b467… ragelink 806 reader_mock = _make_reader_mock(get_tickets=[ticket])
254b467… ragelink 807
254b467… ragelink 808 gh_client_mock = MagicMock()
254b467… ragelink 809
254b467… ragelink 810 with (
254b467… ragelink 811 _disk_exists,
254b467… ragelink 812 patch("fossil.reader.FossilReader", reader_mock),
254b467… ragelink 813 patch("fossil.github_api.GitHubClient", return_value=gh_client_mock),
254b467… ragelink 814 patch("fossil.github_api.parse_github_repo", return_value=("testorg", "testrepo")),
254b467… ragelink 815 ):
254b467… ragelink 816 sync_tickets_to_github(mirror.pk)
254b467… ragelink 817
254b467… ragelink 818 # Neither create nor update called
254b467… ragelink 819 gh_client_mock.create_issue.assert_not_called()
254b467… ragelink 820 gh_client_mock.update_issue.assert_not_called()
254b467… ragelink 821
254b467… ragelink 822 def test_returns_early_for_deleted_mirror(self):
254b467… ragelink 823 """Task exits gracefully when mirror doesn't exist."""
254b467… ragelink 824 from fossil.tasks import sync_tickets_to_github
254b467… ragelink 825
254b467… ragelink 826 sync_tickets_to_github(99999)
254b467… ragelink 827 assert SyncLog.objects.count() == 0
254b467… ragelink 828
254b467… ragelink 829 def test_returns_early_when_no_auth_token(self, mirror, fossil_repo_obj):
254b467… ragelink 830 """Task warns and exits when mirror has no auth_credential."""
254b467… ragelink 831 from fossil.tasks import sync_tickets_to_github
254b467… ragelink 832
254b467… ragelink 833 mirror.auth_credential = ""
254b467… ragelink 834 mirror.save(update_fields=["auth_credential"])
254b467… ragelink 835
254b467… ragelink 836 with (
254b467… ragelink 837 _disk_exists,
254b467… ragelink 838 patch("fossil.github_api.parse_github_repo", return_value=("testorg", "testrepo")),
254b467… ragelink 839 ):
254b467… ragelink 840 sync_tickets_to_github(mirror.pk)
254b467… ragelink 841
254b467… ragelink 842 # A log is not created because we return before SyncLog.objects.create
254b467… ragelink 843 assert SyncLog.objects.filter(mirror=mirror, triggered_by="ticket_sync").count() == 0
254b467… ragelink 844
254b467… ragelink 845 def test_returns_early_when_url_not_parseable(self, mirror, fossil_repo_obj):
254b467… ragelink 846 """Task exits when git_remote_url can't be parsed to owner/repo."""
254b467… ragelink 847 from fossil.tasks import sync_tickets_to_github
254b467… ragelink 848
254b467… ragelink 849 with (
254b467… ragelink 850 _disk_exists,
254b467… ragelink 851 patch("fossil.github_api.parse_github_repo", return_value=None),
254b467… ragelink 852 ):
254b467… ragelink 853 sync_tickets_to_github(mirror.pk)
254b467… ragelink 854
254b467… ragelink 855 assert SyncLog.objects.filter(mirror=mirror, triggered_by="ticket_sync").count() == 0
254b467… ragelink 856
254b467… ragelink 857 def test_handles_exception_during_sync(self, mirror, fossil_repo_obj):
254b467… ragelink 858 """Unexpected exceptions are caught and logged."""
254b467… ragelink 859 from fossil.tasks import sync_tickets_to_github
254b467… ragelink 860
254b467… ragelink 861 reader_mock = MagicMock(side_effect=Exception("reader crash"))
254b467… ragelink 862
254b467… ragelink 863 with (
254b467… ragelink 864 _disk_exists,
254b467… ragelink 865 patch("fossil.reader.FossilReader", reader_mock),
254b467… ragelink 866 patch("fossil.github_api.GitHubClient"),
254b467… ragelink 867 patch("fossil.github_api.parse_github_repo", return_value=("testorg", "testrepo")),
254b467… ragelink 868 ):
254b467… ragelink 869 sync_tickets_to_github(mirror.pk)
254b467… ragelink 870
254b467… ragelink 871 log = SyncLog.objects.get(mirror=mirror, triggered_by="ticket_sync")
254b467… ragelink 872 assert log.status == "failed"
254b467… ragelink 873 assert "Unexpected error" in log.message
254b467… ragelink 874
254b467… ragelink 875 def test_create_issue_error_recorded(self, mirror, fossil_repo_obj):
254b467… ragelink 876 """When GitHub create_issue returns an error, it's recorded in the log."""
254b467… ragelink 877 from fossil.tasks import sync_tickets_to_github
254b467… ragelink 878
254b467… ragelink 879 ticket = _make_ticket(uuid="fail-create-001")
254b467… ragelink 880 detail = _make_ticket(uuid="fail-create-001")
254b467… ragelink 881
254b467… ragelink 882 reader_mock = _make_reader_mock(
254b467… ragelink 883 get_tickets=[ticket],
254b467… ragelink 884 get_ticket_detail=detail,
254b467… ragelink 885 get_ticket_comments=[],
254b467… ragelink 886 )
254b467… ragelink 887
254b467… ragelink 888 gh_client_mock = MagicMock()
254b467… ragelink 889 gh_client_mock.create_issue.return_value = {"number": 0, "url": "", "error": "HTTP 403: Forbidden"}
254b467… ragelink 890
254b467… ragelink 891 with (
254b467… ragelink 892 _disk_exists,
254b467… ragelink 893 patch("fossil.reader.FossilReader", reader_mock),
254b467… ragelink 894 patch("fossil.github_api.GitHubClient", return_value=gh_client_mock),
254b467… ragelink 895 patch("fossil.github_api.parse_github_repo", return_value=("testorg", "testrepo")),
254b467… ragelink 896 ):
254b467… ragelink 897 sync_tickets_to_github(mirror.pk)
254b467… ragelink 898
254b467… ragelink 899 log = SyncLog.objects.get(mirror=mirror, triggered_by="ticket_sync")
254b467… ragelink 900 assert log.status == "failed"
254b467… ragelink 901 assert "Errors" in log.message
254b467… ragelink 902
254b467… ragelink 903
254b467… ragelink 904 # ===================================================================
254b467… ragelink 905 # fossil/tasks.py -- sync_wiki_to_github
254b467… ragelink 906 # ===================================================================
254b467… ragelink 907
254b467… ragelink 908
254b467… ragelink 909 @pytest.mark.django_db
254b467… ragelink 910 class TestSyncWikiToGithub:
254b467… ragelink 911 """Test the sync_wiki_to_github task."""
254b467… ragelink 912
254b467… ragelink 913 def test_syncs_new_wiki_pages(self, mirror, fossil_repo_obj):
254b467… ragelink 914 """New wiki pages are pushed to GitHub and mappings created."""
254b467… ragelink 915 from fossil.tasks import sync_wiki_to_github
254b467… ragelink 916
254b467… ragelink 917 page_listing = _make_wiki_page(name="Home", content="")
254b467… ragelink 918 full_page = _make_wiki_page(name="Home", content="# Home\nWelcome to the wiki.")
254b467… ragelink 919
254b467… ragelink 920 reader_mock = _make_reader_mock(
254b467… ragelink 921 get_wiki_pages=[page_listing],
254b467… ragelink 922 get_wiki_page=full_page,
254b467… ragelink 923 )
254b467… ragelink 924
254b467… ragelink 925 gh_client_mock = MagicMock()
254b467… ragelink 926 gh_client_mock.create_or_update_file.return_value = {"success": True, "sha": "abc123", "error": ""}
254b467… ragelink 927
254b467… ragelink 928 with (
254b467… ragelink 929 _disk_exists,
254b467… ragelink 930 patch("fossil.reader.FossilReader", reader_mock),
254b467… ragelink 931 patch("fossil.github_api.GitHubClient", return_value=gh_client_mock),
254b467… ragelink 932 patch("fossil.github_api.parse_github_repo", return_value=("testorg", "testrepo")),
254b467… ragelink 933 ):
254b467… ragelink 934 sync_wiki_to_github(mirror.pk)
254b467… ragelink 935
254b467… ragelink 936 mapping = WikiSyncMapping.objects.get(mirror=mirror, fossil_page_name="Home")
254b467… ragelink 937 assert mapping.github_path == "wiki/Home.md"
254b467… ragelink 938 assert mapping.content_hash # should be a sha256 hex string
254b467… ragelink 939
254b467… ragelink 940 log = SyncLog.objects.get(mirror=mirror, triggered_by="wiki_sync")
254b467… ragelink 941 assert log.status == "success"
254b467… ragelink 942 assert "1 wiki pages" in log.message
254b467… ragelink 943
254b467… ragelink 944 def test_updates_existing_page_mapping(self, mirror, fossil_repo_obj):
254b467… ragelink 945 """Changed content updates the existing mapping hash."""
254b467… ragelink 946 from fossil.github_api import content_hash
254b467… ragelink 947 from fossil.tasks import sync_wiki_to_github
254b467… ragelink 948
254b467… ragelink 949 old_hash = content_hash("old content")
254b467… ragelink 950 WikiSyncMapping.objects.create(
254b467… ragelink 951 mirror=mirror,
254b467… ragelink 952 fossil_page_name="Changelog",
254b467… ragelink 953 content_hash=old_hash,
254b467… ragelink 954 github_path="wiki/Changelog.md",
254b467… ragelink 955 )
254b467… ragelink 956
254b467… ragelink 957 page_listing = _make_wiki_page(name="Changelog", content="")
254b467… ragelink 958 full_page = _make_wiki_page(name="Changelog", content="# Changelog\nv2.0 release")
254b467… ragelink 959
254b467… ragelink 960 reader_mock = _make_reader_mock(
254b467… ragelink 961 get_wiki_pages=[page_listing],
254b467… ragelink 962 get_wiki_page=full_page,
254b467… ragelink 963 )
254b467… ragelink 964
254b467… ragelink 965 gh_client_mock = MagicMock()
254b467… ragelink 966 gh_client_mock.create_or_update_file.return_value = {"success": True, "sha": "def456", "error": ""}
254b467… ragelink 967
254b467… ragelink 968 with (
254b467… ragelink 969 _disk_exists,
254b467… ragelink 970 patch("fossil.reader.FossilReader", reader_mock),
254b467… ragelink 971 patch("fossil.github_api.GitHubClient", return_value=gh_client_mock),
254b467… ragelink 972 patch("fossil.github_api.parse_github_repo", return_value=("testorg", "testrepo")),
254b467… ragelink 973 ):
254b467… ragelink 974 sync_wiki_to_github(mirror.pk)
254b467… ragelink 975
254b467… ragelink 976 mapping = WikiSyncMapping.objects.get(mirror=mirror, fossil_page_name="Changelog")
254b467… ragelink 977 new_hash = content_hash("# Changelog\nv2.0 release")
254b467… ragelink 978 assert mapping.content_hash == new_hash
254b467… ragelink 979
254b467… ragelink 980 def test_skips_unchanged_content(self, mirror, fossil_repo_obj):
254b467… ragelink 981 """Pages with unchanged content hash are not re-pushed."""
254b467… ragelink 982 from fossil.github_api import content_hash
254b467… ragelink 983 from fossil.tasks import sync_wiki_to_github
254b467… ragelink 984
254b467… ragelink 985 content = "# Home\nSame content."
254b467… ragelink 986 WikiSyncMapping.objects.create(
254b467… ragelink 987 mirror=mirror,
254b467… ragelink 988 fossil_page_name="Home",
254b467… ragelink 989 content_hash=content_hash(content),
254b467… ragelink 990 github_path="wiki/Home.md",
254b467… ragelink 991 )
254b467… ragelink 992
254b467… ragelink 993 page_listing = _make_wiki_page(name="Home", content="")
254b467… ragelink 994 full_page = _make_wiki_page(name="Home", content=content)
254b467… ragelink 995
254b467… ragelink 996 reader_mock = _make_reader_mock(
254b467… ragelink 997 get_wiki_pages=[page_listing],
254b467… ragelink 998 get_wiki_page=full_page,
254b467… ragelink 999 )
254b467… ragelink 1000
254b467… ragelink 1001 gh_client_mock = MagicMock()
254b467… ragelink 1002
254b467… ragelink 1003 with (
254b467… ragelink 1004 _disk_exists,
254b467… ragelink 1005 patch("fossil.reader.FossilReader", reader_mock),
254b467… ragelink 1006 patch("fossil.github_api.GitHubClient", return_value=gh_client_mock),
254b467… ragelink 1007 patch("fossil.github_api.parse_github_repo", return_value=("testorg", "testrepo")),
254b467… ragelink 1008 ):
254b467… ragelink 1009 sync_wiki_to_github(mirror.pk)
254b467… ragelink 1010
254b467… ragelink 1011 gh_client_mock.create_or_update_file.assert_not_called()
254b467… ragelink 1012
254b467… ragelink 1013 def test_skips_empty_page_content(self, mirror, fossil_repo_obj):
254b467… ragelink 1014 """Pages with empty content after stripping are skipped."""
254b467… ragelink 1015 from fossil.tasks import sync_wiki_to_github
254b467… ragelink 1016
254b467… ragelink 1017 page_listing = _make_wiki_page(name="Empty", content="")
254b467… ragelink 1018 full_page = _make_wiki_page(name="Empty", content=" \n ")
254b467… ragelink 1019
254b467… ragelink 1020 reader_mock = _make_reader_mock(
254b467… ragelink 1021 get_wiki_pages=[page_listing],
254b467… ragelink 1022 get_wiki_page=full_page,
254b467… ragelink 1023 )
254b467… ragelink 1024
254b467… ragelink 1025 gh_client_mock = MagicMock()
254b467… ragelink 1026
254b467… ragelink 1027 with (
254b467… ragelink 1028 _disk_exists,
254b467… ragelink 1029 patch("fossil.reader.FossilReader", reader_mock),
254b467… ragelink 1030 patch("fossil.github_api.GitHubClient", return_value=gh_client_mock),
254b467… ragelink 1031 patch("fossil.github_api.parse_github_repo", return_value=("testorg", "testrepo")),
254b467… ragelink 1032 ):
254b467… ragelink 1033 sync_wiki_to_github(mirror.pk)
254b467… ragelink 1034
254b467… ragelink 1035 gh_client_mock.create_or_update_file.assert_not_called()
254b467… ragelink 1036
254b467… ragelink 1037 def test_returns_early_for_deleted_mirror(self):
254b467… ragelink 1038 """Task exits for nonexistent mirror."""
254b467… ragelink 1039 from fossil.tasks import sync_wiki_to_github
254b467… ragelink 1040
254b467… ragelink 1041 sync_wiki_to_github(99999)
254b467… ragelink 1042 assert SyncLog.objects.count() == 0
254b467… ragelink 1043
254b467… ragelink 1044 def test_returns_early_when_no_auth_token(self, mirror, fossil_repo_obj):
254b467… ragelink 1045 """Task exits when no auth token available."""
254b467… ragelink 1046 from fossil.tasks import sync_wiki_to_github
254b467… ragelink 1047
254b467… ragelink 1048 mirror.auth_credential = ""
254b467… ragelink 1049 mirror.save(update_fields=["auth_credential"])
254b467… ragelink 1050
254b467… ragelink 1051 with (
254b467… ragelink 1052 _disk_exists,
254b467… ragelink 1053 patch("fossil.github_api.parse_github_repo", return_value=("testorg", "testrepo")),
254b467… ragelink 1054 ):
254b467… ragelink 1055 sync_wiki_to_github(mirror.pk)
254b467… ragelink 1056
254b467… ragelink 1057 assert SyncLog.objects.filter(mirror=mirror, triggered_by="wiki_sync").count() == 0
254b467… ragelink 1058
254b467… ragelink 1059 def test_handles_github_api_error(self, mirror, fossil_repo_obj):
254b467… ragelink 1060 """GitHub API errors are recorded in the log."""
254b467… ragelink 1061 from fossil.tasks import sync_wiki_to_github
254b467… ragelink 1062
254b467… ragelink 1063 page_listing = _make_wiki_page(name="Failing", content="")
254b467… ragelink 1064 full_page = _make_wiki_page(name="Failing", content="# Oops")
254b467… ragelink 1065
254b467… ragelink 1066 reader_mock = _make_reader_mock(
254b467… ragelink 1067 get_wiki_pages=[page_listing],
254b467… ragelink 1068 get_wiki_page=full_page,
254b467… ragelink 1069 )
254b467… ragelink 1070
254b467… ragelink 1071 gh_client_mock = MagicMock()
254b467… ragelink 1072 gh_client_mock.create_or_update_file.return_value = {"success": False, "sha": "", "error": "HTTP 500"}
254b467… ragelink 1073
254b467… ragelink 1074 with (
254b467… ragelink 1075 _disk_exists,
254b467… ragelink 1076 patch("fossil.reader.FossilReader", reader_mock),
254b467… ragelink 1077 patch("fossil.github_api.GitHubClient", return_value=gh_client_mock),
254b467… ragelink 1078 patch("fossil.github_api.parse_github_repo", return_value=("testorg", "testrepo")),
254b467… ragelink 1079 ):
254b467… ragelink 1080 sync_wiki_to_github(mirror.pk)
254b467… ragelink 1081
254b467… ragelink 1082 log = SyncLog.objects.get(mirror=mirror, triggered_by="wiki_sync")
254b467… ragelink 1083 assert log.status == "failed"
254b467… ragelink 1084 assert "Errors" in log.message
254b467… ragelink 1085
254b467… ragelink 1086 def test_handles_exception_during_sync(self, mirror, fossil_repo_obj):
254b467… ragelink 1087 """Unexpected exceptions are caught and recorded."""
254b467… ragelink 1088 from fossil.tasks import sync_wiki_to_github
254b467… ragelink 1089
254b467… ragelink 1090 reader_mock = MagicMock(side_effect=Exception("reader crash"))
254b467… ragelink 1091
254b467… ragelink 1092 with (
254b467… ragelink 1093 _disk_exists,
254b467… ragelink 1094 patch("fossil.reader.FossilReader", reader_mock),
254b467… ragelink 1095 patch("fossil.github_api.GitHubClient"),
254b467… ragelink 1096 patch("fossil.github_api.parse_github_repo", return_value=("testorg", "testrepo")),
254b467… ragelink 1097 ):
254b467… ragelink 1098 sync_wiki_to_github(mirror.pk)
254b467… ragelink 1099
254b467… ragelink 1100 log = SyncLog.objects.get(mirror=mirror, triggered_by="wiki_sync")
254b467… ragelink 1101 assert log.status == "failed"
254b467… ragelink 1102 assert "Unexpected error" in log.message
254b467… ragelink 1103
254b467… ragelink 1104
254b467… ragelink 1105 # ===================================================================
254b467… ragelink 1106 # fossil/tasks.py -- dispatch_webhook (additional edge cases)
254b467… ragelink 1107 # ===================================================================
254b467… ragelink 1108
254b467… ragelink 1109
254b467… ragelink 1110 @pytest.mark.django_db
254b467… ragelink 1111 class TestDispatchWebhookEdgeCases:
254b467… ragelink 1112 """Edge cases for the dispatch_webhook task not covered by test_webhooks.py."""
254b467… ragelink 1113
254b467… ragelink 1114 def test_unsafe_url_blocked_at_dispatch_time(self, webhook):
254b467… ragelink 1115 """URLs that fail safety check at dispatch are blocked and logged."""
254b467… ragelink 1116 from fossil.tasks import dispatch_webhook
254b467… ragelink 1117
254b467… ragelink 1118 with patch("core.url_validation.is_safe_outbound_url", return_value=(False, "Private IP detected")):
254b467… ragelink 1119 dispatch_webhook.apply(args=[webhook.pk, "checkin", {"hash": "abc"}])
254b467… ragelink 1120
254b467… ragelink 1121 delivery = WebhookDelivery.objects.get(webhook=webhook)
254b467… ragelink 1122 assert delivery.success is False
254b467… ragelink 1123 assert delivery.response_status == 0
254b467… ragelink 1124 assert "Blocked" in delivery.response_body
254b467… ragelink 1125 assert "Private IP" in delivery.response_body
254b467… ragelink 1126
254b467… ragelink 1127 def test_request_exception_creates_delivery_and_retries(self, webhook):
254b467… ragelink 1128 """Network errors create a delivery record and trigger retry."""
254b467… ragelink 1129 import requests as req
254b467… ragelink 1130
254b467… ragelink 1131 from fossil.tasks import dispatch_webhook
254b467… ragelink 1132
254b467… ragelink 1133 with (
254b467… ragelink 1134 patch("core.url_validation.is_safe_outbound_url", return_value=(True, "")),
254b467… ragelink 1135 patch("requests.post", side_effect=req.ConnectionError("refused")),
254b467… ragelink 1136 ):
254b467… ragelink 1137 dispatch_webhook.apply(args=[webhook.pk, "ticket", {"id": "123"}])
254b467… ragelink 1138
254b467… ragelink 1139 delivery = WebhookDelivery.objects.filter(webhook=webhook).first()
254b467… ragelink 1140 assert delivery is not None
254b467… ragelink 1141 assert delivery.success is False
254b467… ragelink 1142 assert delivery.response_status == 0
254b467… ragelink 1143 assert "refused" in delivery.response_body
254b467… ragelink 1144
254b467… ragelink 1145
254b467… ragelink 1146 # ===================================================================
254b467… ragelink 1147 # accounts/views.py -- _sanitize_ssh_key
254b467… ragelink 1148 # ===================================================================
254b467… ragelink 1149
254b467… ragelink 1150
254b467… ragelink 1151 class TestSanitizeSSHKey:
254b467… ragelink 1152 """Unit tests for SSH key validation (no DB needed)."""
254b467… ragelink 1153
254b467… ragelink 1154 def test_rejects_key_with_newlines(self):
254b467… ragelink 1155 from accounts.views import _sanitize_ssh_key
254b467… ragelink 1156
254b467… ragelink 1157 result, error = _sanitize_ssh_key("ssh-ed25519 AAAA key1\nssh-rsa BBBB key2")
254b467… ragelink 1158 assert result is None
254b467… ragelink 1159 assert "Newlines" in error
254b467… ragelink 1160
254b467… ragelink 1161 def test_rejects_key_with_carriage_return(self):
254b467… ragelink 1162 from accounts.views import _sanitize_ssh_key
254b467… ragelink 1163
254b467… ragelink 1164 result, error = _sanitize_ssh_key("ssh-ed25519 AAAA key1\rssh-rsa BBBB key2")
254b467… ragelink 1165 assert result is None
254b467… ragelink 1166 assert "Newlines" in error
254b467… ragelink 1167
254b467… ragelink 1168 def test_rejects_key_with_null_byte(self):
254b467… ragelink 1169 from accounts.views import _sanitize_ssh_key
254b467… ragelink 1170
254b467… ragelink 1171 result, error = _sanitize_ssh_key("ssh-ed25519 AAAA\x00inject")
254b467… ragelink 1172 assert result is None
254b467… ragelink 1173 assert "null bytes" in error
254b467… ragelink 1174
254b467… ragelink 1175 def test_rejects_empty_key(self):
254b467… ragelink 1176 from accounts.views import _sanitize_ssh_key
254b467… ragelink 1177
254b467… ragelink 1178 result, error = _sanitize_ssh_key(" ")
254b467… ragelink 1179 assert result is None
254b467… ragelink 1180 assert "empty" in error.lower()
254b467… ragelink 1181
254b467… ragelink 1182 def test_rejects_wrong_part_count(self):
254b467… ragelink 1183 from accounts.views import _sanitize_ssh_key
254b467… ragelink 1184
254b467… ragelink 1185 result, error = _sanitize_ssh_key("ssh-ed25519")
254b467… ragelink 1186 assert result is None
254b467… ragelink 1187 assert "format" in error.lower()
254b467… ragelink 1188
254b467… ragelink 1189 def test_rejects_too_many_parts(self):
254b467… ragelink 1190 from accounts.views import _sanitize_ssh_key
254b467… ragelink 1191
254b467… ragelink 1192 result, error = _sanitize_ssh_key("ssh-ed25519 AAAA comment extra-part")
254b467… ragelink 1193 assert result is None
254b467… ragelink 1194 assert "format" in error.lower()
254b467… ragelink 1195
254b467… ragelink 1196 def test_rejects_unsupported_key_type(self):
254b467… ragelink 1197 from accounts.views import _sanitize_ssh_key
254b467… ragelink 1198
254b467… ragelink 1199 result, error = _sanitize_ssh_key("ssh-unknown AAAA comment")
254b467… ragelink 1200 assert result is None
254b467… ragelink 1201 assert "Unsupported" in error
254b467… ragelink 1202
254b467… ragelink 1203 def test_rejects_bad_base64(self):
254b467… ragelink 1204 from accounts.views import _sanitize_ssh_key
254b467… ragelink 1205
254b467… ragelink 1206 result, error = _sanitize_ssh_key("ssh-ed25519 !!!invalid comment")
254b467… ragelink 1207 assert result is None
254b467… ragelink 1208 assert "encoding" in error.lower()
254b467… ragelink 1209
254b467… ragelink 1210 def test_accepts_valid_ed25519_key(self):
254b467… ragelink 1211 from accounts.views import _sanitize_ssh_key
254b467… ragelink 1212
254b467… ragelink 1213 key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFakeKeyDataHere= user@host"
254b467… ragelink 1214 result, error = _sanitize_ssh_key(key)
254b467… ragelink 1215 assert result == key
254b467… ragelink 1216 assert error == ""
254b467… ragelink 1217
254b467… ragelink 1218 def test_accepts_valid_rsa_key(self):
254b467… ragelink 1219 from accounts.views import _sanitize_ssh_key
254b467… ragelink 1220
254b467… ragelink 1221 key = "ssh-rsa AAAAB3NzaC1yc2EAAAAFakeBase64Data== user@host"
254b467… ragelink 1222 result, error = _sanitize_ssh_key(key)
254b467… ragelink 1223 assert result == key
254b467… ragelink 1224 assert error == ""
254b467… ragelink 1225
254b467… ragelink 1226 def test_accepts_ecdsa_key(self):
254b467… ragelink 1227 from accounts.views import _sanitize_ssh_key
254b467… ragelink 1228
254b467… ragelink 1229 key = "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTY= user@host"
254b467… ragelink 1230 result, error = _sanitize_ssh_key(key)
254b467… ragelink 1231 assert result == key
254b467… ragelink 1232 assert error == ""
254b467… ragelink 1233
254b467… ragelink 1234 def test_strips_whitespace(self):
254b467… ragelink 1235 from accounts.views import _sanitize_ssh_key
254b467… ragelink 1236
254b467… ragelink 1237 key = " ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFake= "
254b467… ragelink 1238 result, error = _sanitize_ssh_key(key)
254b467… ragelink 1239 assert result is not None
254b467… ragelink 1240 assert result == key.strip()
254b467… ragelink 1241
254b467… ragelink 1242
254b467… ragelink 1243 # ===================================================================
254b467… ragelink 1244 # accounts/views.py -- _verify_turnstile
254b467… ragelink 1245 # ===================================================================
254b467… ragelink 1246
254b467… ragelink 1247
254b467… ragelink 1248 class TestVerifyTurnstile:
254b467… ragelink 1249 """Unit tests for Turnstile CAPTCHA verification."""
254b467… ragelink 1250
254b467… ragelink 1251 @staticmethod
254b467… ragelink 1252 def _turnstile_config(secret_key=""):
254b467… ragelink 1253 cfg = MagicMock()
254b467… ragelink 1254 cfg.TURNSTILE_SECRET_KEY = secret_key
254b467… ragelink 1255 return cfg
254b467… ragelink 1256
254b467… ragelink 1257 def test_returns_false_when_no_secret_key(self):
254b467… ragelink 1258 from accounts.views import _verify_turnstile
254b467… ragelink 1259
254b467… ragelink 1260 with patch("constance.config", self._turnstile_config(secret_key="")):
254b467… ragelink 1261 assert _verify_turnstile("some-token", "1.2.3.4") is False
254b467… ragelink 1262
254b467… ragelink 1263 def test_returns_true_on_success(self):
254b467… ragelink 1264 from accounts.views import _verify_turnstile
254b467… ragelink 1265
254b467… ragelink 1266 mock_resp = MagicMock()
254b467… ragelink 1267 mock_resp.status_code = 200
254b467… ragelink 1268 mock_resp.json.return_value = {"success": True}
254b467… ragelink 1269
254b467… ragelink 1270 with (
254b467… ragelink 1271 patch("constance.config", self._turnstile_config(secret_key="secret-key")),
254b467… ragelink 1272 patch("requests.post", return_value=mock_resp),
254b467… ragelink 1273 ):
254b467… ragelink 1274 assert _verify_turnstile("valid-token", "1.2.3.4") is True
254b467… ragelink 1275
254b467… ragelink 1276 def test_returns_false_on_failed_verification(self):
254b467… ragelink 1277 from accounts.views import _verify_turnstile
254b467… ragelink 1278
254b467… ragelink 1279 mock_resp = MagicMock()
254b467… ragelink 1280 mock_resp.status_code = 200
254b467… ragelink 1281 mock_resp.json.return_value = {"success": False}
254b467… ragelink 1282
254b467… ragelink 1283 with (
254b467… ragelink 1284 patch("constance.config", self._turnstile_config(secret_key="secret-key")),
254b467… ragelink 1285 patch("requests.post", return_value=mock_resp),
254b467… ragelink 1286 ):
254b467… ragelink 1287 assert _verify_turnstile("bad-token", "1.2.3.4") is False
254b467… ragelink 1288
254b467… ragelink 1289 def test_returns_false_on_network_error(self):
254b467… ragelink 1290 from accounts.views import _verify_turnstile
254b467… ragelink 1291
254b467… ragelink 1292 with (
254b467… ragelink 1293 patch("constance.config", self._turnstile_config(secret_key="secret-key")),
254b467… ragelink 1294 patch("requests.post", side_effect=Exception("connection refused")),
254b467… ragelink 1295 ):
254b467… ragelink 1296 assert _verify_turnstile("token", "1.2.3.4") is False
254b467… ragelink 1297
254b467… ragelink 1298
254b467… ragelink 1299 # ===================================================================
254b467… ragelink 1300 # accounts/views.py -- Login Turnstile flow
254b467… ragelink 1301 # ===================================================================
254b467… ragelink 1302
254b467… ragelink 1303
254b467… ragelink 1304 def _login_turnstile_config():
254b467… ragelink 1305 cfg = MagicMock()
254b467… ragelink 1306 cfg.TURNSTILE_ENABLED = True
254b467… ragelink 1307 cfg.TURNSTILE_SITE_KEY = "site-key-123"
254b467… ragelink 1308 cfg.TURNSTILE_SECRET_KEY = "secret-key"
254b467… ragelink 1309 return cfg
254b467… ragelink 1310
254b467… ragelink 1311
254b467… ragelink 1312 @pytest.mark.django_db
254b467… ragelink 1313 class TestLoginTurnstile:
254b467… ragelink 1314 """Test login view with Turnstile CAPTCHA enabled."""
254b467… ragelink 1315
254b467… ragelink 1316 def test_turnstile_error_rerenders_form(self, client, admin_user):
254b467… ragelink 1317 """When Turnstile fails, the login form is re-rendered with error."""
254b467… ragelink 1318 with (
254b467… ragelink 1319 patch("constance.config", _login_turnstile_config()),
254b467… ragelink 1320 patch("accounts.views._verify_turnstile", return_value=False),
254b467… ragelink 1321 ):
254b467… ragelink 1322 response = client.post(
254b467… ragelink 1323 "/auth/login/",
254b467… ragelink 1324 {"username": "admin", "password": "testpass123", "cf-turnstile-response": "bad-token"},
254b467… ragelink 1325 )
254b467… ragelink 1326
254b467… ragelink 1327 assert response.status_code == 200
254b467… ragelink 1328 assert b"login" in response.content.lower()
254b467… ragelink 1329
254b467… ragelink 1330 def test_turnstile_context_passed_to_template(self, client):
254b467… ragelink 1331 """When Turnstile is enabled, context includes turnstile_enabled and site_key."""
254b467… ragelink 1332 with patch("constance.config", _login_turnstile_config()):
254b467… ragelink 1333 response = client.get("/auth/login/")
254b467… ragelink 1334
254b467… ragelink 1335 assert response.status_code == 200
254b467… ragelink 1336 assert response.context["turnstile_enabled"] is True
254b467… ragelink 1337 assert response.context["turnstile_site_key"] == "site-key-123"
254b467… ragelink 1338
254b467… ragelink 1339
254b467… ragelink 1340 # ===================================================================
254b467… ragelink 1341 # accounts/views.py -- SSH key management
254b467… ragelink 1342 # ===================================================================
254b467… ragelink 1343
254b467… ragelink 1344
254b467… ragelink 1345 @pytest.mark.django_db
254b467… ragelink 1346 class TestSSHKeyViews:
254b467… ragelink 1347 """Test SSH key list, add, and delete views."""
254b467… ragelink 1348
254b467… ragelink 1349 def test_list_ssh_keys(self, admin_client, admin_user):
254b467… ragelink 1350 response = admin_client.get("/auth/ssh-keys/")
254b467… ragelink 1351 assert response.status_code == 200
254b467… ragelink 1352
254b467… ragelink 1353 def test_add_valid_ssh_key(self, admin_client, admin_user):
254b467… ragelink 1354 """Adding a valid SSH key creates the record and regenerates authorized_keys."""
254b467… ragelink 1355 from fossil.user_keys import UserSSHKey
254b467… ragelink 1356
254b467… ragelink 1357 with patch("accounts.views._regenerate_authorized_keys"):
254b467… ragelink 1358 response = admin_client.post(
254b467… ragelink 1359 "/auth/ssh-keys/",
254b467… ragelink 1360 {
254b467… ragelink 1361 "title": "Work Laptop",
254b467… ragelink 1362 "public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFakeKeyDataHere= user@host",
254b467… ragelink 1363 },
254b467… ragelink 1364 )
254b467… ragelink 1365
254b467… ragelink 1366 assert response.status_code == 302 # redirect after success
254b467… ragelink 1367 key = UserSSHKey.objects.get(user=admin_user, title="Work Laptop")
254b467… ragelink 1368 assert key.key_type == "ed25519"
254b467… ragelink 1369 assert key.fingerprint # SHA256 computed
254b467… ragelink 1370
254b467… ragelink 1371 def test_add_invalid_ssh_key_shows_error(self, admin_client, admin_user):
254b467… ragelink 1372 """Adding an invalid SSH key shows an error message."""
254b467… ragelink 1373 response = admin_client.post(
254b467… ragelink 1374 "/auth/ssh-keys/",
254b467… ragelink 1375 {
254b467… ragelink 1376 "title": "Bad Key",
254b467… ragelink 1377 "public_key": "not-a-real-key",
254b467… ragelink 1378 },
254b467… ragelink 1379 )
254b467… ragelink 1380
254b467… ragelink 1381 assert response.status_code == 200 # re-renders form
254b467… ragelink 1382
254b467… ragelink 1383 def test_add_ssh_key_with_injection_newline(self, admin_client, admin_user):
254b467… ragelink 1384 """Keys with newlines are rejected (injection prevention)."""
254b467… ragelink 1385 from fossil.user_keys import UserSSHKey
254b467… ragelink 1386
254b467… ragelink 1387 response = admin_client.post(
254b467… ragelink 1388 "/auth/ssh-keys/",
254b467… ragelink 1389 {
254b467… ragelink 1390 "title": "Injected Key",
254b467… ragelink 1391 "public_key": "ssh-ed25519 AAAA key1\nssh-rsa BBBB key2",
254b467… ragelink 1392 },
254b467… ragelink 1393 )
254b467… ragelink 1394
254b467… ragelink 1395 assert response.status_code == 200
254b467… ragelink 1396 assert UserSSHKey.objects.filter(user=admin_user).count() == 0
254b467… ragelink 1397
254b467… ragelink 1398 def test_delete_ssh_key(self, admin_client, admin_user):
254b467… ragelink 1399 """Deleting an SSH key soft-deletes it and regenerates authorized_keys."""
254b467… ragelink 1400 from fossil.user_keys import UserSSHKey
254b467… ragelink 1401
254b467… ragelink 1402 key = UserSSHKey.objects.create(
254b467… ragelink 1403 user=admin_user,
254b467… ragelink 1404 title="Delete Me",
254b467… ragelink 1405 public_key="ssh-ed25519 AAAA= test",
254b467… ragelink 1406 created_by=admin_user,
254b467… ragelink 1407 )
254b467… ragelink 1408
254b467… ragelink 1409 with patch("accounts.views._regenerate_authorized_keys"):
254b467… ragelink 1410 response = admin_client.post(f"/auth/ssh-keys/{key.pk}/delete/")
254b467… ragelink 1411
254b467… ragelink 1412 assert response.status_code == 302
254b467… ragelink 1413 key.refresh_from_db()
254b467… ragelink 1414 assert key.deleted_at is not None
254b467… ragelink 1415
254b467… ragelink 1416 def test_delete_ssh_key_htmx(self, admin_client, admin_user):
254b467… ragelink 1417 """HTMX delete returns HX-Redirect header."""
254b467… ragelink 1418 from fossil.user_keys import UserSSHKey
254b467… ragelink 1419
254b467… ragelink 1420 key = UserSSHKey.objects.create(
254b467… ragelink 1421 user=admin_user,
254b467… ragelink 1422 title="HX Delete",
254b467… ragelink 1423 public_key="ssh-ed25519 AAAA= test",
254b467… ragelink 1424 created_by=admin_user,
254b467… ragelink 1425 )
254b467… ragelink 1426
254b467… ragelink 1427 with patch("accounts.views._regenerate_authorized_keys"):
254b467… ragelink 1428 response = admin_client.post(
254b467… ragelink 1429 f"/auth/ssh-keys/{key.pk}/delete/",
254b467… ragelink 1430 HTTP_HX_REQUEST="true",
254b467… ragelink 1431 )
254b467… ragelink 1432
254b467… ragelink 1433 assert response.status_code == 200
254b467… ragelink 1434 assert response["HX-Redirect"] == "/auth/ssh-keys/"
254b467… ragelink 1435
254b467… ragelink 1436 def test_delete_other_users_key_404(self, admin_client, viewer_user, admin_user):
254b467… ragelink 1437 """Cannot delete another user's SSH key."""
254b467… ragelink 1438 from fossil.user_keys import UserSSHKey
254b467… ragelink 1439
254b467… ragelink 1440 key = UserSSHKey.objects.create(
254b467… ragelink 1441 user=viewer_user,
254b467… ragelink 1442 title="Viewer Key",
254b467… ragelink 1443 public_key="ssh-ed25519 AAAA= test",
254b467… ragelink 1444 created_by=viewer_user,
254b467… ragelink 1445 )
254b467… ragelink 1446
254b467… ragelink 1447 response = admin_client.post(f"/auth/ssh-keys/{key.pk}/delete/")
254b467… ragelink 1448 assert response.status_code == 404
254b467… ragelink 1449
254b467… ragelink 1450 def test_ssh_keys_require_login(self, client):
254b467… ragelink 1451 response = client.get("/auth/ssh-keys/")
254b467… ragelink 1452 assert response.status_code == 302
254b467… ragelink 1453 assert "/auth/login/" in response.url
254b467… ragelink 1454
254b467… ragelink 1455
254b467… ragelink 1456 # ===================================================================
254b467… ragelink 1457 # accounts/views.py -- Notification preferences HTMX
254b467… ragelink 1458 # ===================================================================
254b467… ragelink 1459
254b467… ragelink 1460
254b467… ragelink 1461 @pytest.mark.django_db
254b467… ragelink 1462 class TestNotificationPreferencesHTMX:
254b467… ragelink 1463 """Test the HTMX return path for notification preferences."""
254b467… ragelink 1464
254b467… ragelink 1465 def test_post_htmx_returns_hx_redirect(self, admin_client, admin_user):
254b467… ragelink 1466 """HTMX POST returns 200 with HX-Redirect header instead of 302."""
254b467… ragelink 1467 NotificationPreference.objects.create(user=admin_user)
254b467… ragelink 1468
254b467… ragelink 1469 response = admin_client.post(
254b467… ragelink 1470 "/auth/notifications/",
254b467… ragelink 1471 {"delivery_mode": "weekly"},
254b467… ragelink 1472 HTTP_HX_REQUEST="true",
254b467… ragelink 1473 )
254b467… ragelink 1474
254b467… ragelink 1475 assert response.status_code == 200
254b467… ragelink 1476 assert response["HX-Redirect"] == "/auth/notifications/"
254b467… ragelink 1477
254b467… ragelink 1478
254b467… ragelink 1479 # ===================================================================
254b467… ragelink 1480 # accounts/views.py -- _parse_key_type and _compute_fingerprint
254b467… ragelink 1481 # ===================================================================
254b467… ragelink 1482
254b467… ragelink 1483
254b467… ragelink 1484 class TestParseKeyType:
254b467… ragelink 1485 """Unit tests for SSH key type parsing helper."""
254b467… ragelink 1486
254b467… ragelink 1487 def test_ed25519(self):
254b467… ragelink 1488 from accounts.views import _parse_key_type
254b467… ragelink 1489
254b467… ragelink 1490 assert _parse_key_type("ssh-ed25519 AAAA") == "ed25519"
254b467… ragelink 1491
254b467… ragelink 1492 def test_rsa(self):
254b467… ragelink 1493 from accounts.views import _parse_key_type
254b467… ragelink 1494
254b467… ragelink 1495 assert _parse_key_type("ssh-rsa AAAA") == "rsa"
254b467… ragelink 1496
254b467… ragelink 1497 def test_ecdsa_256(self):
254b467… ragelink 1498 from accounts.views import _parse_key_type
254b467… ragelink 1499
254b467… ragelink 1500 assert _parse_key_type("ecdsa-sha2-nistp256 AAAA") == "ecdsa"
254b467… ragelink 1501
254b467… ragelink 1502 def test_ecdsa_384(self):
254b467… ragelink 1503 from accounts.views import _parse_key_type
254b467… ragelink 1504
254b467… ragelink 1505 assert _parse_key_type("ecdsa-sha2-nistp384 AAAA") == "ecdsa"
254b467… ragelink 1506
254b467… ragelink 1507 def test_dsa(self):
254b467… ragelink 1508 from accounts.views import _parse_key_type
254b467… ragelink 1509
254b467… ragelink 1510 assert _parse_key_type("ssh-dss AAAA") == "dsa"
254b467… ragelink 1511
254b467… ragelink 1512 def test_unknown_type(self):
254b467… ragelink 1513 from accounts.views import _parse_key_type
254b467… ragelink 1514
254b467… ragelink 1515 assert _parse_key_type("custom-type AAAA") == "custom-type"
254b467… ragelink 1516
254b467… ragelink 1517 def test_empty_string(self):
254b467… ragelink 1518 from accounts.views import _parse_key_type
254b467… ragelink 1519
254b467… ragelink 1520 assert _parse_key_type("") == ""
254b467… ragelink 1521
254b467… ragelink 1522
254b467… ragelink 1523 class TestComputeFingerprint:
254b467… ragelink 1524 """Unit tests for SSH key fingerprint computation."""
254b467… ragelink 1525
254b467… ragelink 1526 def test_computes_sha256_fingerprint(self):
254b467… ragelink 1527 from accounts.views import _compute_fingerprint
254b467… ragelink 1528
254b467… ragelink 1529 # Valid base64 key data
254b467… ragelink 1530 key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFakeKeyDataHere= user@host"
254b467… ragelink 1531 result = _compute_fingerprint(key)
254b467… ragelink 1532 assert result.startswith("SHA256:")
254b467… ragelink 1533
254b467… ragelink 1534 def test_invalid_base64_returns_empty(self):
254b467… ragelink 1535 from accounts.views import _compute_fingerprint
254b467… ragelink 1536
254b467… ragelink 1537 key = "ssh-ed25519 !!!notbase64 user@host"
254b467… ragelink 1538 result = _compute_fingerprint(key)
254b467… ragelink 1539 assert result == ""
254b467… ragelink 1540
254b467… ragelink 1541 def test_single_part_returns_empty(self):
254b467… ragelink 1542 from accounts.views import _compute_fingerprint
254b467… ragelink 1543
254b467… ragelink 1544 result = _compute_fingerprint("onlyonepart")
254b467… ragelink 1545 assert result == ""
254b467… ragelink 1546
254b467… ragelink 1547
254b467… ragelink 1548 # ===================================================================
254b467… ragelink 1549 # accounts/views.py -- profile_token_create scopes edge cases
254b467… ragelink 1550 # ===================================================================
254b467… ragelink 1551
254b467… ragelink 1552
254b467… ragelink 1553 @pytest.mark.django_db
254b467… ragelink 1554 class TestProfileTokenCreateEdgeCases:
254b467… ragelink 1555 """Additional edge cases for token creation."""
254b467… ragelink 1556
254b467… ragelink 1557 def test_create_admin_scope_token(self, admin_client, admin_user):
254b467… ragelink 1558 """Admin scope is a valid scope."""
254b467… ragelink 1559 from accounts.models import PersonalAccessToken
254b467… ragelink 1560
254b467… ragelink 1561 response = admin_client.post(
254b467… ragelink 1562 "/auth/profile/tokens/create/",
254b467… ragelink 1563 {"name": "Admin Token", "scopes": "read,write,admin"},
254b467… ragelink 1564 )
254b467… ragelink 1565 assert response.status_code == 200
254b467… ragelink 1566 token = PersonalAccessToken.objects.get(user=admin_user, name="Admin Token")
254b467… ragelink 1567 assert "admin" in token.scopes
254b467… ragelink 1568 assert "read" in token.scopes
254b467… ragelink 1569 assert "write" in token.scopes
254b467… ragelink 1570
254b467… ragelink 1571 def test_create_token_mixed_valid_invalid_scopes(self, admin_client, admin_user):
254b467… ragelink 1572 """Invalid scopes are filtered out, valid ones kept."""
254b467… ragelink 1573 from accounts.models import PersonalAccessToken
254b467… ragelink 1574
254b467… ragelink 1575 admin_client.post(
254b467… ragelink 1576 "/auth/profile/tokens/create/",
254b467… ragelink 1577 {"name": "Mixed Scopes", "scopes": "read,destroy,write,hack"},
254b467… ragelink 1578 )
254b467… ragelink 1579 token = PersonalAccessToken.objects.get(user=admin_user, name="Mixed Scopes")
254b467… ragelink 1580 assert token.scopes == "read,write"
254b467… ragelink 1581
254b467… ragelink 1582 def test_create_token_whitespace_scopes(self, admin_client, admin_user):
254b467… ragelink 1583 """Scopes with extra whitespace are handled correctly."""
254b467… ragelink 1584 from accounts.models import PersonalAccessToken
254b467… ragelink 1585
254b467… ragelink 1586 admin_client.post(
254b467… ragelink 1587 "/auth/profile/tokens/create/",
254b467… ragelink 1588 {"name": "Whitespace", "scopes": " read , write "},
254b467… ragelink 1589 )
254b467… ragelink 1590 token = PersonalAccessToken.objects.get(user=admin_user, name="Whitespace")
254b467… ragelink 1591 assert token.scopes == "read,write"

Keyboard Shortcuts

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