authentik.core.tests.test_users_avatars
Test Users Avatars
1"""Test Users Avatars""" 2 3from json import loads 4 5from django.core.cache import cache 6from django.urls.base import reverse 7from requests_mock import Mocker 8from rest_framework.test import APITestCase 9 10from authentik.core.models import User 11from authentik.core.tests.utils import create_test_admin_user 12from authentik.tenants.utils import get_current_tenant 13 14 15class TestUsersAvatars(APITestCase): 16 """Test Users avatars""" 17 18 def setUp(self) -> None: 19 self.admin = create_test_admin_user() 20 self.user = User.objects.create(username="test-user") 21 22 def set_avatar_mode(self, mode: str): 23 """Set the avatar mode on the current tenant.""" 24 tenant = get_current_tenant() 25 tenant.avatars = mode 26 tenant.save() 27 28 def test_avatars_none(self): 29 """Test avatars none""" 30 self.set_avatar_mode("none") 31 self.client.force_login(self.admin) 32 response = self.client.get(reverse("authentik_api:user-me")) 33 self.assertEqual(response.status_code, 200) 34 body = loads(response.content.decode()) 35 self.assertEqual(body["user"]["avatar"], "/static/dist/assets/images/user_default.png") 36 37 def test_avatars_gravatar(self): 38 """Test avatars gravatar""" 39 self.set_avatar_mode("gravatar") 40 self.admin.email = "static@t.goauthentik.io" 41 self.admin.save() 42 self.client.force_login(self.admin) 43 with Mocker() as mocker: 44 mocker.head( 45 ( 46 "https://www.gravatar.com/avatar/76eb3c74c8beb6faa037f1b6e2ecb3e252bdac" 47 "6cf71fb567ae36025a9d4ea86b?size=158&rating=g&default=404" 48 ), 49 text="foo", 50 headers={"Content-Type": "image/png"}, 51 ) 52 response = self.client.get(reverse("authentik_api:user-me")) 53 self.assertEqual(response.status_code, 200) 54 body = loads(response.content.decode()) 55 self.assertIn("gravatar", body["user"]["avatar"]) 56 57 def test_avatars_initials(self): 58 """Test avatars initials""" 59 self.set_avatar_mode("initials") 60 self.client.force_login(self.admin) 61 response = self.client.get(reverse("authentik_api:user-me")) 62 self.assertEqual(response.status_code, 200) 63 body = loads(response.content.decode()) 64 self.assertIn("data:image/svg+xml;base64,", body["user"]["avatar"]) 65 66 def test_avatars_custom(self): 67 """Test avatars custom""" 68 self.set_avatar_mode("foo://%(username)s") 69 self.client.force_login(self.admin) 70 response = self.client.get(reverse("authentik_api:user-me")) 71 self.assertEqual(response.status_code, 200) 72 body = loads(response.content.decode()) 73 self.assertEqual(body["user"]["avatar"], f"foo://{self.admin.username}") 74 75 def test_avatars_attributes(self): 76 """Test avatars attributes""" 77 self.set_avatar_mode("attributes.foo.avatar") 78 self.admin.attributes = {"foo": {"avatar": "bar"}} 79 self.admin.save() 80 self.client.force_login(self.admin) 81 response = self.client.get(reverse("authentik_api:user-me")) 82 self.assertEqual(response.status_code, 200) 83 body = loads(response.content.decode()) 84 self.assertEqual(body["user"]["avatar"], "bar") 85 86 def test_avatars_fallback(self): 87 """Test fallback""" 88 self.set_avatar_mode("attributes.foo.avatar,initials") 89 self.client.force_login(self.admin) 90 response = self.client.get(reverse("authentik_api:user-me")) 91 self.assertEqual(response.status_code, 200) 92 body = loads(response.content.decode()) 93 self.assertIn("data:image/svg+xml;base64,", body["user"]["avatar"]) 94 95 def test_avatars_custom_content_type_valid(self): 96 """Test custom avatar URL with valid image Content-Type""" 97 cache.clear() 98 self.set_avatar_mode("https://example.com/avatar/%(username)s") 99 self.client.force_login(self.admin) 100 with Mocker() as mocker: 101 mocker.head( 102 f"https://example.com/avatar/{self.admin.username}", 103 headers={"Content-Type": "image/png"}, 104 ) 105 response = self.client.get(reverse("authentik_api:user-me")) 106 self.assertEqual(response.status_code, 200) 107 body = loads(response.content.decode()) 108 self.assertEqual( 109 body["user"]["avatar"], f"https://example.com/avatar/{self.admin.username}" 110 ) 111 112 def test_avatars_custom_content_type_invalid(self): 113 """Test custom avatar URL with invalid Content-Type falls back""" 114 cache.clear() 115 self.set_avatar_mode("https://example.com/avatar/%(username)s,initials") 116 self.client.force_login(self.admin) 117 with Mocker() as mocker: 118 mocker.head( 119 f"https://example.com/avatar/{self.admin.username}", 120 headers={"Content-Type": "text/html"}, 121 ) 122 response = self.client.get(reverse("authentik_api:user-me")) 123 self.assertEqual(response.status_code, 200) 124 body = loads(response.content.decode()) 125 # Should fallback to initials since Content-Type is not image/* 126 self.assertIn("data:image/svg+xml;base64,", body["user"]["avatar"]) 127 128 def test_avatars_custom_content_type_missing(self): 129 """Test custom avatar URL with missing Content-Type header falls back""" 130 cache.clear() 131 self.set_avatar_mode("https://example.com/avatar/%(username)s,initials") 132 self.client.force_login(self.admin) 133 with Mocker() as mocker: 134 mocker.head( 135 f"https://example.com/avatar/{self.admin.username}", 136 headers={}, 137 ) 138 response = self.client.get(reverse("authentik_api:user-me")) 139 self.assertEqual(response.status_code, 200) 140 body = loads(response.content.decode()) 141 # Should fallback to initials since Content-Type header is missing 142 self.assertIn("data:image/svg+xml;base64,", body["user"]["avatar"]) 143 144 def test_avatars_custom_404_cached(self): 145 """Test that 404 responses are cached with TTL""" 146 cache.clear() 147 self.set_avatar_mode("https://example.com/avatar/%(username)s") 148 self.client.force_login(self.admin) 149 with Mocker() as mocker: 150 mocker.head( 151 f"https://example.com/avatar/{self.admin.username}", 152 status_code=404, 153 ) 154 response = self.client.get(reverse("authentik_api:user-me")) 155 self.assertEqual(response.status_code, 200) 156 body = loads(response.content.decode()) 157 # Should fallback to default avatar 158 self.assertEqual(body["user"]["avatar"], "/static/dist/assets/images/user_default.png") 159 160 # Verify cache was set with the expected structure 161 from hashlib import md5 162 163 mail_hash = md5(self.admin.email.lower().encode("utf-8"), usedforsecurity=False).hexdigest() 164 cache_key = f"goauthentik.io/lib/avatars/example.com/{mail_hash}" 165 self.assertIsNone(cache.get(cache_key)) 166 # Verify TTL was set (cache entry exists) 167 self.assertTrue(cache.has_key(cache_key)) 168 169 def test_avatars_custom_redirect(self): 170 """Test custom avatar URL follows redirects""" 171 cache.clear() 172 self.set_avatar_mode("https://example.com/avatar/%(username)s") 173 self.client.force_login(self.admin) 174 with Mocker() as mocker: 175 # Mock a redirect 176 mocker.head( 177 f"https://example.com/avatar/{self.admin.username}", 178 status_code=302, 179 headers={"Location": "https://cdn.example.com/final-avatar.png"}, 180 ) 181 mocker.head( 182 "https://cdn.example.com/final-avatar.png", 183 headers={"Content-Type": "image/png"}, 184 ) 185 response = self.client.get(reverse("authentik_api:user-me")) 186 self.assertEqual(response.status_code, 200) 187 body = loads(response.content.decode()) 188 # Should return the original URL (not the redirect destination) 189 self.assertEqual( 190 body["user"]["avatar"], f"https://example.com/avatar/{self.admin.username}" 191 ) 192 193 def test_avatars_hostname_availability_cache(self): 194 """Test that hostname availability is cached when domain fails""" 195 from requests.exceptions import Timeout 196 197 cache.clear() 198 self.set_avatar_mode("https://failing.example.com/avatar/%(username)s,initials") 199 self.client.force_login(self.admin) 200 201 with Mocker() as mocker: 202 # First request times out 203 mocker.head( 204 f"https://failing.example.com/avatar/{self.admin.username}", 205 exc=Timeout("Connection timeout"), 206 ) 207 response = self.client.get(reverse("authentik_api:user-me")) 208 209 self.assertEqual(response.status_code, 200) 210 body = loads(response.content.decode()) 211 # Should fallback to initials 212 self.assertIn("data:image/svg+xml;base64,", body["user"]["avatar"]) 213 214 # Verify hostname is marked as unavailable 215 cache_key_hostname = "goauthentik.io/lib/avatars/failing.example.com/available" 216 self.assertFalse(cache.get(cache_key_hostname, True)) 217 218 # Second request should not even try to fetch (hostname cached as unavailable) 219 with Mocker() as mocker: 220 # This should NOT be called due to hostname cache 221 mocker.head( 222 f"https://failing.example.com/avatar/{self.admin.username}", 223 headers={"Content-Type": "image/png"}, 224 ) 225 response = self.client.get(reverse("authentik_api:user-me")) 226 227 self.assertEqual(response.status_code, 200) 228 body = loads(response.content.decode()) 229 # Should still fallback to initials without making a request 230 self.assertIn("data:image/svg+xml;base64,", body["user"]["avatar"]) 231 # Verify no request was made (request_history should be empty) 232 self.assertEqual(len(mocker.request_history), 0) 233 234 def test_avatars_gravatar_uses_url_validation(self): 235 """Test that Gravatar now uses avatar_mode_url validation (regression test)""" 236 cache.clear() 237 self.set_avatar_mode("gravatar") 238 self.admin.email = "test@example.com" 239 self.admin.save() 240 self.client.force_login(self.admin) 241 242 with Mocker() as mocker: 243 # Mock Gravatar to return non-image content 244 from hashlib import sha256 245 246 mail_hash = sha256(self.admin.email.lower().encode("utf-8")).hexdigest() 247 gravatar_url = ( 248 f"https://www.gravatar.com/avatar/{mail_hash}?size=158&rating=g&default=404" 249 ) 250 251 mocker.head( 252 gravatar_url, 253 headers={"Content-Type": "text/html"}, 254 ) 255 response = self.client.get(reverse("authentik_api:user-me")) 256 257 self.assertEqual(response.status_code, 200) 258 body = loads(response.content.decode()) 259 # Should fallback to default avatar since Content-Type is not image/* 260 self.assertEqual(body["user"]["avatar"], "/static/dist/assets/images/user_default.png")
class
TestUsersAvatars(rest_framework.test.APITestCase):
16class TestUsersAvatars(APITestCase): 17 """Test Users avatars""" 18 19 def setUp(self) -> None: 20 self.admin = create_test_admin_user() 21 self.user = User.objects.create(username="test-user") 22 23 def set_avatar_mode(self, mode: str): 24 """Set the avatar mode on the current tenant.""" 25 tenant = get_current_tenant() 26 tenant.avatars = mode 27 tenant.save() 28 29 def test_avatars_none(self): 30 """Test avatars none""" 31 self.set_avatar_mode("none") 32 self.client.force_login(self.admin) 33 response = self.client.get(reverse("authentik_api:user-me")) 34 self.assertEqual(response.status_code, 200) 35 body = loads(response.content.decode()) 36 self.assertEqual(body["user"]["avatar"], "/static/dist/assets/images/user_default.png") 37 38 def test_avatars_gravatar(self): 39 """Test avatars gravatar""" 40 self.set_avatar_mode("gravatar") 41 self.admin.email = "static@t.goauthentik.io" 42 self.admin.save() 43 self.client.force_login(self.admin) 44 with Mocker() as mocker: 45 mocker.head( 46 ( 47 "https://www.gravatar.com/avatar/76eb3c74c8beb6faa037f1b6e2ecb3e252bdac" 48 "6cf71fb567ae36025a9d4ea86b?size=158&rating=g&default=404" 49 ), 50 text="foo", 51 headers={"Content-Type": "image/png"}, 52 ) 53 response = self.client.get(reverse("authentik_api:user-me")) 54 self.assertEqual(response.status_code, 200) 55 body = loads(response.content.decode()) 56 self.assertIn("gravatar", body["user"]["avatar"]) 57 58 def test_avatars_initials(self): 59 """Test avatars initials""" 60 self.set_avatar_mode("initials") 61 self.client.force_login(self.admin) 62 response = self.client.get(reverse("authentik_api:user-me")) 63 self.assertEqual(response.status_code, 200) 64 body = loads(response.content.decode()) 65 self.assertIn("data:image/svg+xml;base64,", body["user"]["avatar"]) 66 67 def test_avatars_custom(self): 68 """Test avatars custom""" 69 self.set_avatar_mode("foo://%(username)s") 70 self.client.force_login(self.admin) 71 response = self.client.get(reverse("authentik_api:user-me")) 72 self.assertEqual(response.status_code, 200) 73 body = loads(response.content.decode()) 74 self.assertEqual(body["user"]["avatar"], f"foo://{self.admin.username}") 75 76 def test_avatars_attributes(self): 77 """Test avatars attributes""" 78 self.set_avatar_mode("attributes.foo.avatar") 79 self.admin.attributes = {"foo": {"avatar": "bar"}} 80 self.admin.save() 81 self.client.force_login(self.admin) 82 response = self.client.get(reverse("authentik_api:user-me")) 83 self.assertEqual(response.status_code, 200) 84 body = loads(response.content.decode()) 85 self.assertEqual(body["user"]["avatar"], "bar") 86 87 def test_avatars_fallback(self): 88 """Test fallback""" 89 self.set_avatar_mode("attributes.foo.avatar,initials") 90 self.client.force_login(self.admin) 91 response = self.client.get(reverse("authentik_api:user-me")) 92 self.assertEqual(response.status_code, 200) 93 body = loads(response.content.decode()) 94 self.assertIn("data:image/svg+xml;base64,", body["user"]["avatar"]) 95 96 def test_avatars_custom_content_type_valid(self): 97 """Test custom avatar URL with valid image Content-Type""" 98 cache.clear() 99 self.set_avatar_mode("https://example.com/avatar/%(username)s") 100 self.client.force_login(self.admin) 101 with Mocker() as mocker: 102 mocker.head( 103 f"https://example.com/avatar/{self.admin.username}", 104 headers={"Content-Type": "image/png"}, 105 ) 106 response = self.client.get(reverse("authentik_api:user-me")) 107 self.assertEqual(response.status_code, 200) 108 body = loads(response.content.decode()) 109 self.assertEqual( 110 body["user"]["avatar"], f"https://example.com/avatar/{self.admin.username}" 111 ) 112 113 def test_avatars_custom_content_type_invalid(self): 114 """Test custom avatar URL with invalid Content-Type falls back""" 115 cache.clear() 116 self.set_avatar_mode("https://example.com/avatar/%(username)s,initials") 117 self.client.force_login(self.admin) 118 with Mocker() as mocker: 119 mocker.head( 120 f"https://example.com/avatar/{self.admin.username}", 121 headers={"Content-Type": "text/html"}, 122 ) 123 response = self.client.get(reverse("authentik_api:user-me")) 124 self.assertEqual(response.status_code, 200) 125 body = loads(response.content.decode()) 126 # Should fallback to initials since Content-Type is not image/* 127 self.assertIn("data:image/svg+xml;base64,", body["user"]["avatar"]) 128 129 def test_avatars_custom_content_type_missing(self): 130 """Test custom avatar URL with missing Content-Type header falls back""" 131 cache.clear() 132 self.set_avatar_mode("https://example.com/avatar/%(username)s,initials") 133 self.client.force_login(self.admin) 134 with Mocker() as mocker: 135 mocker.head( 136 f"https://example.com/avatar/{self.admin.username}", 137 headers={}, 138 ) 139 response = self.client.get(reverse("authentik_api:user-me")) 140 self.assertEqual(response.status_code, 200) 141 body = loads(response.content.decode()) 142 # Should fallback to initials since Content-Type header is missing 143 self.assertIn("data:image/svg+xml;base64,", body["user"]["avatar"]) 144 145 def test_avatars_custom_404_cached(self): 146 """Test that 404 responses are cached with TTL""" 147 cache.clear() 148 self.set_avatar_mode("https://example.com/avatar/%(username)s") 149 self.client.force_login(self.admin) 150 with Mocker() as mocker: 151 mocker.head( 152 f"https://example.com/avatar/{self.admin.username}", 153 status_code=404, 154 ) 155 response = self.client.get(reverse("authentik_api:user-me")) 156 self.assertEqual(response.status_code, 200) 157 body = loads(response.content.decode()) 158 # Should fallback to default avatar 159 self.assertEqual(body["user"]["avatar"], "/static/dist/assets/images/user_default.png") 160 161 # Verify cache was set with the expected structure 162 from hashlib import md5 163 164 mail_hash = md5(self.admin.email.lower().encode("utf-8"), usedforsecurity=False).hexdigest() 165 cache_key = f"goauthentik.io/lib/avatars/example.com/{mail_hash}" 166 self.assertIsNone(cache.get(cache_key)) 167 # Verify TTL was set (cache entry exists) 168 self.assertTrue(cache.has_key(cache_key)) 169 170 def test_avatars_custom_redirect(self): 171 """Test custom avatar URL follows redirects""" 172 cache.clear() 173 self.set_avatar_mode("https://example.com/avatar/%(username)s") 174 self.client.force_login(self.admin) 175 with Mocker() as mocker: 176 # Mock a redirect 177 mocker.head( 178 f"https://example.com/avatar/{self.admin.username}", 179 status_code=302, 180 headers={"Location": "https://cdn.example.com/final-avatar.png"}, 181 ) 182 mocker.head( 183 "https://cdn.example.com/final-avatar.png", 184 headers={"Content-Type": "image/png"}, 185 ) 186 response = self.client.get(reverse("authentik_api:user-me")) 187 self.assertEqual(response.status_code, 200) 188 body = loads(response.content.decode()) 189 # Should return the original URL (not the redirect destination) 190 self.assertEqual( 191 body["user"]["avatar"], f"https://example.com/avatar/{self.admin.username}" 192 ) 193 194 def test_avatars_hostname_availability_cache(self): 195 """Test that hostname availability is cached when domain fails""" 196 from requests.exceptions import Timeout 197 198 cache.clear() 199 self.set_avatar_mode("https://failing.example.com/avatar/%(username)s,initials") 200 self.client.force_login(self.admin) 201 202 with Mocker() as mocker: 203 # First request times out 204 mocker.head( 205 f"https://failing.example.com/avatar/{self.admin.username}", 206 exc=Timeout("Connection timeout"), 207 ) 208 response = self.client.get(reverse("authentik_api:user-me")) 209 210 self.assertEqual(response.status_code, 200) 211 body = loads(response.content.decode()) 212 # Should fallback to initials 213 self.assertIn("data:image/svg+xml;base64,", body["user"]["avatar"]) 214 215 # Verify hostname is marked as unavailable 216 cache_key_hostname = "goauthentik.io/lib/avatars/failing.example.com/available" 217 self.assertFalse(cache.get(cache_key_hostname, True)) 218 219 # Second request should not even try to fetch (hostname cached as unavailable) 220 with Mocker() as mocker: 221 # This should NOT be called due to hostname cache 222 mocker.head( 223 f"https://failing.example.com/avatar/{self.admin.username}", 224 headers={"Content-Type": "image/png"}, 225 ) 226 response = self.client.get(reverse("authentik_api:user-me")) 227 228 self.assertEqual(response.status_code, 200) 229 body = loads(response.content.decode()) 230 # Should still fallback to initials without making a request 231 self.assertIn("data:image/svg+xml;base64,", body["user"]["avatar"]) 232 # Verify no request was made (request_history should be empty) 233 self.assertEqual(len(mocker.request_history), 0) 234 235 def test_avatars_gravatar_uses_url_validation(self): 236 """Test that Gravatar now uses avatar_mode_url validation (regression test)""" 237 cache.clear() 238 self.set_avatar_mode("gravatar") 239 self.admin.email = "test@example.com" 240 self.admin.save() 241 self.client.force_login(self.admin) 242 243 with Mocker() as mocker: 244 # Mock Gravatar to return non-image content 245 from hashlib import sha256 246 247 mail_hash = sha256(self.admin.email.lower().encode("utf-8")).hexdigest() 248 gravatar_url = ( 249 f"https://www.gravatar.com/avatar/{mail_hash}?size=158&rating=g&default=404" 250 ) 251 252 mocker.head( 253 gravatar_url, 254 headers={"Content-Type": "text/html"}, 255 ) 256 response = self.client.get(reverse("authentik_api:user-me")) 257 258 self.assertEqual(response.status_code, 200) 259 body = loads(response.content.decode()) 260 # Should fallback to default avatar since Content-Type is not image/* 261 self.assertEqual(body["user"]["avatar"], "/static/dist/assets/images/user_default.png")
Test Users avatars
def
setUp(self) -> None:
19 def setUp(self) -> None: 20 self.admin = create_test_admin_user() 21 self.user = User.objects.create(username="test-user")
Hook method for setting up the test fixture before exercising it.
def
set_avatar_mode(self, mode: str):
23 def set_avatar_mode(self, mode: str): 24 """Set the avatar mode on the current tenant.""" 25 tenant = get_current_tenant() 26 tenant.avatars = mode 27 tenant.save()
Set the avatar mode on the current tenant.
def
test_avatars_none(self):
29 def test_avatars_none(self): 30 """Test avatars none""" 31 self.set_avatar_mode("none") 32 self.client.force_login(self.admin) 33 response = self.client.get(reverse("authentik_api:user-me")) 34 self.assertEqual(response.status_code, 200) 35 body = loads(response.content.decode()) 36 self.assertEqual(body["user"]["avatar"], "/static/dist/assets/images/user_default.png")
Test avatars none
def
test_avatars_gravatar(self):
38 def test_avatars_gravatar(self): 39 """Test avatars gravatar""" 40 self.set_avatar_mode("gravatar") 41 self.admin.email = "static@t.goauthentik.io" 42 self.admin.save() 43 self.client.force_login(self.admin) 44 with Mocker() as mocker: 45 mocker.head( 46 ( 47 "https://www.gravatar.com/avatar/76eb3c74c8beb6faa037f1b6e2ecb3e252bdac" 48 "6cf71fb567ae36025a9d4ea86b?size=158&rating=g&default=404" 49 ), 50 text="foo", 51 headers={"Content-Type": "image/png"}, 52 ) 53 response = self.client.get(reverse("authentik_api:user-me")) 54 self.assertEqual(response.status_code, 200) 55 body = loads(response.content.decode()) 56 self.assertIn("gravatar", body["user"]["avatar"])
Test avatars gravatar
def
test_avatars_initials(self):
58 def test_avatars_initials(self): 59 """Test avatars initials""" 60 self.set_avatar_mode("initials") 61 self.client.force_login(self.admin) 62 response = self.client.get(reverse("authentik_api:user-me")) 63 self.assertEqual(response.status_code, 200) 64 body = loads(response.content.decode()) 65 self.assertIn("data:image/svg+xml;base64,", body["user"]["avatar"])
Test avatars initials
def
test_avatars_custom(self):
67 def test_avatars_custom(self): 68 """Test avatars custom""" 69 self.set_avatar_mode("foo://%(username)s") 70 self.client.force_login(self.admin) 71 response = self.client.get(reverse("authentik_api:user-me")) 72 self.assertEqual(response.status_code, 200) 73 body = loads(response.content.decode()) 74 self.assertEqual(body["user"]["avatar"], f"foo://{self.admin.username}")
Test avatars custom
def
test_avatars_attributes(self):
76 def test_avatars_attributes(self): 77 """Test avatars attributes""" 78 self.set_avatar_mode("attributes.foo.avatar") 79 self.admin.attributes = {"foo": {"avatar": "bar"}} 80 self.admin.save() 81 self.client.force_login(self.admin) 82 response = self.client.get(reverse("authentik_api:user-me")) 83 self.assertEqual(response.status_code, 200) 84 body = loads(response.content.decode()) 85 self.assertEqual(body["user"]["avatar"], "bar")
Test avatars attributes
def
test_avatars_fallback(self):
87 def test_avatars_fallback(self): 88 """Test fallback""" 89 self.set_avatar_mode("attributes.foo.avatar,initials") 90 self.client.force_login(self.admin) 91 response = self.client.get(reverse("authentik_api:user-me")) 92 self.assertEqual(response.status_code, 200) 93 body = loads(response.content.decode()) 94 self.assertIn("data:image/svg+xml;base64,", body["user"]["avatar"])
Test fallback
def
test_avatars_custom_content_type_valid(self):
96 def test_avatars_custom_content_type_valid(self): 97 """Test custom avatar URL with valid image Content-Type""" 98 cache.clear() 99 self.set_avatar_mode("https://example.com/avatar/%(username)s") 100 self.client.force_login(self.admin) 101 with Mocker() as mocker: 102 mocker.head( 103 f"https://example.com/avatar/{self.admin.username}", 104 headers={"Content-Type": "image/png"}, 105 ) 106 response = self.client.get(reverse("authentik_api:user-me")) 107 self.assertEqual(response.status_code, 200) 108 body = loads(response.content.decode()) 109 self.assertEqual( 110 body["user"]["avatar"], f"https://example.com/avatar/{self.admin.username}" 111 )
Test custom avatar URL with valid image Content-Type
def
test_avatars_custom_content_type_invalid(self):
113 def test_avatars_custom_content_type_invalid(self): 114 """Test custom avatar URL with invalid Content-Type falls back""" 115 cache.clear() 116 self.set_avatar_mode("https://example.com/avatar/%(username)s,initials") 117 self.client.force_login(self.admin) 118 with Mocker() as mocker: 119 mocker.head( 120 f"https://example.com/avatar/{self.admin.username}", 121 headers={"Content-Type": "text/html"}, 122 ) 123 response = self.client.get(reverse("authentik_api:user-me")) 124 self.assertEqual(response.status_code, 200) 125 body = loads(response.content.decode()) 126 # Should fallback to initials since Content-Type is not image/* 127 self.assertIn("data:image/svg+xml;base64,", body["user"]["avatar"])
Test custom avatar URL with invalid Content-Type falls back
def
test_avatars_custom_content_type_missing(self):
129 def test_avatars_custom_content_type_missing(self): 130 """Test custom avatar URL with missing Content-Type header falls back""" 131 cache.clear() 132 self.set_avatar_mode("https://example.com/avatar/%(username)s,initials") 133 self.client.force_login(self.admin) 134 with Mocker() as mocker: 135 mocker.head( 136 f"https://example.com/avatar/{self.admin.username}", 137 headers={}, 138 ) 139 response = self.client.get(reverse("authentik_api:user-me")) 140 self.assertEqual(response.status_code, 200) 141 body = loads(response.content.decode()) 142 # Should fallback to initials since Content-Type header is missing 143 self.assertIn("data:image/svg+xml;base64,", body["user"]["avatar"])
Test custom avatar URL with missing Content-Type header falls back
def
test_avatars_custom_404_cached(self):
145 def test_avatars_custom_404_cached(self): 146 """Test that 404 responses are cached with TTL""" 147 cache.clear() 148 self.set_avatar_mode("https://example.com/avatar/%(username)s") 149 self.client.force_login(self.admin) 150 with Mocker() as mocker: 151 mocker.head( 152 f"https://example.com/avatar/{self.admin.username}", 153 status_code=404, 154 ) 155 response = self.client.get(reverse("authentik_api:user-me")) 156 self.assertEqual(response.status_code, 200) 157 body = loads(response.content.decode()) 158 # Should fallback to default avatar 159 self.assertEqual(body["user"]["avatar"], "/static/dist/assets/images/user_default.png") 160 161 # Verify cache was set with the expected structure 162 from hashlib import md5 163 164 mail_hash = md5(self.admin.email.lower().encode("utf-8"), usedforsecurity=False).hexdigest() 165 cache_key = f"goauthentik.io/lib/avatars/example.com/{mail_hash}" 166 self.assertIsNone(cache.get(cache_key)) 167 # Verify TTL was set (cache entry exists) 168 self.assertTrue(cache.has_key(cache_key))
Test that 404 responses are cached with TTL
def
test_avatars_custom_redirect(self):
170 def test_avatars_custom_redirect(self): 171 """Test custom avatar URL follows redirects""" 172 cache.clear() 173 self.set_avatar_mode("https://example.com/avatar/%(username)s") 174 self.client.force_login(self.admin) 175 with Mocker() as mocker: 176 # Mock a redirect 177 mocker.head( 178 f"https://example.com/avatar/{self.admin.username}", 179 status_code=302, 180 headers={"Location": "https://cdn.example.com/final-avatar.png"}, 181 ) 182 mocker.head( 183 "https://cdn.example.com/final-avatar.png", 184 headers={"Content-Type": "image/png"}, 185 ) 186 response = self.client.get(reverse("authentik_api:user-me")) 187 self.assertEqual(response.status_code, 200) 188 body = loads(response.content.decode()) 189 # Should return the original URL (not the redirect destination) 190 self.assertEqual( 191 body["user"]["avatar"], f"https://example.com/avatar/{self.admin.username}" 192 )
Test custom avatar URL follows redirects
def
test_avatars_hostname_availability_cache(self):
194 def test_avatars_hostname_availability_cache(self): 195 """Test that hostname availability is cached when domain fails""" 196 from requests.exceptions import Timeout 197 198 cache.clear() 199 self.set_avatar_mode("https://failing.example.com/avatar/%(username)s,initials") 200 self.client.force_login(self.admin) 201 202 with Mocker() as mocker: 203 # First request times out 204 mocker.head( 205 f"https://failing.example.com/avatar/{self.admin.username}", 206 exc=Timeout("Connection timeout"), 207 ) 208 response = self.client.get(reverse("authentik_api:user-me")) 209 210 self.assertEqual(response.status_code, 200) 211 body = loads(response.content.decode()) 212 # Should fallback to initials 213 self.assertIn("data:image/svg+xml;base64,", body["user"]["avatar"]) 214 215 # Verify hostname is marked as unavailable 216 cache_key_hostname = "goauthentik.io/lib/avatars/failing.example.com/available" 217 self.assertFalse(cache.get(cache_key_hostname, True)) 218 219 # Second request should not even try to fetch (hostname cached as unavailable) 220 with Mocker() as mocker: 221 # This should NOT be called due to hostname cache 222 mocker.head( 223 f"https://failing.example.com/avatar/{self.admin.username}", 224 headers={"Content-Type": "image/png"}, 225 ) 226 response = self.client.get(reverse("authentik_api:user-me")) 227 228 self.assertEqual(response.status_code, 200) 229 body = loads(response.content.decode()) 230 # Should still fallback to initials without making a request 231 self.assertIn("data:image/svg+xml;base64,", body["user"]["avatar"]) 232 # Verify no request was made (request_history should be empty) 233 self.assertEqual(len(mocker.request_history), 0)
Test that hostname availability is cached when domain fails
def
test_avatars_gravatar_uses_url_validation(self):
235 def test_avatars_gravatar_uses_url_validation(self): 236 """Test that Gravatar now uses avatar_mode_url validation (regression test)""" 237 cache.clear() 238 self.set_avatar_mode("gravatar") 239 self.admin.email = "test@example.com" 240 self.admin.save() 241 self.client.force_login(self.admin) 242 243 with Mocker() as mocker: 244 # Mock Gravatar to return non-image content 245 from hashlib import sha256 246 247 mail_hash = sha256(self.admin.email.lower().encode("utf-8")).hexdigest() 248 gravatar_url = ( 249 f"https://www.gravatar.com/avatar/{mail_hash}?size=158&rating=g&default=404" 250 ) 251 252 mocker.head( 253 gravatar_url, 254 headers={"Content-Type": "text/html"}, 255 ) 256 response = self.client.get(reverse("authentik_api:user-me")) 257 258 self.assertEqual(response.status_code, 200) 259 body = loads(response.content.decode()) 260 # Should fallback to default avatar since Content-Type is not image/* 261 self.assertEqual(body["user"]["avatar"], "/static/dist/assets/images/user_default.png")
Test that Gravatar now uses avatar_mode_url validation (regression test)