|
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 |