FossilRepo

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

Keyboard Shortcuts

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