FossilRepo

fossilrepo / tests / test_api_tokens.py
Blame History Raw 292 lines
1
import pytest
2
from django.contrib.auth.models import User
3
from django.test import Client
4
5
from fossil.api_tokens import APIToken, authenticate_api_token
6
from fossil.models import FossilRepository
7
from organization.models import Team
8
from projects.models import ProjectTeam
9
10
11
@pytest.fixture
12
def fossil_repo_obj(sample_project):
13
"""Return the auto-created FossilRepository for sample_project."""
14
return FossilRepository.objects.get(project=sample_project, deleted_at__isnull=True)
15
16
17
@pytest.fixture
18
def api_token(fossil_repo_obj, admin_user):
19
"""Create an API token and return (APIToken instance, raw_token)."""
20
raw, token_hash, prefix = APIToken.generate()
21
token = APIToken.objects.create(
22
repository=fossil_repo_obj,
23
name="Test Token",
24
token_hash=token_hash,
25
token_prefix=prefix,
26
permissions="status:write",
27
created_by=admin_user,
28
)
29
return token, raw
30
31
32
@pytest.fixture
33
def writer_user(db, admin_user, sample_project):
34
"""User with write access but not admin."""
35
writer = User.objects.create_user(username="writer_tok", password="testpass123")
36
team = Team.objects.create(name="Token Writers", organization=sample_project.organization, created_by=admin_user)
37
team.members.add(writer)
38
ProjectTeam.objects.create(project=sample_project, team=team, role="write", created_by=admin_user)
39
return writer
40
41
42
@pytest.fixture
43
def writer_client(writer_user):
44
client = Client()
45
client.login(username="writer_tok", password="testpass123")
46
return client
47
48
49
# --- APIToken Model Tests ---
50
51
52
@pytest.mark.django_db
53
class TestAPITokenModel:
54
def test_generate_token(self):
55
raw, token_hash, prefix = APIToken.generate()
56
assert raw.startswith("frp_")
57
assert len(token_hash) == 64 # SHA-256 hex digest
58
assert prefix == raw[:12]
59
60
def test_hash_token(self):
61
raw, token_hash, prefix = APIToken.generate()
62
assert APIToken.hash_token(raw) == token_hash
63
64
def test_create_token(self, api_token):
65
token, raw = api_token
66
assert token.pk is not None
67
assert "Test Token" in str(token)
68
assert token.token_prefix in str(token)
69
70
def test_soft_delete(self, api_token, admin_user):
71
token, _ = api_token
72
token.soft_delete(user=admin_user)
73
assert token.is_deleted
74
assert APIToken.objects.filter(pk=token.pk).count() == 0
75
assert APIToken.all_objects.filter(pk=token.pk).count() == 1
76
77
def test_has_permission(self, api_token):
78
token, _ = api_token
79
assert token.has_permission("status:write") is True
80
assert token.has_permission("status:read") is False
81
82
def test_has_permission_wildcard(self, fossil_repo_obj, admin_user):
83
raw, token_hash, prefix = APIToken.generate()
84
token = APIToken.objects.create(
85
repository=fossil_repo_obj,
86
name="Wildcard",
87
token_hash=token_hash,
88
token_prefix=prefix,
89
permissions="*",
90
created_by=admin_user,
91
)
92
assert token.has_permission("status:write") is True
93
assert token.has_permission("anything") is True
94
95
def test_unique_token_hash(self, fossil_repo_obj, admin_user, api_token):
96
"""Token hashes must be unique across all tokens."""
97
token, _ = api_token
98
from django.db import IntegrityError
99
100
with pytest.raises(IntegrityError):
101
APIToken.objects.create(
102
repository=fossil_repo_obj,
103
name="Duplicate Hash",
104
token_hash=token.token_hash,
105
token_prefix="dup_",
106
created_by=admin_user,
107
)
108
109
110
# --- authenticate_api_token Tests ---
111
112
113
@pytest.mark.django_db
114
class TestAuthenticateAPIToken:
115
def test_valid_token(self, api_token, fossil_repo_obj):
116
token, raw = api_token
117
118
class FakeRequest:
119
META = {"HTTP_AUTHORIZATION": f"Bearer {raw}"}
120
121
result = authenticate_api_token(FakeRequest(), fossil_repo_obj)
122
assert result is not None
123
assert result.pk == token.pk
124
125
def test_invalid_token(self, fossil_repo_obj):
126
class FakeRequest:
127
META = {"HTTP_AUTHORIZATION": "Bearer invalid_token_xyz"}
128
129
result = authenticate_api_token(FakeRequest(), fossil_repo_obj)
130
assert result is None
131
132
def test_no_auth_header(self, fossil_repo_obj):
133
class FakeRequest:
134
META = {}
135
136
result = authenticate_api_token(FakeRequest(), fossil_repo_obj)
137
assert result is None
138
139
def test_non_bearer_auth(self, fossil_repo_obj):
140
class FakeRequest:
141
META = {"HTTP_AUTHORIZATION": "Basic dXNlcjpwYXNz"}
142
143
result = authenticate_api_token(FakeRequest(), fossil_repo_obj)
144
assert result is None
145
146
def test_expired_token(self, fossil_repo_obj, admin_user):
147
from datetime import timedelta
148
149
from django.utils import timezone
150
151
raw, token_hash, prefix = APIToken.generate()
152
APIToken.objects.create(
153
repository=fossil_repo_obj,
154
name="Expired",
155
token_hash=token_hash,
156
token_prefix=prefix,
157
expires_at=timezone.now() - timedelta(days=1),
158
created_by=admin_user,
159
)
160
161
class FakeRequest:
162
META = {"HTTP_AUTHORIZATION": f"Bearer {raw}"}
163
164
result = authenticate_api_token(FakeRequest(), fossil_repo_obj)
165
assert result is None
166
167
def test_updates_last_used_at(self, api_token, fossil_repo_obj):
168
token, raw = api_token
169
assert token.last_used_at is None
170
171
class FakeRequest:
172
META = {"HTTP_AUTHORIZATION": f"Bearer {raw}"}
173
174
authenticate_api_token(FakeRequest(), fossil_repo_obj)
175
token.refresh_from_db()
176
assert token.last_used_at is not None
177
178
def test_deleted_token_not_found(self, api_token, fossil_repo_obj, admin_user):
179
token, raw = api_token
180
token.soft_delete(user=admin_user)
181
182
class FakeRequest:
183
META = {"HTTP_AUTHORIZATION": f"Bearer {raw}"}
184
185
result = authenticate_api_token(FakeRequest(), fossil_repo_obj)
186
assert result is None
187
188
189
# --- API Token List View Tests ---
190
191
192
@pytest.mark.django_db
193
class TestAPITokenListView:
194
def test_list_tokens(self, admin_client, sample_project, api_token):
195
response = admin_client.get(f"/projects/{sample_project.slug}/fossil/tokens/")
196
assert response.status_code == 200
197
content = response.content.decode()
198
assert "Test Token" in content
199
assert "status:write" in content
200
201
def test_list_empty(self, admin_client, sample_project, fossil_repo_obj):
202
response = admin_client.get(f"/projects/{sample_project.slug}/fossil/tokens/")
203
assert response.status_code == 200
204
assert "No API tokens generated yet" in response.content.decode()
205
206
def test_list_denied_for_writer(self, writer_client, sample_project, api_token):
207
"""Token management requires admin, not just write."""
208
response = writer_client.get(f"/projects/{sample_project.slug}/fossil/tokens/")
209
assert response.status_code == 403
210
211
def test_list_denied_for_no_perm(self, no_perm_client, sample_project):
212
response = no_perm_client.get(f"/projects/{sample_project.slug}/fossil/tokens/")
213
assert response.status_code == 403
214
215
def test_list_denied_for_anon(self, client, sample_project):
216
response = client.get(f"/projects/{sample_project.slug}/fossil/tokens/")
217
assert response.status_code == 302 # redirect to login
218
219
220
# --- API Token Create View Tests ---
221
222
223
@pytest.mark.django_db
224
class TestAPITokenCreateView:
225
def test_get_form(self, admin_client, sample_project, fossil_repo_obj):
226
response = admin_client.get(f"/projects/{sample_project.slug}/fossil/tokens/create/")
227
assert response.status_code == 200
228
assert "Generate API Token" in response.content.decode()
229
230
def test_create_token(self, admin_client, sample_project, fossil_repo_obj):
231
response = admin_client.post(
232
f"/projects/{sample_project.slug}/fossil/tokens/create/",
233
{"name": "New CI Token", "permissions": "status:write"},
234
)
235
assert response.status_code == 200 # Shows the token on the same page
236
content = response.content.decode()
237
assert "frp_" in content # Raw token is displayed
238
assert "Token Generated" in content
239
240
# Verify token was created in DB
241
token = APIToken.objects.get(name="New CI Token")
242
assert token.permissions == "status:write"
243
244
def test_create_token_without_name_fails(self, admin_client, sample_project, fossil_repo_obj):
245
response = admin_client.post(
246
f"/projects/{sample_project.slug}/fossil/tokens/create/",
247
{"name": "", "permissions": "status:write"},
248
)
249
assert response.status_code == 200
250
assert "Token name is required" in response.content.decode()
251
assert APIToken.objects.filter(repository__project=sample_project).count() == 0
252
253
def test_create_denied_for_writer(self, writer_client, sample_project):
254
response = writer_client.post(
255
f"/projects/{sample_project.slug}/fossil/tokens/create/",
256
{"name": "Evil Token"},
257
)
258
assert response.status_code == 403
259
260
261
# --- API Token Delete View Tests ---
262
263
264
@pytest.mark.django_db
265
class TestAPITokenDeleteView:
266
def test_delete_token(self, admin_client, sample_project, api_token):
267
token, _ = api_token
268
response = admin_client.post(f"/projects/{sample_project.slug}/fossil/tokens/{token.pk}/delete/")
269
assert response.status_code == 302
270
token.refresh_from_db()
271
assert token.is_deleted
272
273
def test_delete_get_redirects(self, admin_client, sample_project, api_token):
274
token, _ = api_token
275
response = admin_client.get(f"/projects/{sample_project.slug}/fossil/tokens/{token.pk}/delete/")
276
assert response.status_code == 302 # GET redirects to list
277
278
def test_delete_denied_for_writer(self, writer_client, sample_project, api_token):
279
token, _ = api_token
280
response = writer_client.post(f"/projects/{sample_project.slug}/fossil/tokens/{token.pk}/delete/")
281
assert response.status_code == 403
282
283
def test_delete_nonexistent_token(self, admin_client, sample_project, fossil_repo_obj):
284
response = admin_client.post(f"/projects/{sample_project.slug}/fossil/tokens/99999/delete/")
285
assert response.status_code == 404
286
287
def test_deleted_token_cannot_be_deleted_again(self, admin_client, sample_project, api_token, admin_user):
288
token, _ = api_token
289
token.soft_delete(user=admin_user)
290
response = admin_client.post(f"/projects/{sample_project.slug}/fossil/tokens/{token.pk}/delete/")
291
assert response.status_code == 404
292

Keyboard Shortcuts

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