Coverage for ivatar/test_views.py: 100%
143 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-20 05:10 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-20 05:10 +0000
1"""
2Test our views in ivatar.ivataraccount.views and ivatar.views
3"""
5# pylint: disable=too-many-lines
6import os
7import django
8from django.urls import reverse
9from django.test import TestCase, Client, override_settings
10from django.contrib.auth.models import User
11from ivatar.utils import random_string, Bluesky
13from django.conf import settings
15BLUESKY_APP_PASSWORD = getattr(settings, "BLUESKY_APP_PASSWORD", None)
16BLUESKY_IDENTIFIER = getattr(settings, "BLUESKY_IDENTIFIER", None)
17os.environ["DJANGO_SETTINGS_MODULE"] = "ivatar.settings"
18django.setup()
21class Tester(TestCase): # pylint: disable=too-many-public-methods
22 """
23 Main test class
24 """
26 client = Client()
27 user = None
28 username = random_string()
29 password = random_string()
30 email = "{}@{}.{}".format(username, random_string(), random_string(2))
31 # Dunno why random tld doesn't work, but I'm too lazy now to investigate
32 openid = "http://{}.{}.{}/".format(username, random_string(), "org")
34 def login(self):
35 """
36 Login as user
37 """
38 self.client.login(username=self.username, password=self.password)
40 def setUp(self):
41 """
42 Prepare for tests.
43 - Create user
44 """
45 self.user = User.objects.create_user(
46 username=self.username,
47 password=self.password,
48 )
50 def test_incorrect_digest(self):
51 """
52 Test incorrect digest
53 """
54 response = self.client.get("/avatar/" + "x" * 65, follow=True)
55 self.assertEqual(
56 response.redirect_chain[2][0],
57 "/static/img/nobody/80.png",
58 "Doesn't redirect to static?",
59 )
60 # self.assertRedirects(
61 # response=response,
62 # expected_url="/static/img/nobody/80.png",
63 # msg_prefix="Why does an invalid hash not redirect to deadbeef?",
64 # )
66 def test_logout(self):
67 """
68 Test if logout works correctly
69 """
70 self.login()
71 response = self.client.get(reverse("logout"), follow=True)
72 self.assertEqual(
73 response.status_code, 405, "logout with get should lead to http error 405"
74 )
75 response = self.client.post(reverse("logout"), follow=True)
76 self.assertEqual(response.status_code, 200, "logout with post should logout")
78 def test_Bluesky_client(self):
79 """
80 Bluesky client needs credentials, so it's limited with testing here now
81 """
83 if BLUESKY_APP_PASSWORD and BLUESKY_IDENTIFIER:
84 b = Bluesky()
85 profile = b.get_profile("libravatar.org")
86 self.assertEqual(profile["handle"], "libravatar.org")
87 # As long as I don't change my avatar, this should stay the same
88 self.assertEqual(
89 profile["avatar"],
90 "https://cdn.bsky.app/img/avatar/plain/did:plc:pnzkvz5q5xg72bzkxrucalje/bafkreic456md3b46sm5sh3rsrgis26njy2d4p6u5yqxpz5bpqa6yk6ceni",
91 )
92 self.assertEqual(True, True)
94 @override_settings(
95 CACHES={"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}}
96 )
97 def test_default_avatar_caching(self):
98 """
99 Test in-memory and shared Redis caching for default avatars
100 """
101 from ivatar.views import (
102 _get_cached_default_avatar,
103 _set_cached_default_avatar,
104 _default_avatars_png_cache,
105 _default_avatars_png_cache_lock,
106 )
107 from django.core.cache import cache
109 test_digest = "abcdef1234567890abcdef1234567890"
110 test_png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDRtestdata"
112 # Clear any existing cache for this key
113 with _default_avatars_png_cache_lock:
114 _default_avatars_png_cache.pop(("retro", test_digest, 28), None)
115 cache.delete(f"default_avatar:retro:{test_digest}:28")
117 # Initially not cached
118 self.assertIsNone(_get_cached_default_avatar("retro", test_digest, 28))
120 # Store in cache
121 _set_cached_default_avatar("retro", test_digest, 28, test_png)
123 # Retrieve from in-process memory cache
124 result = _get_cached_default_avatar("retro", test_digest, 28)
125 self.assertIsNotNone(result)
126 self.assertEqual(result.getvalue(), test_png)
128 # Evict in-memory process cache to test shared Django/Redis cache fallback
129 with _default_avatars_png_cache_lock:
130 _default_avatars_png_cache.pop(("retro", test_digest, 28), None)
132 result_fallback = _get_cached_default_avatar("retro", test_digest, 28)
133 self.assertIsNotNone(result_fallback)
134 self.assertEqual(result_fallback.getvalue(), test_png)
136 def test_retro_avatar_request(self):
137 """
138 Test requesting an uncached retro avatar
139 """
140 digest = "63a75a80e6b1f4adfdb04c1ca02e596c"
141 response = self.client.get(f"/avatar/{digest}?d=retro&s=28&forcedefault=y")
142 self.assertEqual(response.status_code, 200)
143 self.assertEqual(response["Content-Type"], "image/png")
144 self.assertGreater(len(response.content), 0)
146 def test_default_avatar_caching_graceful_degradation(self):
147 """
148 Test that default avatar caching gracefully degrades if Redis or Django cache fails
149 """
150 from unittest.mock import patch
151 from ivatar.views import (
152 _get_cached_default_avatar,
153 _set_cached_default_avatar,
154 _default_avatars_png_cache,
155 _default_avatars_png_cache_lock,
156 )
158 test_digest = "deadbeef1234567890deadbeef123456"
159 test_png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDRgraceful"
161 with patch("django.core.cache.cache.get", side_effect=ConnectionError("Redis connection refused")), \
162 patch("django.core.cache.cache.set", side_effect=ConnectionError("Redis connection refused")):
163 with _default_avatars_png_cache_lock:
164 _default_avatars_png_cache.pop(("retro", test_digest, 28), None)
166 # Get with Redis failing should return None instead of raising an exception
167 self.assertIsNone(_get_cached_default_avatar("retro", test_digest, 28))
169 # Set with Redis failing should still store in local in-memory cache without raising
170 _set_cached_default_avatar("retro", test_digest, 28, test_png)
172 # Retrieval from local memory cache works despite Redis failure
173 cached = _get_cached_default_avatar("retro", test_digest, 28)
174 self.assertIsNotNone(cached)
175 self.assertEqual(cached.getvalue(), test_png)
177 def test_gravatar_fallback_with_encoded_spaces_in_default_url(self):
178 """
179 Test that default URLs containing spaces (e.g. ui-avatars.com name)
180 do not trigger InvalidURL or skip Gravatar fallback (Issue #111).
181 """
182 from unittest.mock import patch
183 from io import BytesIO
184 from PIL import Image
186 digest = "7487baad7b087cb03cc4ccf7d044c150"
187 img = Image.new("RGBA", (10, 10), (255, 0, 0, 255))
188 buf = BytesIO()
189 img.save(buf, format="PNG")
190 valid_png = buf.getvalue()
192 class MockResponse:
193 def __init__(self, content):
194 self.content = content
195 def read(self):
196 return self.content
198 # Test request with URL-encoded space in default
199 url = f"/avatar/{digest}?d=https%3A%2F%2Fui-avatars.com%2Fapi%2FSteve%2520Lacey"
200 with patch("ivatar.views.urlopen", side_effect=lambda u, **kw: MockResponse(valid_png)) as mock_urlopen:
201 response = self.client.get(url, follow=True)
202 self.assertEqual(response.status_code, 200)
203 self.assertEqual(response["Content-Type"], "image/png")
204 # Verify urlopen was called with properly quoted URL without raw spaces
205 gravatar_call_url = mock_urlopen.call_args_list[-1][0][0]
206 self.assertNotIn(" ", gravatar_call_url)
207 self.assertIn("Steve%20Lacey", gravatar_call_url)
209 def test_trusted_default_urls_ui_avatars_regional_subdomains(self):
210 """
211 Test that regional subdomains of ui-avatars.com are accepted as trusted (Issue #112).
212 """
213 from ivatar.utils import is_trusted_url
214 from django.conf import settings
216 self.assertTrue(is_trusted_url("https://ui-avatars.com/api/Test", settings.TRUSTED_DEFAULT_URLS))
217 self.assertTrue(is_trusted_url("https://eu.ui-avatars.com/api/Test", settings.TRUSTED_DEFAULT_URLS))
218 self.assertTrue(is_trusted_url("https://na.ui-avatars.com/api/Test", settings.TRUSTED_DEFAULT_URLS))
219 self.assertTrue(is_trusted_url("https://ap.ui-avatars.com/api/Test", settings.TRUSTED_DEFAULT_URLS))
220 self.assertFalse(is_trusted_url("https://fake-ui-avatars.com/api/Test", settings.TRUSTED_DEFAULT_URLS))
222 def test_bluesky_proxy_caches_and_supports_etag(self):
223 """
224 Verify BlueskyProxyView caches the proxied payload, advertises an
225 ETag/Cache-Control, skips re-downloads on later hits, and answers
226 conditional requests with 304.
227 """
228 from unittest.mock import patch
229 from io import BytesIO
230 from PIL import Image
231 from django.core.cache import cache
232 from ivatar.ivataraccount.models import ConfirmedEmail
234 email = ConfirmedEmail.objects.create(
235 user=self.user,
236 email="bsky-proxy@example.com",
237 ip_address="127.0.0.1",
238 )
239 email.bluesky_handle = "test.bsky.social"
240 email.save()
241 email.refresh_from_db()
243 img = Image.new("RGB", (200, 200), (10, 20, 30))
244 buf = BytesIO()
245 img.save(buf, format="PNG")
246 png = buf.getvalue()
248 class MockResponse:
249 def read(self):
250 return png
252 class MockBluesky:
253 def get_avatar(self, handle):
254 return "https://cdn.bsky.test/avatar.png"
256 cache.clear()
257 url = f"/blueskyproxy/{email.digest}?s=80"
258 with patch("ivatar.views.Bluesky", MockBluesky), patch(
259 "ivatar.views.urlopen", return_value=MockResponse()
260 ) as mock_urlopen:
261 response = self.client.get(url)
262 self.assertEqual(response.status_code, 200)
263 self.assertEqual(response["Content-Type"], "image/png")
264 self.assertIn("Etag", response)
265 self.assertIn("Cache-Control", response)
266 self.assertEqual(mock_urlopen.call_count, 1)
267 etag = response["Etag"]
269 # Second plain request is served from the payload cache
270 response2 = self.client.get(url)
271 self.assertEqual(response2.status_code, 200)
272 self.assertEqual(mock_urlopen.call_count, 1)
274 # Conditional request short-circuits to 304 without downloading
275 response3 = self.client.get(url, HTTP_IF_NONE_MATCH=etag)
276 self.assertEqual(response3.status_code, 304)
277 self.assertEqual(response3["Etag"], etag)
278 self.assertEqual(mock_urlopen.call_count, 1)