FossilRepo

fossilrepo / tests / test_json_api.py
Source Blame History 1044 lines
c588255… ragelink 1 """Tests for JSON API endpoints at /projects/<slug>/fossil/api/.
c588255… ragelink 2
c588255… ragelink 3 Covers:
c588255… ragelink 4 - Authentication: Bearer tokens (APIToken, PersonalAccessToken), session fallback,
c588255… ragelink 5 invalid/expired tokens
c588255… ragelink 6 - Each endpoint: basic response shape, pagination, filtering
c588255… ragelink 7 - Access control: public vs private projects, anonymous vs authenticated
c588255… ragelink 8 """
c588255… ragelink 9
c588255… ragelink 10 from datetime import UTC, datetime, timedelta
c588255… ragelink 11 from unittest.mock import MagicMock, PropertyMock, patch
c588255… ragelink 12
c588255… ragelink 13 import pytest
c588255… ragelink 14 from django.contrib.auth.models import User
c588255… ragelink 15 from django.test import Client
c588255… ragelink 16 from django.utils import timezone
c588255… ragelink 17
c588255… ragelink 18 from accounts.models import PersonalAccessToken
c588255… ragelink 19 from fossil.api_tokens import APIToken
c588255… ragelink 20 from fossil.models import FossilRepository
c588255… ragelink 21 from fossil.reader import TicketEntry, TimelineEntry, WikiPage
c588255… ragelink 22 from fossil.releases import Release, ReleaseAsset
c588255… ragelink 23 from organization.models import Team
c588255… ragelink 24 from projects.models import Project, ProjectTeam
c588255… ragelink 25
c588255… ragelink 26 # ---------------------------------------------------------------------------
c588255… ragelink 27 # Fixtures
c588255… ragelink 28 # ---------------------------------------------------------------------------
c588255… ragelink 29
c588255… ragelink 30
c588255… ragelink 31 @pytest.fixture
c588255… ragelink 32 def fossil_repo_obj(sample_project):
c588255… ragelink 33 """Return the auto-created FossilRepository for sample_project."""
c588255… ragelink 34 return FossilRepository.objects.get(project=sample_project, deleted_at__isnull=True)
c588255… ragelink 35
c588255… ragelink 36
c588255… ragelink 37 @pytest.fixture
c588255… ragelink 38 def api_token(fossil_repo_obj, admin_user):
c588255… ragelink 39 """Create a project-scoped API token and return (APIToken, raw_token)."""
c588255… ragelink 40 raw, token_hash, prefix = APIToken.generate()
c588255… ragelink 41 token = APIToken.objects.create(
c588255… ragelink 42 repository=fossil_repo_obj,
c588255… ragelink 43 name="Test API Token",
c588255… ragelink 44 token_hash=token_hash,
c588255… ragelink 45 token_prefix=prefix,
c588255… ragelink 46 permissions="*",
c588255… ragelink 47 created_by=admin_user,
c588255… ragelink 48 )
c588255… ragelink 49 return token, raw
c588255… ragelink 50
c588255… ragelink 51
c588255… ragelink 52 @pytest.fixture
c588255… ragelink 53 def expired_api_token(fossil_repo_obj, admin_user):
c588255… ragelink 54 """Create an expired project-scoped API token."""
c588255… ragelink 55 raw, token_hash, prefix = APIToken.generate()
c588255… ragelink 56 token = APIToken.objects.create(
c588255… ragelink 57 repository=fossil_repo_obj,
c588255… ragelink 58 name="Expired Token",
c588255… ragelink 59 token_hash=token_hash,
c588255… ragelink 60 token_prefix=prefix,
c588255… ragelink 61 permissions="*",
c588255… ragelink 62 expires_at=timezone.now() - timedelta(days=1),
c588255… ragelink 63 created_by=admin_user,
c588255… ragelink 64 )
c588255… ragelink 65 return token, raw
c588255… ragelink 66
c588255… ragelink 67
c588255… ragelink 68 @pytest.fixture
c588255… ragelink 69 def pat_token(admin_user):
c588255… ragelink 70 """Create a user-scoped PersonalAccessToken and return (PAT, raw_token)."""
c588255… ragelink 71 raw, token_hash, prefix = PersonalAccessToken.generate()
c588255… ragelink 72 pat = PersonalAccessToken.objects.create(
c588255… ragelink 73 user=admin_user,
c588255… ragelink 74 name="Test PAT",
c588255… ragelink 75 token_hash=token_hash,
c588255… ragelink 76 token_prefix=prefix,
c588255… ragelink 77 scopes="read,write",
c588255… ragelink 78 )
c588255… ragelink 79 return pat, raw
c588255… ragelink 80
c588255… ragelink 81
c588255… ragelink 82 @pytest.fixture
c588255… ragelink 83 def expired_pat(admin_user):
c588255… ragelink 84 """Create an expired PersonalAccessToken."""
c588255… ragelink 85 raw, token_hash, prefix = PersonalAccessToken.generate()
c588255… ragelink 86 pat = PersonalAccessToken.objects.create(
c588255… ragelink 87 user=admin_user,
c588255… ragelink 88 name="Expired PAT",
c588255… ragelink 89 token_hash=token_hash,
c588255… ragelink 90 token_prefix=prefix,
c588255… ragelink 91 scopes="read",
c588255… ragelink 92 expires_at=timezone.now() - timedelta(days=1),
c588255… ragelink 93 )
c588255… ragelink 94 return pat, raw
c588255… ragelink 95
c588255… ragelink 96
c588255… ragelink 97 @pytest.fixture
c588255… ragelink 98 def revoked_pat(admin_user):
c588255… ragelink 99 """Create a revoked PersonalAccessToken."""
c588255… ragelink 100 raw, token_hash, prefix = PersonalAccessToken.generate()
c588255… ragelink 101 pat = PersonalAccessToken.objects.create(
c588255… ragelink 102 user=admin_user,
c588255… ragelink 103 name="Revoked PAT",
c588255… ragelink 104 token_hash=token_hash,
c588255… ragelink 105 token_prefix=prefix,
c588255… ragelink 106 scopes="read",
c588255… ragelink 107 revoked_at=timezone.now() - timedelta(hours=1),
c588255… ragelink 108 )
c588255… ragelink 109 return pat, raw
c588255… ragelink 110
c588255… ragelink 111
c588255… ragelink 112 @pytest.fixture
c588255… ragelink 113 def public_project(db, org, admin_user, sample_team):
c588255… ragelink 114 """A public project visible to anonymous users."""
c588255… ragelink 115 project = Project.objects.create(
c588255… ragelink 116 name="Public API Project",
c588255… ragelink 117 organization=org,
c588255… ragelink 118 visibility="public",
c588255… ragelink 119 created_by=admin_user,
c588255… ragelink 120 )
c588255… ragelink 121 ProjectTeam.objects.create(project=project, team=sample_team, role="write", created_by=admin_user)
c588255… ragelink 122 return project
c588255… ragelink 123
c588255… ragelink 124
c588255… ragelink 125 @pytest.fixture
c588255… ragelink 126 def public_fossil_repo(public_project):
c588255… ragelink 127 """Return the auto-created FossilRepository for the public project."""
c588255… ragelink 128 return FossilRepository.objects.get(project=public_project, deleted_at__isnull=True)
c588255… ragelink 129
c588255… ragelink 130
c588255… ragelink 131 @pytest.fixture
c588255… ragelink 132 def no_access_user(db, org, admin_user):
c588255… ragelink 133 """User with no team access to any project."""
c588255… ragelink 134 return User.objects.create_user(username="noaccess_api", password="testpass123")
c588255… ragelink 135
c588255… ragelink 136
c588255… ragelink 137 @pytest.fixture
c588255… ragelink 138 def no_access_pat(no_access_user):
c588255… ragelink 139 """PAT for a user with no project access."""
c588255… ragelink 140 raw, token_hash, prefix = PersonalAccessToken.generate()
c588255… ragelink 141 pat = PersonalAccessToken.objects.create(
c588255… ragelink 142 user=no_access_user,
c588255… ragelink 143 name="No Access PAT",
c588255… ragelink 144 token_hash=token_hash,
c588255… ragelink 145 token_prefix=prefix,
c588255… ragelink 146 scopes="read",
c588255… ragelink 147 )
c588255… ragelink 148 return pat, raw
c588255… ragelink 149
c588255… ragelink 150
c588255… ragelink 151 @pytest.fixture
c588255… ragelink 152 def anon_client():
c588255… ragelink 153 """Unauthenticated client."""
c588255… ragelink 154 return Client()
c588255… ragelink 155
c588255… ragelink 156
c588255… ragelink 157 # ---------------------------------------------------------------------------
c588255… ragelink 158 # Mock helpers
c588255… ragelink 159 # ---------------------------------------------------------------------------
c588255… ragelink 160
c588255… ragelink 161
c588255… ragelink 162 def _mock_fossil_reader():
c588255… ragelink 163 """Return a context-manager mock that satisfies FossilReader usage in api_views."""
c588255… ragelink 164 reader = MagicMock()
c588255… ragelink 165 reader.__enter__ = MagicMock(return_value=reader)
c588255… ragelink 166 reader.__exit__ = MagicMock(return_value=False)
c588255… ragelink 167
c588255… ragelink 168 # Timeline
c588255… ragelink 169 reader.get_timeline.return_value = [
c588255… ragelink 170 TimelineEntry(
c588255… ragelink 171 rid=1,
c588255… ragelink 172 uuid="abc123def456",
c588255… ragelink 173 event_type="ci",
c588255… ragelink 174 timestamp=datetime(2025, 1, 15, 10, 30, 0, tzinfo=UTC),
c588255… ragelink 175 user="alice",
c588255… ragelink 176 comment="Initial commit",
c588255… ragelink 177 branch="trunk",
c588255… ragelink 178 ),
c588255… ragelink 179 TimelineEntry(
c588255… ragelink 180 rid=2,
c588255… ragelink 181 uuid="def456abc789",
c588255… ragelink 182 event_type="ci",
c588255… ragelink 183 timestamp=datetime(2025, 1, 14, 9, 0, 0, tzinfo=UTC),
c588255… ragelink 184 user="bob",
c588255… ragelink 185 comment="Add readme",
c588255… ragelink 186 branch="trunk",
c588255… ragelink 187 ),
c588255… ragelink 188 ]
c588255… ragelink 189 reader.get_checkin_count.return_value = 42
c588255… ragelink 190
c588255… ragelink 191 # Tickets
c588255… ragelink 192 reader.get_tickets.return_value = [
c588255… ragelink 193 TicketEntry(
c588255… ragelink 194 uuid="tkt-001-uuid",
c588255… ragelink 195 title="Fix login bug",
c588255… ragelink 196 status="Open",
c588255… ragelink 197 type="Code_Defect",
c588255… ragelink 198 created=datetime(2025, 1, 10, 8, 0, 0, tzinfo=UTC),
c588255… ragelink 199 owner="alice",
c588255… ragelink 200 subsystem="auth",
c588255… ragelink 201 priority="Immediate",
c588255… ragelink 202 severity="Critical",
c588255… ragelink 203 ),
c588255… ragelink 204 TicketEntry(
c588255… ragelink 205 uuid="tkt-002-uuid",
c588255… ragelink 206 title="Add dark mode",
c588255… ragelink 207 status="Open",
c588255… ragelink 208 type="Feature_Request",
c588255… ragelink 209 created=datetime(2025, 1, 11, 12, 0, 0, tzinfo=UTC),
c588255… ragelink 210 owner="bob",
c588255… ragelink 211 subsystem="ui",
c588255… ragelink 212 priority="Medium",
c588255… ragelink 213 severity="Minor",
c588255… ragelink 214 ),
c588255… ragelink 215 ]
c588255… ragelink 216 reader.get_ticket_detail.return_value = TicketEntry(
c588255… ragelink 217 uuid="tkt-001-uuid",
c588255… ragelink 218 title="Fix login bug",
c588255… ragelink 219 status="Open",
c588255… ragelink 220 type="Code_Defect",
c588255… ragelink 221 created=datetime(2025, 1, 10, 8, 0, 0, tzinfo=UTC),
c588255… ragelink 222 owner="alice",
c588255… ragelink 223 subsystem="auth",
c588255… ragelink 224 priority="Immediate",
c588255… ragelink 225 severity="Critical",
c588255… ragelink 226 resolution="",
c588255… ragelink 227 body="Login fails when session expires.",
c588255… ragelink 228 )
c588255… ragelink 229 reader.get_ticket_comments.return_value = [
c588255… ragelink 230 {
c588255… ragelink 231 "timestamp": datetime(2025, 1, 11, 9, 0, 0, tzinfo=UTC),
c588255… ragelink 232 "user": "bob",
c588255… ragelink 233 "comment": "I can reproduce this.",
c588255… ragelink 234 "mimetype": "text/plain",
c588255… ragelink 235 },
c588255… ragelink 236 ]
c588255… ragelink 237
c588255… ragelink 238 # Wiki
c588255… ragelink 239 reader.get_wiki_pages.return_value = [
c588255… ragelink 240 WikiPage(
c588255… ragelink 241 name="Home",
c588255… ragelink 242 content="# Welcome",
c588255… ragelink 243 last_modified=datetime(2025, 1, 12, 15, 0, 0, tzinfo=UTC),
c588255… ragelink 244 user="alice",
c588255… ragelink 245 ),
c588255… ragelink 246 WikiPage(
c588255… ragelink 247 name="FAQ",
c588255… ragelink 248 content="# FAQ\nQ: ...",
c588255… ragelink 249 last_modified=datetime(2025, 1, 13, 10, 0, 0, tzinfo=UTC),
c588255… ragelink 250 user="bob",
c588255… ragelink 251 ),
c588255… ragelink 252 ]
c588255… ragelink 253 reader.get_wiki_page.return_value = WikiPage(
c588255… ragelink 254 name="Home",
c588255… ragelink 255 content="# Welcome\nThis is the home page.",
c588255… ragelink 256 last_modified=datetime(2025, 1, 12, 15, 0, 0, tzinfo=UTC),
c588255… ragelink 257 user="alice",
c588255… ragelink 258 )
c588255… ragelink 259
c588255… ragelink 260 # Branches
c588255… ragelink 261 reader.get_branches.return_value = [
c588255… ragelink 262 {
c588255… ragelink 263 "name": "trunk",
c588255… ragelink 264 "last_checkin": datetime(2025, 1, 15, 10, 30, 0, tzinfo=UTC),
c588255… ragelink 265 "last_user": "alice",
c588255… ragelink 266 "checkin_count": 30,
c588255… ragelink 267 "last_uuid": "abc123def456",
c588255… ragelink 268 },
c588255… ragelink 269 {
c588255… ragelink 270 "name": "feature-x",
c588255… ragelink 271 "last_checkin": datetime(2025, 1, 14, 9, 0, 0, tzinfo=UTC),
c588255… ragelink 272 "last_user": "bob",
c588255… ragelink 273 "checkin_count": 5,
c588255… ragelink 274 "last_uuid": "def456abc789",
c588255… ragelink 275 },
c588255… ragelink 276 ]
c588255… ragelink 277
c588255… ragelink 278 # Tags
c588255… ragelink 279 reader.get_tags.return_value = [
c588255… ragelink 280 {
c588255… ragelink 281 "name": "v1.0.0",
c588255… ragelink 282 "timestamp": datetime(2025, 1, 15, 10, 30, 0, tzinfo=UTC),
c588255… ragelink 283 "user": "alice",
c588255… ragelink 284 "uuid": "tag-uuid-100",
c588255… ragelink 285 },
c588255… ragelink 286 ]
c588255… ragelink 287
c588255… ragelink 288 # Search
c588255… ragelink 289 reader.search.return_value = {
c588255… ragelink 290 "checkins": [
c588255… ragelink 291 {
c588255… ragelink 292 "uuid": "abc123def456",
c588255… ragelink 293 "timestamp": datetime(2025, 1, 15, 10, 30, 0, tzinfo=UTC),
c588255… ragelink 294 "user": "alice",
c588255… ragelink 295 "comment": "Initial commit",
c588255… ragelink 296 }
c588255… ragelink 297 ],
c588255… ragelink 298 "tickets": [
c588255… ragelink 299 {
c588255… ragelink 300 "uuid": "tkt-001-uuid",
c588255… ragelink 301 "title": "Fix login bug",
c588255… ragelink 302 "status": "Open",
c588255… ragelink 303 "created": datetime(2025, 1, 10, 8, 0, 0, tzinfo=UTC),
c588255… ragelink 304 }
c588255… ragelink 305 ],
c588255… ragelink 306 "wiki": [{"name": "Home"}],
c588255… ragelink 307 }
c588255… ragelink 308
c588255… ragelink 309 return reader
c588255… ragelink 310
c588255… ragelink 311
c588255… ragelink 312 def _patch_api_fossil():
c588255… ragelink 313 """Patch exists_on_disk to True and FossilReader for api_views."""
c588255… ragelink 314 reader = _mock_fossil_reader()
c588255… ragelink 315 return (
c588255… ragelink 316 patch.object(FossilRepository, "exists_on_disk", new_callable=PropertyMock, return_value=True),
c588255… ragelink 317 patch("fossil.api_views.FossilReader", return_value=reader),
c588255… ragelink 318 reader,
c588255… ragelink 319 )
c588255… ragelink 320
c588255… ragelink 321
c588255… ragelink 322 def _api_url(slug, endpoint):
c588255… ragelink 323 """Build API URL for a given project slug and endpoint."""
c588255… ragelink 324 return f"/projects/{slug}/fossil/api/{endpoint}"
c588255… ragelink 325
c588255… ragelink 326
c588255… ragelink 327 def _bearer_header(raw_token):
c588255… ragelink 328 """Build HTTP_AUTHORIZATION header for Bearer token."""
c588255… ragelink 329 return {"HTTP_AUTHORIZATION": f"Bearer {raw_token}"}
c588255… ragelink 330
c588255… ragelink 331
c588255… ragelink 332 # ===========================================================================
c588255… ragelink 333 # Authentication Tests
c588255… ragelink 334 # ===========================================================================
c588255… ragelink 335
c588255… ragelink 336
c588255… ragelink 337 @pytest.mark.django_db
c588255… ragelink 338 class TestAPIAuthentication:
c588255… ragelink 339 """Test auth helper: Bearer tokens, session fallback, errors."""
c588255… ragelink 340
c588255… ragelink 341 def test_valid_api_token(self, client, sample_project, fossil_repo_obj, api_token):
c588255… ragelink 342 """Project-scoped APIToken grants access."""
c588255… ragelink 343 _, raw = api_token
c588255… ragelink 344 disk_patch, reader_patch, _ = _patch_api_fossil()
c588255… ragelink 345 with disk_patch, reader_patch:
c588255… ragelink 346 response = client.get(_api_url(sample_project.slug, "project"), **_bearer_header(raw))
c588255… ragelink 347 assert response.status_code == 200
c588255… ragelink 348 data = response.json()
c588255… ragelink 349 assert data["slug"] == sample_project.slug
c588255… ragelink 350
c588255… ragelink 351 def test_valid_personal_access_token(self, client, sample_project, fossil_repo_obj, pat_token):
c588255… ragelink 352 """User-scoped PersonalAccessToken grants access."""
c588255… ragelink 353 _, raw = pat_token
c588255… ragelink 354 disk_patch, reader_patch, _ = _patch_api_fossil()
c588255… ragelink 355 with disk_patch, reader_patch:
c588255… ragelink 356 response = client.get(_api_url(sample_project.slug, "project"), **_bearer_header(raw))
c588255… ragelink 357 assert response.status_code == 200
c588255… ragelink 358 data = response.json()
c588255… ragelink 359 assert data["slug"] == sample_project.slug
c588255… ragelink 360
c588255… ragelink 361 def test_session_auth_fallback(self, admin_client, sample_project, fossil_repo_obj):
c588255… ragelink 362 """Session auth works when no Bearer token is provided."""
c588255… ragelink 363 disk_patch, reader_patch, _ = _patch_api_fossil()
c588255… ragelink 364 with disk_patch, reader_patch:
c588255… ragelink 365 response = admin_client.get(_api_url(sample_project.slug, "project"))
c588255… ragelink 366 assert response.status_code == 200
c588255… ragelink 367 data = response.json()
c588255… ragelink 368 assert data["slug"] == sample_project.slug
c588255… ragelink 369
c588255… ragelink 370 def test_no_auth_returns_401(self, anon_client, sample_project, fossil_repo_obj):
c588255… ragelink 371 """Unauthenticated request to private project returns 401."""
c588255… ragelink 372 disk_patch, reader_patch, _ = _patch_api_fossil()
c588255… ragelink 373 with disk_patch, reader_patch:
c588255… ragelink 374 response = anon_client.get(_api_url(sample_project.slug, "project"))
c588255… ragelink 375 assert response.status_code == 401
c588255… ragelink 376 assert response.json()["error"] == "Authentication required"
c588255… ragelink 377
c588255… ragelink 378 def test_invalid_token_returns_401(self, client, sample_project, fossil_repo_obj):
c588255… ragelink 379 """Garbage token returns 401."""
c588255… ragelink 380 disk_patch, reader_patch, _ = _patch_api_fossil()
c588255… ragelink 381 with disk_patch, reader_patch:
c588255… ragelink 382 response = client.get(_api_url(sample_project.slug, "project"), **_bearer_header("frp_invalid_garbage_token"))
c588255… ragelink 383 assert response.status_code == 401
c588255… ragelink 384 assert response.json()["error"] == "Invalid token"
c588255… ragelink 385
c588255… ragelink 386 def test_expired_api_token_returns_401(self, client, sample_project, fossil_repo_obj, expired_api_token):
c588255… ragelink 387 """Expired project-scoped token returns 401."""
c588255… ragelink 388 _, raw = expired_api_token
c588255… ragelink 389 disk_patch, reader_patch, _ = _patch_api_fossil()
c588255… ragelink 390 with disk_patch, reader_patch:
c588255… ragelink 391 response = client.get(_api_url(sample_project.slug, "project"), **_bearer_header(raw))
c588255… ragelink 392 assert response.status_code == 401
c588255… ragelink 393 assert response.json()["error"] == "Token expired"
c588255… ragelink 394
c588255… ragelink 395 def test_expired_pat_returns_401(self, client, sample_project, fossil_repo_obj, expired_pat):
c588255… ragelink 396 """Expired PersonalAccessToken returns 401."""
c588255… ragelink 397 _, raw = expired_pat
c588255… ragelink 398 disk_patch, reader_patch, _ = _patch_api_fossil()
c588255… ragelink 399 with disk_patch, reader_patch:
c588255… ragelink 400 response = client.get(_api_url(sample_project.slug, "project"), **_bearer_header(raw))
c588255… ragelink 401 assert response.status_code == 401
c588255… ragelink 402 assert response.json()["error"] == "Token expired"
c588255… ragelink 403
c588255… ragelink 404 def test_revoked_pat_returns_401(self, client, sample_project, fossil_repo_obj, revoked_pat):
c588255… ragelink 405 """Revoked PersonalAccessToken returns 401."""
c588255… ragelink 406 _, raw = revoked_pat
c588255… ragelink 407 disk_patch, reader_patch, _ = _patch_api_fossil()
c588255… ragelink 408 with disk_patch, reader_patch:
c588255… ragelink 409 response = client.get(_api_url(sample_project.slug, "project"), **_bearer_header(raw))
c588255… ragelink 410 assert response.status_code == 401
c588255… ragelink 411 assert response.json()["error"] == "Invalid token"
c588255… ragelink 412
c588255… ragelink 413 def test_api_token_updates_last_used_at(self, client, sample_project, fossil_repo_obj, api_token):
c588255… ragelink 414 """Using an API token updates its last_used_at timestamp."""
c588255… ragelink 415 token, raw = api_token
c588255… ragelink 416 assert token.last_used_at is None
c588255… ragelink 417
c588255… ragelink 418 disk_patch, reader_patch, _ = _patch_api_fossil()
c588255… ragelink 419 with disk_patch, reader_patch:
c588255… ragelink 420 client.get(_api_url(sample_project.slug, "project"), **_bearer_header(raw))
c588255… ragelink 421
c588255… ragelink 422 token.refresh_from_db()
c588255… ragelink 423 assert token.last_used_at is not None
c588255… ragelink 424
c588255… ragelink 425 def test_pat_updates_last_used_at(self, client, sample_project, fossil_repo_obj, pat_token):
c588255… ragelink 426 """Using a PAT updates its last_used_at timestamp."""
c588255… ragelink 427 pat, raw = pat_token
c588255… ragelink 428 assert pat.last_used_at is None
c588255… ragelink 429
c588255… ragelink 430 disk_patch, reader_patch, _ = _patch_api_fossil()
c588255… ragelink 431 with disk_patch, reader_patch:
c588255… ragelink 432 client.get(_api_url(sample_project.slug, "project"), **_bearer_header(raw))
c588255… ragelink 433
c588255… ragelink 434 pat.refresh_from_db()
c588255… ragelink 435 assert pat.last_used_at is not None
c588255… ragelink 436
c588255… ragelink 437 def test_deleted_api_token_returns_401(self, client, sample_project, fossil_repo_obj, api_token, admin_user):
c588255… ragelink 438 """Soft-deleted API token cannot authenticate."""
c588255… ragelink 439 token, raw = api_token
c588255… ragelink 440 token.soft_delete(user=admin_user)
c588255… ragelink 441
c588255… ragelink 442 disk_patch, reader_patch, _ = _patch_api_fossil()
c588255… ragelink 443 with disk_patch, reader_patch:
c588255… ragelink 444 response = client.get(_api_url(sample_project.slug, "project"), **_bearer_header(raw))
c588255… ragelink 445 assert response.status_code == 401
c588255… ragelink 446
c588255… ragelink 447
c588255… ragelink 448 # ===========================================================================
c588255… ragelink 449 # Access Control Tests
c588255… ragelink 450 # ===========================================================================
c588255… ragelink 451
c588255… ragelink 452
c588255… ragelink 453 @pytest.mark.django_db
c588255… ragelink 454 class TestAPIAccessControl:
c588255… ragelink 455 """Test read access control: public vs private, user roles."""
c588255… ragelink 456
c588255… ragelink 457 def test_public_project_allows_anonymous(self, anon_client, public_project, public_fossil_repo):
c588255… ragelink 458 """Public projects allow anonymous access via session fallback (no auth needed)."""
c588255… ragelink 459 disk_patch, reader_patch, _ = _patch_api_fossil()
c588255… ragelink 460 with disk_patch, reader_patch:
c588255… ragelink 461 response = anon_client.get(_api_url(public_project.slug, "project"))
c588255… ragelink 462 # Anonymous hits session fallback -> user not authenticated -> 401
c588255… ragelink 463 # But public project check happens after auth, so this returns 401
c588255… ragelink 464 # because the auth helper returns 401 for unauthenticated requests
c588255… ragelink 465 assert response.status_code == 401
c588255… ragelink 466
c588255… ragelink 467 def test_public_project_allows_api_token(self, client, public_project, public_fossil_repo, admin_user):
c588255… ragelink 468 """API token scoped to a public project's repo grants access."""
c588255… ragelink 469 raw, token_hash, prefix = APIToken.generate()
c588255… ragelink 470 APIToken.objects.create(
c588255… ragelink 471 repository=public_fossil_repo,
c588255… ragelink 472 name="Public Token",
c588255… ragelink 473 token_hash=token_hash,
c588255… ragelink 474 token_prefix=prefix,
c588255… ragelink 475 permissions="*",
c588255… ragelink 476 created_by=admin_user,
c588255… ragelink 477 )
c588255… ragelink 478 disk_patch, reader_patch, _ = _patch_api_fossil()
c588255… ragelink 479 with disk_patch, reader_patch:
c588255… ragelink 480 response = client.get(_api_url(public_project.slug, "project"), **_bearer_header(raw))
c588255… ragelink 481 assert response.status_code == 200
c588255… ragelink 482 assert response.json()["slug"] == public_project.slug
c588255… ragelink 483
c588255… ragelink 484 def test_private_project_denies_no_access_user(self, client, sample_project, fossil_repo_obj, no_access_pat):
c588255… ragelink 485 """PAT for a user with no team access to a private project returns 403."""
c588255… ragelink 486 _, raw = no_access_pat
c588255… ragelink 487 disk_patch, reader_patch, _ = _patch_api_fossil()
c588255… ragelink 488 with disk_patch, reader_patch:
c588255… ragelink 489 response = client.get(_api_url(sample_project.slug, "project"), **_bearer_header(raw))
c588255… ragelink 490 assert response.status_code == 403
c588255… ragelink 491 assert response.json()["error"] == "Access denied"
c588255… ragelink 492
c588255… ragelink 493 def test_api_token_for_wrong_repo_returns_401(self, client, sample_project, fossil_repo_obj, public_fossil_repo, admin_user):
c588255… ragelink 494 """API token scoped to a different repo cannot access another repo."""
c588255… ragelink 495 raw, token_hash, prefix = APIToken.generate()
c588255… ragelink 496 APIToken.objects.create(
c588255… ragelink 497 repository=public_fossil_repo,
c588255… ragelink 498 name="Wrong Repo Token",
c588255… ragelink 499 token_hash=token_hash,
c588255… ragelink 500 token_prefix=prefix,
c588255… ragelink 501 permissions="*",
c588255… ragelink 502 created_by=admin_user,
c588255… ragelink 503 )
c588255… ragelink 504 disk_patch, reader_patch, _ = _patch_api_fossil()
c588255… ragelink 505 with disk_patch, reader_patch:
c588255… ragelink 506 # Try to access sample_project (private) with a token scoped to public_fossil_repo
c588255… ragelink 507 response = client.get(_api_url(sample_project.slug, "project"), **_bearer_header(raw))
c588255… ragelink 508 # The token won't match the sample_project's repo, and no PAT match either -> 401
c588255… ragelink 509 assert response.status_code == 401
c588255… ragelink 510
c588255… ragelink 511
c588255… ragelink 512 # ===========================================================================
c588255… ragelink 513 # API Docs Endpoint
c588255… ragelink 514 # ===========================================================================
c588255… ragelink 515
c588255… ragelink 516
c588255… ragelink 517 @pytest.mark.django_db
c588255… ragelink 518 class TestAPIDocs:
c588255… ragelink 519 def test_api_docs_returns_endpoint_list(self, admin_client, sample_project, fossil_repo_obj):
c588255… ragelink 520 response = admin_client.get(_api_url(sample_project.slug, ""))
c588255… ragelink 521 assert response.status_code == 200
c588255… ragelink 522 data = response.json()
c588255… ragelink 523 assert "endpoints" in data
c588255… ragelink 524 assert "auth" in data
c588255… ragelink 525 paths = [e["path"] for e in data["endpoints"]]
c588255… ragelink 526 assert any("/project" in p for p in paths)
c588255… ragelink 527 assert any("/timeline" in p for p in paths)
c588255… ragelink 528 assert any("/tickets" in p for p in paths)
c588255… ragelink 529 assert any("/wiki" in p for p in paths)
c588255… ragelink 530 assert any("/branches" in p for p in paths)
c588255… ragelink 531 assert any("/tags" in p for p in paths)
c588255… ragelink 532 assert any("/releases" in p for p in paths)
c588255… ragelink 533 assert any("/search" in p for p in paths)
c588255… ragelink 534
c588255… ragelink 535
c588255… ragelink 536 # ===========================================================================
c588255… ragelink 537 # Project Metadata Endpoint
c588255… ragelink 538 # ===========================================================================
c588255… ragelink 539
c588255… ragelink 540
c588255… ragelink 541 @pytest.mark.django_db
c588255… ragelink 542 class TestAPIProject:
c588255… ragelink 543 def test_project_metadata(self, admin_client, sample_project, fossil_repo_obj):
c588255… ragelink 544 disk_patch, reader_patch, _ = _patch_api_fossil()
c588255… ragelink 545 with disk_patch, reader_patch:
c588255… ragelink 546 response = admin_client.get(_api_url(sample_project.slug, "project"))
c588255… ragelink 547 assert response.status_code == 200
c588255… ragelink 548 data = response.json()
c588255… ragelink 549 assert data["name"] == sample_project.name
c588255… ragelink 550 assert data["slug"] == sample_project.slug
c588255… ragelink 551 assert data["visibility"] == sample_project.visibility
c588255… ragelink 552 assert "star_count" in data
c588255… ragelink 553 assert "description" in data
c588255… ragelink 554
c588255… ragelink 555 def test_nonexistent_project_returns_404(self, admin_client):
c588255… ragelink 556 response = admin_client.get(_api_url("nonexistent-slug", "project"))
c588255… ragelink 557 assert response.status_code == 404
c588255… ragelink 558
c588255… ragelink 559
c588255… ragelink 560 # ===========================================================================
c588255… ragelink 561 # Timeline Endpoint
c588255… ragelink 562 # ===========================================================================
c588255… ragelink 563
c588255… ragelink 564
c588255… ragelink 565 @pytest.mark.django_db
c588255… ragelink 566 class TestAPITimeline:
c588255… ragelink 567 def test_timeline_returns_checkins(self, admin_client, sample_project, fossil_repo_obj):
c588255… ragelink 568 disk_patch, reader_patch, _ = _patch_api_fossil()
c588255… ragelink 569 with disk_patch, reader_patch:
c588255… ragelink 570 response = admin_client.get(_api_url(sample_project.slug, "timeline"))
c588255… ragelink 571 assert response.status_code == 200
c588255… ragelink 572 data = response.json()
c588255… ragelink 573 assert "checkins" in data
c588255… ragelink 574 assert "total" in data
c588255… ragelink 575 assert "page" in data
c588255… ragelink 576 assert "per_page" in data
c588255… ragelink 577 assert "total_pages" in data
c588255… ragelink 578 assert len(data["checkins"]) == 2
c588255… ragelink 579 checkin = data["checkins"][0]
c588255… ragelink 580 assert "uuid" in checkin
c588255… ragelink 581 assert "timestamp" in checkin
c588255… ragelink 582 assert "user" in checkin
c588255… ragelink 583 assert "comment" in checkin
c588255… ragelink 584 assert "branch" in checkin
c588255… ragelink 585
c588255… ragelink 586 def test_timeline_pagination(self, admin_client, sample_project, fossil_repo_obj):
c588255… ragelink 587 disk_patch, reader_patch, reader = _patch_api_fossil()
c588255… ragelink 588 with disk_patch, reader_patch:
c588255… ragelink 589 response = admin_client.get(_api_url(sample_project.slug, "timeline") + "?page=2&per_page=10")
c588255… ragelink 590 assert response.status_code == 200
c588255… ragelink 591 data = response.json()
c588255… ragelink 592 assert data["page"] == 2
c588255… ragelink 593 assert data["per_page"] == 10
c588255… ragelink 594
c588255… ragelink 595 def test_timeline_branch_filter(self, admin_client, sample_project, fossil_repo_obj):
c588255… ragelink 596 disk_patch, reader_patch, _ = _patch_api_fossil()
c588255… ragelink 597 with disk_patch, reader_patch:
c588255… ragelink 598 response = admin_client.get(_api_url(sample_project.slug, "timeline") + "?branch=trunk")
c588255… ragelink 599 assert response.status_code == 200
c588255… ragelink 600 data = response.json()
c588255… ragelink 601 # All returned checkins should be on "trunk" branch
c588255… ragelink 602 for checkin in data["checkins"]:
c588255… ragelink 603 assert checkin["branch"] == "trunk"
c588255… ragelink 604
c588255… ragelink 605 def test_timeline_invalid_page_defaults(self, admin_client, sample_project, fossil_repo_obj):
c588255… ragelink 606 disk_patch, reader_patch, _ = _patch_api_fossil()
c588255… ragelink 607 with disk_patch, reader_patch:
c588255… ragelink 608 response = admin_client.get(_api_url(sample_project.slug, "timeline") + "?page=abc&per_page=xyz")
c588255… ragelink 609 assert response.status_code == 200
c588255… ragelink 610 data = response.json()
c588255… ragelink 611 assert data["page"] == 1
c588255… ragelink 612 assert data["per_page"] == 25 # default
c588255… ragelink 613
c588255… ragelink 614
c588255… ragelink 615 # ===========================================================================
c588255… ragelink 616 # Tickets Endpoint
c588255… ragelink 617 # ===========================================================================
c588255… ragelink 618
c588255… ragelink 619
c588255… ragelink 620 @pytest.mark.django_db
c588255… ragelink 621 class TestAPITickets:
c588255… ragelink 622 def test_tickets_returns_list(self, admin_client, sample_project, fossil_repo_obj):
c588255… ragelink 623 disk_patch, reader_patch, _ = _patch_api_fossil()
c588255… ragelink 624 with disk_patch, reader_patch:
c588255… ragelink 625 response = admin_client.get(_api_url(sample_project.slug, "tickets"))
c588255… ragelink 626 assert response.status_code == 200
c588255… ragelink 627 data = response.json()
c588255… ragelink 628 assert "tickets" in data
c588255… ragelink 629 assert "total" in data
c588255… ragelink 630 assert "page" in data
c588255… ragelink 631 assert "per_page" in data
c588255… ragelink 632 assert "total_pages" in data
c588255… ragelink 633 assert len(data["tickets"]) == 2
c588255… ragelink 634 ticket = data["tickets"][0]
c588255… ragelink 635 assert "uuid" in ticket
c588255… ragelink 636 assert "title" in ticket
c588255… ragelink 637 assert "status" in ticket
c588255… ragelink 638 assert "type" in ticket
c588255… ragelink 639 assert "created" in ticket
c588255… ragelink 640
c588255… ragelink 641 def test_tickets_status_filter(self, admin_client, sample_project, fossil_repo_obj):
c588255… ragelink 642 disk_patch, reader_patch, reader = _patch_api_fossil()
c588255… ragelink 643 with disk_patch, reader_patch:
c588255… ragelink 644 response = admin_client.get(_api_url(sample_project.slug, "tickets") + "?status=Open")
c588255… ragelink 645 assert response.status_code == 200
c588255… ragelink 646 # Verify the reader was called with the status filter
c588255… ragelink 647 reader.get_tickets.assert_called_once_with(status="Open", limit=1000)
c588255… ragelink 648
c588255… ragelink 649 def test_tickets_pagination(self, admin_client, sample_project, fossil_repo_obj):
c588255… ragelink 650 disk_patch, reader_patch, _ = _patch_api_fossil()
c588255… ragelink 651 with disk_patch, reader_patch:
c588255… ragelink 652 response = admin_client.get(_api_url(sample_project.slug, "tickets") + "?page=1&per_page=1")
c588255… ragelink 653 assert response.status_code == 200
c588255… ragelink 654 data = response.json()
c588255… ragelink 655 assert data["per_page"] == 1
c588255… ragelink 656 assert len(data["tickets"]) == 1
c588255… ragelink 657 assert data["total"] == 2
c588255… ragelink 658 assert data["total_pages"] == 2
c588255… ragelink 659
c588255… ragelink 660
c588255… ragelink 661 # ===========================================================================
c588255… ragelink 662 # Ticket Detail Endpoint
c588255… ragelink 663 # ===========================================================================
c588255… ragelink 664
c588255… ragelink 665
c588255… ragelink 666 @pytest.mark.django_db
c588255… ragelink 667 class TestAPITicketDetail:
c588255… ragelink 668 def test_ticket_detail_returns_ticket(self, admin_client, sample_project, fossil_repo_obj):
c588255… ragelink 669 disk_patch, reader_patch, _ = _patch_api_fossil()
c588255… ragelink 670 with disk_patch, reader_patch:
c588255… ragelink 671 response = admin_client.get(_api_url(sample_project.slug, "tickets/tkt-001-uuid"))
c588255… ragelink 672 assert response.status_code == 200
c588255… ragelink 673 data = response.json()
c588255… ragelink 674 assert data["uuid"] == "tkt-001-uuid"
c588255… ragelink 675 assert data["title"] == "Fix login bug"
c588255… ragelink 676 assert data["status"] == "Open"
c588255… ragelink 677 assert data["body"] == "Login fails when session expires."
c588255… ragelink 678 assert "comments" in data
c588255… ragelink 679 assert len(data["comments"]) == 1
c588255… ragelink 680 comment = data["comments"][0]
c588255… ragelink 681 assert comment["user"] == "bob"
c588255… ragelink 682 assert comment["comment"] == "I can reproduce this."
c588255… ragelink 683
c588255… ragelink 684 def test_ticket_detail_not_found(self, admin_client, sample_project, fossil_repo_obj):
c588255… ragelink 685 disk_patch, reader_patch, reader = _patch_api_fossil()
c588255… ragelink 686 reader.get_ticket_detail.return_value = None
c588255… ragelink 687 with disk_patch, reader_patch:
c588255… ragelink 688 response = admin_client.get(_api_url(sample_project.slug, "tickets/nonexistent-uuid"))
c588255… ragelink 689 assert response.status_code == 404
c588255… ragelink 690 assert response.json()["error"] == "Ticket not found"
c588255… ragelink 691
c588255… ragelink 692
c588255… ragelink 693 # ===========================================================================
c588255… ragelink 694 # Wiki List Endpoint
c588255… ragelink 695 # ===========================================================================
c588255… ragelink 696
c588255… ragelink 697
c588255… ragelink 698 @pytest.mark.django_db
c588255… ragelink 699 class TestAPIWikiList:
c588255… ragelink 700 def test_wiki_list_returns_pages(self, admin_client, sample_project, fossil_repo_obj):
c588255… ragelink 701 disk_patch, reader_patch, _ = _patch_api_fossil()
c588255… ragelink 702 with disk_patch, reader_patch:
c588255… ragelink 703 response = admin_client.get(_api_url(sample_project.slug, "wiki"))
c588255… ragelink 704 assert response.status_code == 200
c588255… ragelink 705 data = response.json()
c588255… ragelink 706 assert "pages" in data
c588255… ragelink 707 assert len(data["pages"]) == 2
c588255… ragelink 708 page = data["pages"][0]
c588255… ragelink 709 assert "name" in page
c588255… ragelink 710 assert "last_modified" in page
c588255… ragelink 711 assert "user" in page
c588255… ragelink 712
c588255… ragelink 713 def test_wiki_list_empty(self, admin_client, sample_project, fossil_repo_obj):
c588255… ragelink 714 disk_patch, reader_patch, reader = _patch_api_fossil()
c588255… ragelink 715 reader.get_wiki_pages.return_value = []
c588255… ragelink 716 with disk_patch, reader_patch:
c588255… ragelink 717 response = admin_client.get(_api_url(sample_project.slug, "wiki"))
c588255… ragelink 718 assert response.status_code == 200
c588255… ragelink 719 data = response.json()
c588255… ragelink 720 assert data["pages"] == []
c588255… ragelink 721
c588255… ragelink 722
c588255… ragelink 723 # ===========================================================================
c588255… ragelink 724 # Wiki Page Endpoint
c588255… ragelink 725 # ===========================================================================
c588255… ragelink 726
c588255… ragelink 727
c588255… ragelink 728 @pytest.mark.django_db
c588255… ragelink 729 class TestAPIWikiPage:
c588255… ragelink 730 def test_wiki_page_returns_content(self, admin_client, sample_project, fossil_repo_obj):
c588255… ragelink 731 disk_patch, reader_patch, _ = _patch_api_fossil()
c588255… ragelink 732 with disk_patch, reader_patch, patch("fossil.views._render_fossil_content", return_value="<h1>Welcome</h1>"):
c588255… ragelink 733 response = admin_client.get(_api_url(sample_project.slug, "wiki/Home"))
c588255… ragelink 734 assert response.status_code == 200
c588255… ragelink 735 data = response.json()
c588255… ragelink 736 assert data["name"] == "Home"
c588255… ragelink 737 assert data["content"] == "# Welcome\nThis is the home page."
c588255… ragelink 738 assert "content_html" in data
c588255… ragelink 739 assert "last_modified" in data
c588255… ragelink 740 assert data["user"] == "alice"
c588255… ragelink 741
c588255… ragelink 742 def test_wiki_page_not_found(self, admin_client, sample_project, fossil_repo_obj):
c588255… ragelink 743 disk_patch, reader_patch, reader = _patch_api_fossil()
c588255… ragelink 744 reader.get_wiki_page.return_value = None
c588255… ragelink 745 with disk_patch, reader_patch:
c588255… ragelink 746 response = admin_client.get(_api_url(sample_project.slug, "wiki/Nonexistent"))
c588255… ragelink 747 assert response.status_code == 404
c588255… ragelink 748 assert response.json()["error"] == "Wiki page not found"
c588255… ragelink 749
c588255… ragelink 750
c588255… ragelink 751 # ===========================================================================
c588255… ragelink 752 # Branches Endpoint
c588255… ragelink 753 # ===========================================================================
c588255… ragelink 754
c588255… ragelink 755
c588255… ragelink 756 @pytest.mark.django_db
c588255… ragelink 757 class TestAPIBranches:
c588255… ragelink 758 def test_branches_returns_list(self, admin_client, sample_project, fossil_repo_obj):
c588255… ragelink 759 disk_patch, reader_patch, _ = _patch_api_fossil()
c588255… ragelink 760 with disk_patch, reader_patch:
c588255… ragelink 761 response = admin_client.get(_api_url(sample_project.slug, "branches"))
c588255… ragelink 762 assert response.status_code == 200
c588255… ragelink 763 data = response.json()
c588255… ragelink 764 assert "branches" in data
c588255… ragelink 765 assert len(data["branches"]) == 2
c588255… ragelink 766 branch = data["branches"][0]
c588255… ragelink 767 assert "name" in branch
c588255… ragelink 768 assert "last_checkin" in branch
c588255… ragelink 769 assert "last_user" in branch
c588255… ragelink 770 assert "checkin_count" in branch
c588255… ragelink 771 assert "last_uuid" in branch
c588255… ragelink 772
c588255… ragelink 773 def test_branches_empty(self, admin_client, sample_project, fossil_repo_obj):
c588255… ragelink 774 disk_patch, reader_patch, reader = _patch_api_fossil()
c588255… ragelink 775 reader.get_branches.return_value = []
c588255… ragelink 776 with disk_patch, reader_patch:
c588255… ragelink 777 response = admin_client.get(_api_url(sample_project.slug, "branches"))
c588255… ragelink 778 assert response.status_code == 200
c588255… ragelink 779 assert response.json()["branches"] == []
c588255… ragelink 780
c588255… ragelink 781
c588255… ragelink 782 # ===========================================================================
c588255… ragelink 783 # Tags Endpoint
c588255… ragelink 784 # ===========================================================================
c588255… ragelink 785
c588255… ragelink 786
c588255… ragelink 787 @pytest.mark.django_db
c588255… ragelink 788 class TestAPITags:
c588255… ragelink 789 def test_tags_returns_list(self, admin_client, sample_project, fossil_repo_obj):
c588255… ragelink 790 disk_patch, reader_patch, _ = _patch_api_fossil()
c588255… ragelink 791 with disk_patch, reader_patch:
c588255… ragelink 792 response = admin_client.get(_api_url(sample_project.slug, "tags"))
c588255… ragelink 793 assert response.status_code == 200
c588255… ragelink 794 data = response.json()
c588255… ragelink 795 assert "tags" in data
c588255… ragelink 796 assert len(data["tags"]) == 1
c588255… ragelink 797 tag = data["tags"][0]
c588255… ragelink 798 assert tag["name"] == "v1.0.0"
c588255… ragelink 799 assert "timestamp" in tag
c588255… ragelink 800 assert "user" in tag
c588255… ragelink 801 assert "uuid" in tag
c588255… ragelink 802
c588255… ragelink 803 def test_tags_empty(self, admin_client, sample_project, fossil_repo_obj):
c588255… ragelink 804 disk_patch, reader_patch, reader = _patch_api_fossil()
c588255… ragelink 805 reader.get_tags.return_value = []
c588255… ragelink 806 with disk_patch, reader_patch:
c588255… ragelink 807 response = admin_client.get(_api_url(sample_project.slug, "tags"))
c588255… ragelink 808 assert response.status_code == 200
c588255… ragelink 809 assert response.json()["tags"] == []
c588255… ragelink 810
c588255… ragelink 811
c588255… ragelink 812 # ===========================================================================
c588255… ragelink 813 # Releases Endpoint
c588255… ragelink 814 # ===========================================================================
c588255… ragelink 815
c588255… ragelink 816
c588255… ragelink 817 @pytest.mark.django_db
c588255… ragelink 818 class TestAPIReleases:
c588255… ragelink 819 def test_releases_returns_list(self, admin_client, sample_project, fossil_repo_obj):
c588255… ragelink 820 Release.objects.create(
c588255… ragelink 821 repository=fossil_repo_obj,
c588255… ragelink 822 tag_name="v1.0.0",
c588255… ragelink 823 name="Version 1.0.0",
c588255… ragelink 824 body="Initial release.",
c588255… ragelink 825 is_prerelease=False,
c588255… ragelink 826 is_draft=False,
c588255… ragelink 827 published_at=timezone.now(),
c588255… ragelink 828 checkin_uuid="abc123",
c588255… ragelink 829 created_by=admin_client.session.get("_auth_user_id") and User.objects.first(),
c588255… ragelink 830 )
c588255… ragelink 831 response = admin_client.get(_api_url(sample_project.slug, "releases"))
c588255… ragelink 832 assert response.status_code == 200
c588255… ragelink 833 data = response.json()
c588255… ragelink 834 assert "releases" in data
c588255… ragelink 835 assert len(data["releases"]) == 1
c588255… ragelink 836 rel = data["releases"][0]
c588255… ragelink 837 assert rel["tag_name"] == "v1.0.0"
c588255… ragelink 838 assert rel["name"] == "Version 1.0.0"
c588255… ragelink 839 assert rel["body"] == "Initial release."
c588255… ragelink 840 assert "published_at" in rel
c588255… ragelink 841 assert "assets" in rel
c588255… ragelink 842
c588255… ragelink 843 def test_releases_hides_drafts_from_readers(self, client, sample_project, fossil_repo_obj, pat_token, admin_user):
c588255… ragelink 844 """Draft releases are hidden from users without write access."""
c588255… ragelink 845 # Create a draft release and a published release
c588255… ragelink 846 Release.objects.create(
c588255… ragelink 847 repository=fossil_repo_obj,
c588255… ragelink 848 tag_name="v0.9.0",
c588255… ragelink 849 name="Draft Release",
c588255… ragelink 850 is_draft=True,
c588255… ragelink 851 created_by=admin_user,
c588255… ragelink 852 )
c588255… ragelink 853 Release.objects.create(
c588255… ragelink 854 repository=fossil_repo_obj,
c588255… ragelink 855 tag_name="v1.0.0",
c588255… ragelink 856 name="Published Release",
c588255… ragelink 857 is_draft=False,
c588255… ragelink 858 published_at=timezone.now(),
c588255… ragelink 859 created_by=admin_user,
c588255… ragelink 860 )
c588255… ragelink 861
c588255… ragelink 862 # Create a read-only user with a PAT
c588255… ragelink 863 reader_user = User.objects.create_user(username="api_reader", password="testpass123")
c588255… ragelink 864 team = Team.objects.create(name="API Readers", organization=sample_project.organization, created_by=admin_user)
c588255… ragelink 865 team.members.add(reader_user)
c588255… ragelink 866 ProjectTeam.objects.create(project=sample_project, team=team, role="read", created_by=admin_user)
c588255… ragelink 867
c588255… ragelink 868 raw, token_hash, prefix = PersonalAccessToken.generate()
c588255… ragelink 869 PersonalAccessToken.objects.create(
c588255… ragelink 870 user=reader_user,
c588255… ragelink 871 name="Reader PAT",
c588255… ragelink 872 token_hash=token_hash,
c588255… ragelink 873 token_prefix=prefix,
c588255… ragelink 874 scopes="read",
c588255… ragelink 875 )
c588255… ragelink 876
c588255… ragelink 877 response = client.get(_api_url(sample_project.slug, "releases"), **_bearer_header(raw))
c588255… ragelink 878 assert response.status_code == 200
c588255… ragelink 879 data = response.json()
c588255… ragelink 880 # Reader should only see the published release, not the draft
c588255… ragelink 881 assert len(data["releases"]) == 1
c588255… ragelink 882 assert data["releases"][0]["tag_name"] == "v1.0.0"
c588255… ragelink 883
c588255… ragelink 884 def test_releases_shows_drafts_to_writers(self, client, sample_project, fossil_repo_obj, pat_token, admin_user):
c588255… ragelink 885 """Draft releases are visible to users with write access."""
c588255… ragelink 886 Release.objects.create(
c588255… ragelink 887 repository=fossil_repo_obj,
c588255… ragelink 888 tag_name="v0.9.0",
c588255… ragelink 889 name="Draft Release",
c588255… ragelink 890 is_draft=True,
c588255… ragelink 891 created_by=admin_user,
c588255… ragelink 892 )
c588255… ragelink 893 Release.objects.create(
c588255… ragelink 894 repository=fossil_repo_obj,
c588255… ragelink 895 tag_name="v1.0.0",
c588255… ragelink 896 name="Published Release",
c588255… ragelink 897 is_draft=False,
c588255… ragelink 898 published_at=timezone.now(),
c588255… ragelink 899 created_by=admin_user,
c588255… ragelink 900 )
c588255… ragelink 901
c588255… ragelink 902 # admin_user has write access via sample_team -> sample_project
c588255… ragelink 903 _, raw = pat_token # PAT for admin_user
c588255… ragelink 904 response = client.get(_api_url(sample_project.slug, "releases"), **_bearer_header(raw))
c588255… ragelink 905 assert response.status_code == 200
c588255… ragelink 906 data = response.json()
c588255… ragelink 907 assert len(data["releases"]) == 2
c588255… ragelink 908
c588255… ragelink 909 def test_releases_includes_assets(self, admin_client, sample_project, fossil_repo_obj, admin_user):
c588255… ragelink 910 release = Release.objects.create(
c588255… ragelink 911 repository=fossil_repo_obj,
c588255… ragelink 912 tag_name="v2.0.0",
c588255… ragelink 913 name="Version 2.0.0",
c588255… ragelink 914 is_draft=False,
c588255… ragelink 915 published_at=timezone.now(),
c588255… ragelink 916 created_by=admin_user,
c588255… ragelink 917 )
c588255… ragelink 918 ReleaseAsset.objects.create(
c588255… ragelink 919 release=release,
c588255… ragelink 920 name="app-v2.0.0.tar.gz",
c588255… ragelink 921 file_size_bytes=1024000,
c588255… ragelink 922 content_type="application/gzip",
c588255… ragelink 923 download_count=5,
c588255… ragelink 924 created_by=admin_user,
c588255… ragelink 925 )
c588255… ragelink 926 response = admin_client.get(_api_url(sample_project.slug, "releases"))
c588255… ragelink 927 assert response.status_code == 200
c588255… ragelink 928 data = response.json()
c588255… ragelink 929 assert len(data["releases"]) == 1
c588255… ragelink 930 assets = data["releases"][0]["assets"]
c588255… ragelink 931 assert len(assets) == 1
c588255… ragelink 932 assert assets[0]["name"] == "app-v2.0.0.tar.gz"
c588255… ragelink 933 assert assets[0]["file_size_bytes"] == 1024000
c588255… ragelink 934 assert assets[0]["download_count"] == 5
c588255… ragelink 935
c588255… ragelink 936 def test_releases_empty(self, admin_client, sample_project, fossil_repo_obj):
c588255… ragelink 937 response = admin_client.get(_api_url(sample_project.slug, "releases"))
c588255… ragelink 938 assert response.status_code == 200
c588255… ragelink 939 assert response.json()["releases"] == []
c588255… ragelink 940
c588255… ragelink 941
c588255… ragelink 942 # ===========================================================================
c588255… ragelink 943 # Search Endpoint
c588255… ragelink 944 # ===========================================================================
c588255… ragelink 945
c588255… ragelink 946
c588255… ragelink 947 @pytest.mark.django_db
c588255… ragelink 948 class TestAPISearch:
c588255… ragelink 949 def test_search_returns_results(self, admin_client, sample_project, fossil_repo_obj):
c588255… ragelink 950 disk_patch, reader_patch, _ = _patch_api_fossil()
c588255… ragelink 951 with disk_patch, reader_patch:
c588255… ragelink 952 response = admin_client.get(_api_url(sample_project.slug, "search") + "?q=login")
c588255… ragelink 953 assert response.status_code == 200
c588255… ragelink 954 data = response.json()
c588255… ragelink 955 assert "checkins" in data
c588255… ragelink 956 assert "tickets" in data
c588255… ragelink 957 assert "wiki" in data
c588255… ragelink 958 assert len(data["checkins"]) == 1
c588255… ragelink 959 assert len(data["tickets"]) == 1
c588255… ragelink 960 assert len(data["wiki"]) == 1
c588255… ragelink 961
c588255… ragelink 962 def test_search_missing_query_returns_400(self, admin_client, sample_project, fossil_repo_obj):
c588255… ragelink 963 disk_patch, reader_patch, _ = _patch_api_fossil()
c588255… ragelink 964 with disk_patch, reader_patch:
c588255… ragelink 965 response = admin_client.get(_api_url(sample_project.slug, "search"))
c588255… ragelink 966 assert response.status_code == 400
c588255… ragelink 967 assert response.json()["error"] == "Query parameter 'q' is required"
c588255… ragelink 968
c588255… ragelink 969 def test_search_empty_query_returns_400(self, admin_client, sample_project, fossil_repo_obj):
c588255… ragelink 970 disk_patch, reader_patch, _ = _patch_api_fossil()
c588255… ragelink 971 with disk_patch, reader_patch:
c588255… ragelink 972 response = admin_client.get(_api_url(sample_project.slug, "search") + "?q=")
c588255… ragelink 973 assert response.status_code == 400
c588255… ragelink 974
c588255… ragelink 975 def test_search_passes_query_to_reader(self, admin_client, sample_project, fossil_repo_obj):
c588255… ragelink 976 disk_patch, reader_patch, reader = _patch_api_fossil()
c588255… ragelink 977 with disk_patch, reader_patch:
c588255… ragelink 978 admin_client.get(_api_url(sample_project.slug, "search") + "?q=test+query")
c588255… ragelink 979 reader.search.assert_called_once_with("test query", limit=50)
c588255… ragelink 980
c588255… ragelink 981
c588255… ragelink 982 # ===========================================================================
c588255… ragelink 983 # HTTP Method Restrictions
c588255… ragelink 984 # ===========================================================================
c588255… ragelink 985
c588255… ragelink 986
c588255… ragelink 987 @pytest.mark.django_db
c588255… ragelink 988 class TestAPIMethodRestrictions:
c588255… ragelink 989 """All endpoints should only accept GET requests."""
c588255… ragelink 990
c588255… ragelink 991 def test_post_to_project_returns_405(self, admin_client, sample_project, fossil_repo_obj):
c588255… ragelink 992 disk_patch, reader_patch, _ = _patch_api_fossil()
c588255… ragelink 993 with disk_patch, reader_patch:
c588255… ragelink 994 response = admin_client.post(_api_url(sample_project.slug, "project"))
c588255… ragelink 995 assert response.status_code == 405
c588255… ragelink 996
c588255… ragelink 997 def test_post_to_timeline_returns_405(self, admin_client, sample_project, fossil_repo_obj):
c588255… ragelink 998 disk_patch, reader_patch, _ = _patch_api_fossil()
c588255… ragelink 999 with disk_patch, reader_patch:
c588255… ragelink 1000 response = admin_client.post(_api_url(sample_project.slug, "timeline"))
c588255… ragelink 1001 assert response.status_code == 405
c588255… ragelink 1002
c588255… ragelink 1003 def test_post_to_tickets_returns_405(self, admin_client, sample_project, fossil_repo_obj):
c588255… ragelink 1004 disk_patch, reader_patch, _ = _patch_api_fossil()
c588255… ragelink 1005 with disk_patch, reader_patch:
c588255… ragelink 1006 response = admin_client.post(_api_url(sample_project.slug, "tickets"))
c588255… ragelink 1007 assert response.status_code == 405
c588255… ragelink 1008
c588255… ragelink 1009 def test_post_to_search_returns_405(self, admin_client, sample_project, fossil_repo_obj):
c588255… ragelink 1010 disk_patch, reader_patch, _ = _patch_api_fossil()
c588255… ragelink 1011 with disk_patch, reader_patch:
c588255… ragelink 1012 response = admin_client.post(_api_url(sample_project.slug, "search"))
c588255… ragelink 1013 assert response.status_code == 405
c588255… ragelink 1014
c588255… ragelink 1015
c588255… ragelink 1016 # ===========================================================================
c588255… ragelink 1017 # Cross-endpoint auth consistency
c588255… ragelink 1018 # ===========================================================================
c588255… ragelink 1019
c588255… ragelink 1020
c588255… ragelink 1021 @pytest.mark.django_db
c588255… ragelink 1022 class TestAPIAllEndpointsRequireAuth:
c588255… ragelink 1023 """Every endpoint should return 401 for unauthenticated requests to private projects."""
c588255… ragelink 1024
c588255… ragelink 1025 @pytest.mark.parametrize(
c588255… ragelink 1026 "endpoint",
c588255… ragelink 1027 [
c588255… ragelink 1028 "project",
c588255… ragelink 1029 "timeline",
c588255… ragelink 1030 "tickets",
c588255… ragelink 1031 "tickets/some-uuid",
c588255… ragelink 1032 "wiki",
c588255… ragelink 1033 "wiki/Home",
c588255… ragelink 1034 "branches",
c588255… ragelink 1035 "tags",
c588255… ragelink 1036 "releases",
c588255… ragelink 1037 "search?q=test",
c588255… ragelink 1038 ],
c588255… ragelink 1039 )
c588255… ragelink 1040 def test_endpoint_requires_auth(self, anon_client, sample_project, fossil_repo_obj, endpoint):
c588255… ragelink 1041 disk_patch, reader_patch, _ = _patch_api_fossil()
c588255… ragelink 1042 with disk_patch, reader_patch:
c588255… ragelink 1043 response = anon_client.get(_api_url(sample_project.slug, endpoint))
c588255… ragelink 1044 assert response.status_code == 401

Keyboard Shortcuts

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