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)