FossilRepo

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

Keyboard Shortcuts

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