response_handler_test.go 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294
  1. // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
  2. // SPDX-License-Identifier: Apache-2.0
  3. package fetcher // import "miniflux.app/v2/internal/reader/fetcher"
  4. import (
  5. "errors"
  6. "io"
  7. "net/http"
  8. "testing"
  9. "time"
  10. )
  11. type testReadCloser struct {
  12. closed bool
  13. }
  14. func (rc *testReadCloser) Read(_ []byte) (int, error) {
  15. return 0, io.EOF
  16. }
  17. func (rc *testReadCloser) Close() error {
  18. rc.closed = true
  19. return nil
  20. }
  21. func TestIsModified(t *testing.T) {
  22. var cachedEtag = "abc123"
  23. var cachedLastModified = "Wed, 21 Oct 2015 07:28:00 GMT"
  24. var testCases = map[string]struct {
  25. Status int
  26. LastModified string
  27. ETag string
  28. IsModified bool
  29. }{
  30. "Unmodified 304": {
  31. Status: 304,
  32. LastModified: cachedLastModified,
  33. ETag: cachedEtag,
  34. IsModified: false,
  35. },
  36. "Unmodified 200": {
  37. Status: 200,
  38. LastModified: cachedLastModified,
  39. ETag: cachedEtag,
  40. IsModified: false,
  41. },
  42. // ETag takes precedence per RFC9110 8.8.1.
  43. "Last-Modified changed only": {
  44. Status: 200,
  45. LastModified: "Thu, 22 Oct 2015 07:28:00 GMT",
  46. ETag: cachedEtag,
  47. IsModified: false,
  48. },
  49. "ETag changed only": {
  50. Status: 200,
  51. LastModified: cachedLastModified,
  52. ETag: "xyz789",
  53. IsModified: true,
  54. },
  55. "ETag and Last-Modified changed": {
  56. Status: 200,
  57. LastModified: "Thu, 22 Oct 2015 07:28:00 GMT",
  58. ETag: "xyz789",
  59. IsModified: true,
  60. },
  61. }
  62. for name, tc := range testCases {
  63. t.Run(name, func(tt *testing.T) {
  64. header := http.Header{}
  65. header.Add("Last-Modified", tc.LastModified)
  66. header.Add("ETag", tc.ETag)
  67. rh := ResponseHandler{
  68. httpResponse: &http.Response{
  69. StatusCode: tc.Status,
  70. Header: header,
  71. },
  72. }
  73. if tc.IsModified != rh.IsModified(cachedEtag, cachedLastModified) {
  74. tt.Error(name)
  75. }
  76. })
  77. }
  78. }
  79. func TestRetryDelay(t *testing.T) {
  80. var testCases = map[string]struct {
  81. RetryAfterHeader string
  82. ExpectedDelay time.Duration
  83. }{
  84. "Empty header": {
  85. RetryAfterHeader: "",
  86. ExpectedDelay: 0,
  87. },
  88. "Integer value": {
  89. RetryAfterHeader: "42",
  90. ExpectedDelay: 42 * time.Second,
  91. },
  92. "HTTP-date": {
  93. RetryAfterHeader: time.Now().Add(42 * time.Second).Format(time.RFC1123),
  94. ExpectedDelay: 41 * time.Second,
  95. },
  96. }
  97. for name, tc := range testCases {
  98. t.Run(name, func(tt *testing.T) {
  99. header := http.Header{}
  100. header.Add("Retry-After", tc.RetryAfterHeader)
  101. rh := ResponseHandler{
  102. httpResponse: &http.Response{
  103. Header: header,
  104. },
  105. }
  106. if tc.ExpectedDelay != rh.ParseRetryDelay() {
  107. tt.Errorf("Expected %d, got %d for scenario %q", tc.ExpectedDelay, rh.ParseRetryDelay(), name)
  108. }
  109. })
  110. }
  111. }
  112. func TestExpiresInMinutes(t *testing.T) {
  113. var testCases = map[string]struct {
  114. ExpiresHeader string
  115. Expected time.Duration
  116. }{
  117. "Empty header": {
  118. ExpiresHeader: "",
  119. Expected: 0,
  120. },
  121. "Valid Expires header": {
  122. ExpiresHeader: time.Now().Add(10 * time.Minute).Format(time.RFC1123),
  123. Expected: 10 * time.Minute,
  124. },
  125. "Invalid Expires header": {
  126. ExpiresHeader: "invalid-date",
  127. Expected: 0,
  128. },
  129. }
  130. for name, tc := range testCases {
  131. t.Run(name, func(tt *testing.T) {
  132. header := http.Header{}
  133. header.Add("Expires", tc.ExpiresHeader)
  134. rh := ResponseHandler{
  135. httpResponse: &http.Response{
  136. Header: header,
  137. },
  138. }
  139. if tc.Expected != rh.Expires() {
  140. t.Errorf("Expected %d, got %d for scenario %q", tc.Expected, rh.Expires(), name)
  141. }
  142. })
  143. }
  144. }
  145. func TestCacheControlMaxAgeInMinutes(t *testing.T) {
  146. var testCases = map[string]struct {
  147. CacheControlHeader string
  148. Expected time.Duration
  149. }{
  150. "Empty header": {
  151. CacheControlHeader: "",
  152. Expected: 0,
  153. },
  154. "Valid max-age": {
  155. CacheControlHeader: "max-age=600",
  156. Expected: 10 * time.Minute,
  157. },
  158. "Invalid max-age": {
  159. CacheControlHeader: "max-age=invalid",
  160. Expected: 0,
  161. },
  162. "Multiple directives": {
  163. CacheControlHeader: "no-cache, max-age=300",
  164. Expected: 5 * time.Minute,
  165. },
  166. }
  167. for name, tc := range testCases {
  168. t.Run(name, func(tt *testing.T) {
  169. header := http.Header{}
  170. header.Add("Cache-Control", tc.CacheControlHeader)
  171. rh := ResponseHandler{
  172. httpResponse: &http.Response{
  173. Header: header,
  174. },
  175. }
  176. if tc.Expected != rh.CacheControlMaxAge() {
  177. t.Errorf("Expected %d, got %d for scenario %q", tc.Expected, rh.CacheControlMaxAge(), name)
  178. }
  179. })
  180. }
  181. }
  182. func TestIsCloudflareChallenge(t *testing.T) {
  183. makeResp := func(status int, headers map[string]string) *http.Response {
  184. h := http.Header{}
  185. for k, v := range headers {
  186. h.Set(k, v)
  187. }
  188. return &http.Response{StatusCode: status, Header: h}
  189. }
  190. cases := map[string]struct {
  191. response *http.Response
  192. expected bool
  193. }{
  194. "403 with cf-mitigated challenge and html": {
  195. response: makeResp(http.StatusForbidden, map[string]string{
  196. "Cf-Mitigated": "challenge",
  197. "Content-Type": "text/html; charset=UTF-8",
  198. }),
  199. expected: true,
  200. },
  201. "cf-mitigated challenge header on 200": {
  202. response: makeResp(http.StatusOK, map[string]string{
  203. "Cf-Mitigated": "challenge",
  204. "Content-Type": "text/html",
  205. }),
  206. expected: false,
  207. },
  208. "403 cf-mitigated challenge without html": {
  209. response: makeResp(http.StatusForbidden, map[string]string{
  210. "Cf-Mitigated": "challenge",
  211. "Content-Type": "application/json",
  212. }),
  213. expected: false,
  214. },
  215. "403 from cloudflare with html but no challenge signal": {
  216. response: makeResp(http.StatusForbidden, map[string]string{
  217. "Server": "cloudflare",
  218. "Cf-Ray": "8abc123def456-IAD",
  219. "Content-Type": "text/html; charset=UTF-8",
  220. }),
  221. expected: false,
  222. },
  223. "503 from cloudflare with html but no challenge signal": {
  224. response: makeResp(http.StatusServiceUnavailable, map[string]string{
  225. "Server": "cloudflare",
  226. "Cf-Ray": "8abc123def456-IAD",
  227. "Content-Type": "text/html",
  228. }),
  229. expected: false,
  230. },
  231. "403 from non-cloudflare server": {
  232. response: makeResp(http.StatusForbidden, map[string]string{
  233. "Server": "nginx",
  234. "Content-Type": "text/html",
  235. }),
  236. expected: false,
  237. },
  238. "500 from cloudflare with html": {
  239. response: makeResp(http.StatusInternalServerError, map[string]string{
  240. "Server": "cloudflare",
  241. "Cf-Ray": "8abc123def456-IAD",
  242. "Content-Type": "text/html",
  243. }),
  244. expected: false,
  245. },
  246. "200 OK from cloudflare": {
  247. response: makeResp(http.StatusOK, map[string]string{
  248. "Server": "cloudflare",
  249. "Cf-Ray": "8abc123def456-IAD",
  250. "Content-Type": "application/rss+xml",
  251. }),
  252. expected: false,
  253. },
  254. "nil response": {
  255. response: nil,
  256. expected: false,
  257. },
  258. }
  259. for name, tc := range cases {
  260. t.Run(name, func(t *testing.T) {
  261. rh := &ResponseHandler{httpResponse: tc.response}
  262. if got := rh.isCloudflareChallenge(); got != tc.expected {
  263. t.Errorf("isCloudflareChallenge() = %v, want %v", got, tc.expected)
  264. }
  265. })
  266. }
  267. }
  268. func TestResponseHandlerCloseClosesBodyOnClientError(t *testing.T) {
  269. body := &testReadCloser{}
  270. rh := ResponseHandler{
  271. httpResponse: &http.Response{Body: body},
  272. clientErr: errors.New("boom"),
  273. }
  274. rh.Close()
  275. if !body.closed {
  276. t.Error("Expected response body to be closed")
  277. }
  278. }