FossilRepo

fossilrepo / accounts / tests.py
Blame History Raw 309 lines
1
import pytest
2
from django.urls import reverse
3
4
from accounts.models import PersonalAccessToken, UserProfile
5
6
7
@pytest.mark.django_db
8
class TestLogin:
9
def test_login_page_renders(self, client):
10
response = client.get(reverse("accounts:login"))
11
assert response.status_code == 200
12
assert b"Sign in" in response.content
13
14
def test_login_success_redirects_to_dashboard(self, client, admin_user):
15
response = client.post(reverse("accounts:login"), {"username": "admin", "password": "testpass123"})
16
assert response.status_code == 302
17
assert response.url == reverse("dashboard")
18
19
def test_login_failure_shows_error(self, client, admin_user):
20
response = client.post(reverse("accounts:login"), {"username": "admin", "password": "wrong"})
21
assert response.status_code == 200
22
assert b"Invalid username or password" in response.content
23
24
def test_login_redirect_when_already_authenticated(self, admin_client):
25
response = admin_client.get(reverse("accounts:login"))
26
assert response.status_code == 302
27
28
def test_login_with_next_param(self, client, admin_user):
29
response = client.post(reverse("accounts:login") + "?next=/projects/", {"username": "admin", "password": "testpass123"})
30
assert response.status_code == 302
31
assert response.url == "/projects/"
32
33
34
@pytest.mark.django_db
35
class TestLogout:
36
def test_logout_redirects_to_login(self, admin_client):
37
response = admin_client.post(reverse("accounts:logout"))
38
assert response.status_code == 302
39
assert reverse("accounts:login") in response.url
40
41
def test_logout_clears_session(self, admin_client):
42
admin_client.post(reverse("accounts:logout"))
43
response = admin_client.get(reverse("dashboard"))
44
assert response.status_code == 302 # redirected to login
45
46
def test_logout_rejects_get(self, admin_client):
47
response = admin_client.get(reverse("accounts:logout"))
48
assert response.status_code == 405
49
50
51
# ---------------------------------------------------------------------------
52
# Profile views
53
# ---------------------------------------------------------------------------
54
55
56
@pytest.mark.django_db
57
class TestProfile:
58
def test_profile_page_renders(self, admin_client, admin_user):
59
response = admin_client.get(reverse("accounts:profile"))
60
assert response.status_code == 200
61
assert b"Profile Info" in response.content
62
assert b"SSH Keys" in response.content
63
assert b"Personal Access Tokens" in response.content
64
65
def test_profile_creates_user_profile_on_first_visit(self, admin_client, admin_user):
66
assert not UserProfile.objects.filter(user=admin_user).exists()
67
admin_client.get(reverse("accounts:profile"))
68
assert UserProfile.objects.filter(user=admin_user).exists()
69
70
def test_profile_requires_login(self, client):
71
response = client.get(reverse("accounts:profile"))
72
assert response.status_code == 302
73
assert "/auth/login/" in response.url
74
75
def test_profile_top_level_redirect(self, admin_client):
76
response = admin_client.get("/profile/")
77
assert response.status_code == 302
78
assert "/auth/profile/" in response.url
79
80
81
@pytest.mark.django_db
82
class TestProfileEdit:
83
def test_edit_page_renders(self, admin_client, admin_user):
84
response = admin_client.get(reverse("accounts:profile_edit"))
85
assert response.status_code == 200
86
assert b"Edit Profile" in response.content
87
88
def test_edit_updates_user_fields(self, admin_client, admin_user):
89
response = admin_client.post(
90
reverse("accounts:profile_edit"),
91
{
92
"first_name": "Alice",
93
"last_name": "Smith",
94
"email": "[email protected]",
95
"handle": "alice-s",
96
"bio": "Hello world",
97
"location": "NYC",
98
"website": "https://alice.dev",
99
},
100
)
101
assert response.status_code == 302
102
admin_user.refresh_from_db()
103
assert admin_user.first_name == "Alice"
104
assert admin_user.last_name == "Smith"
105
assert admin_user.email == "[email protected]"
106
profile = UserProfile.objects.get(user=admin_user)
107
assert profile.handle == "alice-s"
108
assert profile.bio == "Hello world"
109
assert profile.location == "NYC"
110
assert profile.website == "https://alice.dev"
111
112
def test_edit_sanitizes_handle(self, admin_client, admin_user):
113
admin_client.post(
114
reverse("accounts:profile_edit"),
115
{"handle": " UPPER Case! Stuff ", "first_name": "", "last_name": "", "email": ""},
116
)
117
profile = UserProfile.objects.get(user=admin_user)
118
assert profile.handle == "uppercasestuff"
119
120
def test_edit_handle_uniqueness(self, admin_client, admin_user, viewer_user):
121
# Create a profile with handle for viewer_user
122
UserProfile.objects.create(user=viewer_user, handle="taken-handle")
123
response = admin_client.post(
124
reverse("accounts:profile_edit"),
125
{"handle": "taken-handle", "first_name": "", "last_name": "", "email": ""},
126
)
127
assert response.status_code == 200 # re-renders form with error
128
assert b"already taken" in response.content
129
130
def test_edit_empty_handle_saves_as_none(self, admin_client, admin_user):
131
admin_client.post(
132
reverse("accounts:profile_edit"),
133
{"handle": "", "first_name": "", "last_name": "", "email": ""},
134
)
135
profile = UserProfile.objects.get(user=admin_user)
136
assert profile.handle is None
137
138
def test_edit_requires_login(self, client):
139
response = client.get(reverse("accounts:profile_edit"))
140
assert response.status_code == 302
141
assert "/auth/login/" in response.url
142
143
144
@pytest.mark.django_db
145
class TestPersonalAccessTokenCreate:
146
def test_create_form_renders(self, admin_client):
147
response = admin_client.get(reverse("accounts:profile_token_create"))
148
assert response.status_code == 200
149
assert b"Generate Personal Access Token" in response.content
150
151
def test_create_token_shows_raw_once(self, admin_client, admin_user):
152
response = admin_client.post(
153
reverse("accounts:profile_token_create"),
154
{"name": "CI Token", "scopes": "read,write"},
155
)
156
assert response.status_code == 200
157
assert b"frp_" in response.content
158
assert b"will not be shown again" in response.content
159
token = PersonalAccessToken.objects.get(user=admin_user, name="CI Token")
160
assert token.scopes == "read,write"
161
assert token.token_prefix.startswith("frp_")
162
163
def test_create_token_default_scope_is_read(self, admin_client, admin_user):
164
admin_client.post(
165
reverse("accounts:profile_token_create"),
166
{"name": "Default Token", "scopes": ""},
167
)
168
token = PersonalAccessToken.objects.get(user=admin_user, name="Default Token")
169
assert token.scopes == "read"
170
171
def test_create_token_rejects_invalid_scopes(self, admin_client, admin_user):
172
admin_client.post(
173
reverse("accounts:profile_token_create"),
174
{"name": "Bad Token", "scopes": "delete,destroy"},
175
)
176
token = PersonalAccessToken.objects.get(user=admin_user, name="Bad Token")
177
assert token.scopes == "read" # falls back to read
178
179
def test_create_token_requires_name(self, admin_client, admin_user):
180
response = admin_client.post(
181
reverse("accounts:profile_token_create"),
182
{"name": "", "scopes": "read"},
183
)
184
assert response.status_code == 200
185
assert b"Token name is required" in response.content
186
assert PersonalAccessToken.objects.filter(user=admin_user).count() == 0
187
188
def test_create_token_requires_login(self, client):
189
response = client.get(reverse("accounts:profile_token_create"))
190
assert response.status_code == 302
191
assert "/auth/login/" in response.url
192
193
194
@pytest.mark.django_db
195
class TestPersonalAccessTokenRevoke:
196
def test_revoke_token(self, admin_client, admin_user):
197
raw, token_hash, prefix = PersonalAccessToken.generate()
198
token = PersonalAccessToken.objects.create(user=admin_user, name="To Revoke", token_hash=token_hash, token_prefix=prefix)
199
response = admin_client.post(reverse("accounts:profile_token_revoke", kwargs={"guid": prefix}))
200
assert response.status_code == 302
201
token.refresh_from_db()
202
assert token.revoked_at is not None
203
204
def test_revoke_token_htmx(self, admin_client, admin_user):
205
raw, token_hash, prefix = PersonalAccessToken.generate()
206
PersonalAccessToken.objects.create(user=admin_user, name="HX Revoke", token_hash=token_hash, token_prefix=prefix)
207
response = admin_client.post(
208
reverse("accounts:profile_token_revoke", kwargs={"guid": prefix}),
209
HTTP_HX_REQUEST="true",
210
)
211
assert response.status_code == 200
212
assert response["HX-Redirect"] == "/auth/profile/"
213
214
def test_revoke_token_wrong_user(self, admin_client, viewer_user):
215
"""Cannot revoke another user's token."""
216
raw, token_hash, prefix = PersonalAccessToken.generate()
217
PersonalAccessToken.objects.create(user=viewer_user, name="Other User", token_hash=token_hash, token_prefix=prefix)
218
response = admin_client.post(reverse("accounts:profile_token_revoke", kwargs={"guid": prefix}))
219
assert response.status_code == 404
220
221
def test_revoke_already_revoked(self, admin_client, admin_user):
222
from django.utils import timezone
223
224
raw, token_hash, prefix = PersonalAccessToken.generate()
225
PersonalAccessToken.objects.create(
226
user=admin_user, name="Already Revoked", token_hash=token_hash, token_prefix=prefix, revoked_at=timezone.now()
227
)
228
response = admin_client.post(reverse("accounts:profile_token_revoke", kwargs={"guid": prefix}))
229
assert response.status_code == 404
230
231
def test_revoke_requires_post(self, admin_client, admin_user):
232
raw, token_hash, prefix = PersonalAccessToken.generate()
233
PersonalAccessToken.objects.create(user=admin_user, name="GET test", token_hash=token_hash, token_prefix=prefix)
234
response = admin_client.get(reverse("accounts:profile_token_revoke", kwargs={"guid": prefix}))
235
assert response.status_code == 405
236
237
def test_revoke_requires_login(self, client):
238
response = client.post(reverse("accounts:profile_token_revoke", kwargs={"guid": "frp_xxxxxxx"}))
239
assert response.status_code == 302
240
assert "/auth/login/" in response.url
241
242
243
# ---------------------------------------------------------------------------
244
# Model unit tests
245
# ---------------------------------------------------------------------------
246
247
248
@pytest.mark.django_db
249
class TestUserProfileModel:
250
def test_str_with_handle(self, admin_user):
251
profile = UserProfile.objects.create(user=admin_user, handle="testhandle")
252
assert str(profile) == "@testhandle"
253
254
def test_str_without_handle(self, admin_user):
255
profile = UserProfile.objects.create(user=admin_user)
256
assert str(profile) == "@admin"
257
258
def test_sanitize_handle(self):
259
assert UserProfile.sanitize_handle("Hello World!") == "helloworld"
260
assert UserProfile.sanitize_handle(" --test-handle-- ") == "test-handle"
261
assert UserProfile.sanitize_handle("UPPER_CASE") == "uppercase"
262
assert UserProfile.sanitize_handle("") == ""
263
264
def test_multiple_null_handles_allowed(self, admin_user, viewer_user):
265
"""Multiple profiles with handle=None should not violate unique constraint."""
266
UserProfile.objects.create(user=admin_user, handle=None)
267
UserProfile.objects.create(user=viewer_user, handle=None)
268
assert UserProfile.objects.filter(handle__isnull=True).count() == 2
269
270
271
@pytest.mark.django_db
272
class TestPersonalAccessTokenModel:
273
def test_generate_returns_triple(self):
274
raw, hash_val, prefix = PersonalAccessToken.generate()
275
assert raw.startswith("frp_")
276
assert len(hash_val) == 64
277
assert prefix == raw[:12]
278
279
def test_hash_token_matches_generate(self):
280
raw, expected_hash, _ = PersonalAccessToken.generate()
281
assert PersonalAccessToken.hash_token(raw) == expected_hash
282
283
def test_is_expired(self, admin_user):
284
from django.utils import timezone
285
286
token = PersonalAccessToken(user=admin_user, expires_at=timezone.now() - timezone.timedelta(days=1))
287
assert token.is_expired is True
288
289
def test_is_not_expired(self, admin_user):
290
from django.utils import timezone
291
292
token = PersonalAccessToken(user=admin_user, expires_at=timezone.now() + timezone.timedelta(days=1))
293
assert token.is_expired is False
294
295
def test_is_active(self, admin_user):
296
token = PersonalAccessToken(user=admin_user)
297
assert token.is_active is True
298
299
def test_is_revoked(self, admin_user):
300
from django.utils import timezone
301
302
token = PersonalAccessToken(user=admin_user, revoked_at=timezone.now())
303
assert token.is_active is False
304
assert token.is_revoked is True
305
306
def test_str(self, admin_user):
307
token = PersonalAccessToken(user=admin_user, name="Test", token_prefix="frp_abc12345")
308
assert str(token) == "Test (frp_abc12345...)"
309

Keyboard Shortcuts

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