Coverage for ivatar/test_conditional_requests.py: 100%

65 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-20 05:10 +0000

1""" 

2Test suite for Fast ETag and Conditional Request (304 Not Modified) handling. 

3""" 

4 

5from io import BytesIO 

6import hashlib 

7 

8from PIL import Image 

9from django.contrib.auth.models import User 

10from django.test import TestCase, override_settings 

11from django.utils.http import http_date 

12 

13from ivatar.ivataraccount.models import ConfirmedEmail, Photo 

14 

15 

16@override_settings( 

17 CACHES={"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}}, 

18 CACHE_RESPONSE=False, 

19) 

20class ConditionalRequestTest(TestCase): 

21 """ 

22 Test 304 Not Modified fast-paths for If-None-Match and If-Modified-Since. 

23 """ 

24 

25 def setUp(self): 

26 self.user = User.objects.create(username="etagtestuser") 

27 

28 # Create a test photo (100x100 PNG) 

29 img = Image.new("RGBA", (100, 100), (200, 50, 50, 255)) 

30 buf = BytesIO() 

31 img.save(buf, format="PNG") 

32 self.photo = Photo.objects.create( 

33 user=self.user, 

34 data=buf.getvalue(), 

35 format="png", 

36 ip_address="127.0.0.1", 

37 ) 

38 

39 self.email = "etagtest@example.com" 

40 self.digest = hashlib.md5(self.email.encode("utf-8")).hexdigest() 

41 self.confirmed_email = ConfirmedEmail.objects.create( 

42 user=self.user, 

43 email=self.email, 

44 digest=self.digest, 

45 photo=self.photo, 

46 ) 

47 

48 def test_initial_request_returns_200_with_etag_and_last_modified(self): 

49 """ 

50 Standard request without conditional headers returns 200 with ETag and Last-Modified. 

51 """ 

52 response = self.client.get(f"/avatar/{self.digest}?s=80") 

53 self.assertEqual(response.status_code, 200) 

54 self.assertEqual(response["Content-Type"], "image/png") 

55 self.assertIn("Etag", response) 

56 self.assertEqual(response["Etag"], f'"{self.digest}"') 

57 self.assertIn("Last-Modified", response) 

58 self.assertTrue(len(response.content) > 0) 

59 

60 def test_if_none_match_exact_returns_304(self): 

61 """ 

62 Matching If-None-Match returns 304 Not Modified with empty body. 

63 """ 

64 response = self.client.get( 

65 f"/avatar/{self.digest}?s=80", 

66 HTTP_IF_NONE_MATCH=f'"{self.digest}"', 

67 ) 

68 self.assertEqual(response.status_code, 304) 

69 self.assertEqual(response.content, b"") 

70 self.assertEqual(response["Etag"], f'"{self.digest}"') 

71 

72 def test_if_none_match_weak_returns_304(self): 

73 """ 

74 Weak ETag in If-None-Match (W/"...") returns 304 per RFC 7232. 

75 """ 

76 response = self.client.get( 

77 f"/avatar/{self.digest}?s=80", 

78 HTTP_IF_NONE_MATCH=f'W/"{self.digest}"', 

79 ) 

80 self.assertEqual(response.status_code, 304) 

81 self.assertEqual(response.content, b"") 

82 

83 def test_if_none_match_wildcard_returns_304(self): 

84 """ 

85 Wildcard If-None-Match: * returns 304 when resource exists. 

86 """ 

87 response = self.client.get( 

88 f"/avatar/{self.digest}?s=80", 

89 HTTP_IF_NONE_MATCH="*", 

90 ) 

91 self.assertEqual(response.status_code, 304) 

92 self.assertEqual(response.content, b"") 

93 

94 def test_if_none_match_mismatch_returns_200(self): 

95 """ 

96 Mismatched If-None-Match returns full 200 OK image response. 

97 """ 

98 response = self.client.get( 

99 f"/avatar/{self.digest}?s=80", 

100 HTTP_IF_NONE_MATCH='"different_etag_value"', 

101 ) 

102 self.assertEqual(response.status_code, 200) 

103 self.assertTrue(len(response.content) > 0) 

104 

105 def test_if_modified_since_not_modified_returns_304(self): 

106 """ 

107 If-Modified-Since with date matching or after photo add_date returns 304. 

108 """ 

109 future_date = http_date(self.photo.add_date.timestamp() + 3600) 

110 response = self.client.get( 

111 f"/avatar/{self.digest}?s=80", 

112 HTTP_IF_MODIFIED_SINCE=future_date, 

113 ) 

114 self.assertEqual(response.status_code, 304) 

115 self.assertEqual(response.content, b"") 

116 

117 def test_if_modified_since_modified_returns_200(self): 

118 """ 

119 If-Modified-Since with date prior to photo add_date returns 200. 

120 """ 

121 past_date = http_date(self.photo.add_date.timestamp() - 3600) 

122 response = self.client.get( 

123 f"/avatar/{self.digest}?s=80", 

124 HTTP_IF_MODIFIED_SINCE=past_date, 

125 ) 

126 self.assertEqual(response.status_code, 200) 

127 self.assertTrue(len(response.content) > 0) 

128 

129 def test_conditional_request_on_default_avatar_generator(self): 

130 """ 

131 Generated default avatars (retro/monsterid/etc.) support 304 on If-None-Match. 

132 """ 

133 unknown_digest = "0123456789abcdef0123456789abcdef" 

134 

135 # First request generates and returns 200 

136 response1 = self.client.get( 

137 f"/avatar/{unknown_digest}?d=retro&forcedefault=y&s=64" 

138 ) 

139 self.assertEqual(response1.status_code, 200) 

140 self.assertIn("Etag", response1) 

141 

142 # Conditional request returns 304 

143 response2 = self.client.get( 

144 f"/avatar/{unknown_digest}?d=retro&forcedefault=y&s=64", 

145 HTTP_IF_NONE_MATCH=f'"{unknown_digest}"', 

146 ) 

147 self.assertEqual(response2.status_code, 304) 

148 self.assertEqual(response2.content, b"") 

149 

150 def test_conditional_request_does_not_convert_redirect_to_304(self): 

151 """ 

152 Redirects (e.g. to Gravatar proxy) must not be converted to 304. 

153 """ 

154 missing_digest = "fedcba9876543210fedcba9876543210" 

155 response = self.client.get( 

156 f"/avatar/{missing_digest}?gravatarproxy=y", 

157 HTTP_IF_NONE_MATCH=f'"{missing_digest}"', 

158 ) 

159 self.assertEqual(response.status_code, 302)