handler_test.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495
  1. package stream
  2. import (
  3. "fmt"
  4. "io"
  5. "net/http"
  6. "net/http/httptest"
  7. "os"
  8. "path/filepath"
  9. "strconv"
  10. "testing"
  11. "time"
  12. "github.com/sirupsen/logrus"
  13. "github.com/stretchr/testify/suite"
  14. "github.com/imgproxy/imgproxy/v3/config"
  15. "github.com/imgproxy/imgproxy/v3/fetcher"
  16. "github.com/imgproxy/imgproxy/v3/httpheaders"
  17. "github.com/imgproxy/imgproxy/v3/options"
  18. "github.com/imgproxy/imgproxy/v3/server/responsewriter"
  19. "github.com/imgproxy/imgproxy/v3/testutil"
  20. )
  21. const (
  22. testDataPath = "../../testdata"
  23. )
  24. type HandlerTestSuite struct {
  25. testutil.LazySuite
  26. rwConf testutil.LazyObj[*responsewriter.Config]
  27. rwFactory testutil.LazyObj[*responsewriter.Factory]
  28. config testutil.LazyObj[*Config]
  29. handler testutil.LazyObj[*Handler]
  30. }
  31. func (s *HandlerTestSuite) SetupSuite() {
  32. config.Reset()
  33. config.AllowLoopbackSourceAddresses = true
  34. s.rwConf, _ = testutil.NewLazySuiteObj(
  35. s,
  36. func() (*responsewriter.Config, error) {
  37. c := responsewriter.NewDefaultConfig()
  38. return &c, nil
  39. },
  40. )
  41. s.rwFactory, _ = testutil.NewLazySuiteObj(
  42. s,
  43. func() (*responsewriter.Factory, error) {
  44. return responsewriter.NewFactory(s.rwConf())
  45. },
  46. )
  47. s.config, _ = testutil.NewLazySuiteObj(
  48. s,
  49. func() (*Config, error) {
  50. c := NewDefaultConfig()
  51. return &c, nil
  52. },
  53. )
  54. s.handler, _ = testutil.NewLazySuiteObj(
  55. s,
  56. func() (*Handler, error) {
  57. fc := fetcher.NewDefaultConfig()
  58. fetcher, err := fetcher.New(&fc)
  59. s.Require().NoError(err)
  60. return New(s.config(), fetcher)
  61. },
  62. )
  63. // Silence logs during tests
  64. logrus.SetOutput(io.Discard)
  65. }
  66. func (s *HandlerTestSuite) TearDownSuite() {
  67. config.Reset()
  68. logrus.SetOutput(os.Stdout)
  69. }
  70. func (s *HandlerTestSuite) SetupTest() {
  71. config.Reset()
  72. config.AllowLoopbackSourceAddresses = true
  73. }
  74. func (s *HandlerTestSuite) SetupSubTest() {
  75. // We use t.Run() a lot, so we need to reset lazy objects at the beginning of each subtest
  76. s.ResetLazyObjects()
  77. }
  78. func (s *HandlerTestSuite) readTestFile(name string) []byte {
  79. data, err := os.ReadFile(filepath.Join(testDataPath, name))
  80. s.Require().NoError(err)
  81. return data
  82. }
  83. func (s *HandlerTestSuite) execute(
  84. imageURL string,
  85. header http.Header,
  86. po *options.ProcessingOptions,
  87. ) *httptest.ResponseRecorder {
  88. req := httptest.NewRequest("GET", "/", nil)
  89. httpheaders.CopyAll(header, req.Header, true)
  90. ctx := s.T().Context()
  91. rw := httptest.NewRecorder()
  92. rww := s.rwFactory().NewWriter(rw)
  93. err := s.handler().Execute(ctx, req, imageURL, "test-req-id", po, rww)
  94. s.Require().NoError(err)
  95. return rw
  96. }
  97. // TestHandlerBasicRequest checks basic streaming request
  98. func (s *HandlerTestSuite) TestHandlerBasicRequest() {
  99. data := s.readTestFile("test1.png")
  100. ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  101. w.Header().Set(httpheaders.ContentType, "image/png")
  102. w.WriteHeader(200)
  103. w.Write(data)
  104. }))
  105. defer ts.Close()
  106. rw := s.execute(ts.URL, nil, &options.ProcessingOptions{})
  107. res := rw.Result()
  108. s.Require().Equal(200, res.StatusCode)
  109. s.Require().Equal("image/png", res.Header.Get(httpheaders.ContentType))
  110. // Verify we get the original image data
  111. actual := rw.Body.Bytes()
  112. s.Require().Equal(data, actual)
  113. }
  114. // TestHandlerResponseHeadersPassthrough checks that original response headers are
  115. // passed through to the client
  116. func (s *HandlerTestSuite) TestHandlerResponseHeadersPassthrough() {
  117. data := s.readTestFile("test1.png")
  118. contentLength := len(data)
  119. ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  120. w.Header().Set(httpheaders.ContentType, "image/png")
  121. w.Header().Set(httpheaders.ContentLength, strconv.Itoa(contentLength))
  122. w.Header().Set(httpheaders.AcceptRanges, "bytes")
  123. w.Header().Set(httpheaders.Etag, "etag")
  124. w.Header().Set(httpheaders.LastModified, "Wed, 21 Oct 2015 07:28:00 GMT")
  125. w.WriteHeader(200)
  126. w.Write(data)
  127. }))
  128. defer ts.Close()
  129. rw := s.execute(ts.URL, nil, &options.ProcessingOptions{})
  130. res := rw.Result()
  131. s.Require().Equal(200, res.StatusCode)
  132. s.Require().Equal("image/png", res.Header.Get(httpheaders.ContentType))
  133. s.Require().Equal(strconv.Itoa(contentLength), res.Header.Get(httpheaders.ContentLength))
  134. s.Require().Equal("bytes", res.Header.Get(httpheaders.AcceptRanges))
  135. s.Require().Equal("etag", res.Header.Get(httpheaders.Etag))
  136. s.Require().Equal("Wed, 21 Oct 2015 07:28:00 GMT", res.Header.Get(httpheaders.LastModified))
  137. }
  138. // TestHandlerRequestHeadersPassthrough checks that original request headers are passed through
  139. // to the server
  140. func (s *HandlerTestSuite) TestHandlerRequestHeadersPassthrough() {
  141. etag := `"test-etag-123"`
  142. data := s.readTestFile("test1.png")
  143. ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  144. // Verify that If-None-Match header is passed through
  145. s.Equal(etag, r.Header.Get(httpheaders.IfNoneMatch))
  146. s.Equal("gzip", r.Header.Get(httpheaders.AcceptEncoding))
  147. s.Equal("bytes=*", r.Header.Get(httpheaders.Range))
  148. w.Header().Set(httpheaders.Etag, etag)
  149. w.WriteHeader(200)
  150. w.Write(data)
  151. }))
  152. defer ts.Close()
  153. h := make(http.Header)
  154. h.Set(httpheaders.IfNoneMatch, etag)
  155. h.Set(httpheaders.AcceptEncoding, "gzip")
  156. h.Set(httpheaders.Range, "bytes=*")
  157. rw := s.execute(ts.URL, h, &options.ProcessingOptions{})
  158. res := rw.Result()
  159. s.Require().Equal(200, res.StatusCode)
  160. s.Require().Equal(etag, res.Header.Get(httpheaders.Etag))
  161. }
  162. // TestHandlerContentDisposition checks that Content-Disposition header is set correctly
  163. func (s *HandlerTestSuite) TestHandlerContentDisposition() {
  164. data := s.readTestFile("test1.png")
  165. ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  166. w.Header().Set(httpheaders.ContentType, "image/png")
  167. w.WriteHeader(200)
  168. w.Write(data)
  169. }))
  170. defer ts.Close()
  171. po := &options.ProcessingOptions{
  172. Filename: "custom_name",
  173. ReturnAttachment: true,
  174. }
  175. // Use a URL with a .png extension to help content disposition logic
  176. imageURL := ts.URL + "/test.png"
  177. rw := s.execute(imageURL, nil, po)
  178. res := rw.Result()
  179. s.Require().Equal(200, res.StatusCode)
  180. s.Require().Contains(res.Header.Get(httpheaders.ContentDisposition), "custom_name.png")
  181. s.Require().Contains(res.Header.Get(httpheaders.ContentDisposition), "attachment")
  182. }
  183. // TestHandlerCacheControl checks that Cache-Control header is set correctly in different cases
  184. func (s *HandlerTestSuite) TestHandlerCacheControl() {
  185. type testCase struct {
  186. name string
  187. cacheControlPassthrough bool
  188. setupOriginHeaders func(http.ResponseWriter)
  189. timestampOffset *time.Duration // nil for no timestamp, otherwise the offset from now
  190. expectedStatusCode int
  191. validate func(*testing.T, *http.Response)
  192. }
  193. // Duration variables for test cases
  194. var (
  195. oneHour = time.Hour
  196. thirtyMinutes = 30 * time.Minute
  197. fortyFiveMinutes = 45 * time.Minute
  198. twoHours = time.Hour * 2
  199. oneMinuteDelta = float64(time.Minute)
  200. )
  201. defaultTTL := 4242
  202. testCases := []testCase{
  203. {
  204. name: "Passthrough",
  205. cacheControlPassthrough: true,
  206. setupOriginHeaders: func(w http.ResponseWriter) {
  207. w.Header().Set(httpheaders.CacheControl, "max-age=3600, public")
  208. },
  209. timestampOffset: nil,
  210. expectedStatusCode: 200,
  211. validate: func(t *testing.T, res *http.Response) {
  212. s.Require().Equal("max-age=3600, public", res.Header.Get(httpheaders.CacheControl))
  213. },
  214. },
  215. // Checks that expires gets convert to cache-control
  216. {
  217. name: "ExpiresPassthrough",
  218. cacheControlPassthrough: true,
  219. setupOriginHeaders: func(w http.ResponseWriter) {
  220. w.Header().Set(httpheaders.Expires, time.Now().Add(oneHour).UTC().Format(http.TimeFormat))
  221. },
  222. timestampOffset: nil,
  223. expectedStatusCode: 200,
  224. validate: func(t *testing.T, res *http.Response) {
  225. // When expires is converted to cache-control, the expires header should be empty
  226. s.Require().Empty(res.Header.Get(httpheaders.Expires))
  227. s.Require().InDelta(oneHour, s.maxAgeValue(res), oneMinuteDelta)
  228. },
  229. },
  230. // It would be set to something like default ttl
  231. {
  232. name: "PassthroughDisabled",
  233. cacheControlPassthrough: false,
  234. setupOriginHeaders: func(w http.ResponseWriter) {
  235. w.Header().Set(httpheaders.CacheControl, "max-age=3600, public")
  236. },
  237. timestampOffset: nil,
  238. expectedStatusCode: 200,
  239. validate: func(t *testing.T, res *http.Response) {
  240. s.Require().Equal(s.maxAgeValue(res), time.Duration(defaultTTL)*time.Second)
  241. },
  242. },
  243. // When expires is set in processing options, but not present in the response
  244. {
  245. name: "WithProcessingOptionsExpires",
  246. cacheControlPassthrough: false,
  247. setupOriginHeaders: func(w http.ResponseWriter) {}, // No origin headers
  248. timestampOffset: &oneHour,
  249. expectedStatusCode: 200,
  250. validate: func(t *testing.T, res *http.Response) {
  251. s.Require().InDelta(oneHour, s.maxAgeValue(res), oneMinuteDelta)
  252. },
  253. },
  254. // When expires is set in processing options, and is present in the response,
  255. // and passthrough is enabled
  256. {
  257. name: "ProcessingOptionsOverridesOrigin",
  258. cacheControlPassthrough: true,
  259. setupOriginHeaders: func(w http.ResponseWriter) {
  260. // Origin has a longer cache time
  261. w.Header().Set(httpheaders.CacheControl, "max-age=7200, public")
  262. },
  263. timestampOffset: &thirtyMinutes,
  264. expectedStatusCode: 200,
  265. validate: func(t *testing.T, res *http.Response) {
  266. s.Require().InDelta(thirtyMinutes, s.maxAgeValue(res), oneMinuteDelta)
  267. },
  268. },
  269. // When expires is not set in po, but both expires and cc are present in response,
  270. // and passthrough is enabled
  271. {
  272. name: "BothHeadersPassthroughEnabled",
  273. cacheControlPassthrough: true,
  274. setupOriginHeaders: func(w http.ResponseWriter) {
  275. // Origin has both Cache-Control and Expires headers
  276. w.Header().Set(httpheaders.CacheControl, "max-age=1800, public")
  277. w.Header().Set(httpheaders.Expires, time.Now().Add(oneHour).UTC().Format(http.TimeFormat))
  278. },
  279. timestampOffset: nil,
  280. expectedStatusCode: 200,
  281. validate: func(t *testing.T, res *http.Response) {
  282. // Cache-Control should take precedence over Expires when both are present
  283. s.Require().InDelta(thirtyMinutes, s.maxAgeValue(res), oneMinuteDelta)
  284. s.Require().Empty(res.Header.Get(httpheaders.Expires))
  285. },
  286. },
  287. // When expires is set in PO AND both cache-control and expires are present in response,
  288. // and passthrough is enabled
  289. {
  290. name: "ProcessingOptionsOverridesBothOriginHeaders",
  291. cacheControlPassthrough: true,
  292. setupOriginHeaders: func(w http.ResponseWriter) {
  293. // Origin has both Cache-Control and Expires headers with longer cache times
  294. w.Header().Set(httpheaders.CacheControl, "max-age=7200, public")
  295. w.Header().Set(httpheaders.Expires, time.Now().Add(twoHours).UTC().Format(http.TimeFormat))
  296. },
  297. timestampOffset: &fortyFiveMinutes, // Shorter than origin headers
  298. expectedStatusCode: 200,
  299. validate: func(t *testing.T, res *http.Response) {
  300. s.Require().InDelta(fortyFiveMinutes, s.maxAgeValue(res), oneMinuteDelta)
  301. s.Require().Empty(res.Header.Get(httpheaders.Expires))
  302. },
  303. },
  304. // No headers set
  305. {
  306. name: "NoOriginHeaders",
  307. cacheControlPassthrough: false,
  308. setupOriginHeaders: func(w http.ResponseWriter) {}, // Origin has no cache headers
  309. timestampOffset: nil,
  310. expectedStatusCode: 200,
  311. validate: func(t *testing.T, res *http.Response) {
  312. s.Require().Equal(s.maxAgeValue(res), time.Duration(defaultTTL)*time.Second)
  313. },
  314. },
  315. }
  316. for _, tc := range testCases {
  317. s.Run(tc.name, func() {
  318. data := s.readTestFile("test1.png")
  319. ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  320. tc.setupOriginHeaders(w)
  321. w.Header().Set(httpheaders.ContentType, "image/png")
  322. w.WriteHeader(200)
  323. w.Write(data)
  324. }))
  325. defer ts.Close()
  326. s.rwConf().CacheControlPassthrough = tc.cacheControlPassthrough
  327. s.rwConf().DefaultTTL = 4242
  328. po := &options.ProcessingOptions{}
  329. if tc.timestampOffset != nil {
  330. expires := time.Now().Add(*tc.timestampOffset)
  331. po.Expires = &expires
  332. }
  333. rw := s.execute(ts.URL, nil, po)
  334. res := rw.Result()
  335. s.Require().Equal(tc.expectedStatusCode, res.StatusCode)
  336. tc.validate(s.T(), res)
  337. })
  338. }
  339. }
  340. // maxAgeValue parses max-age from cache-control
  341. func (s *HandlerTestSuite) maxAgeValue(res *http.Response) time.Duration {
  342. cacheControl := res.Header.Get(httpheaders.CacheControl)
  343. if cacheControl == "" {
  344. return 0
  345. }
  346. var maxAge int
  347. fmt.Sscanf(cacheControl, "max-age=%d", &maxAge)
  348. return time.Duration(maxAge) * time.Second
  349. }
  350. // TestHandlerSecurityHeaders tests the security headers set by the streaming service.
  351. func (s *HandlerTestSuite) TestHandlerSecurityHeaders() {
  352. data := s.readTestFile("test1.png")
  353. ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  354. w.Header().Set(httpheaders.ContentType, "image/png")
  355. w.WriteHeader(200)
  356. w.Write(data)
  357. }))
  358. defer ts.Close()
  359. rw := s.execute(ts.URL, nil, &options.ProcessingOptions{})
  360. res := rw.Result()
  361. s.Require().Equal(200, res.StatusCode)
  362. s.Require().Equal("script-src 'none'", res.Header.Get(httpheaders.ContentSecurityPolicy))
  363. }
  364. // TestHandlerErrorResponse tests the error responses from the streaming service.
  365. func (s *HandlerTestSuite) TestHandlerErrorResponse() {
  366. ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  367. w.WriteHeader(404)
  368. w.Write([]byte("Not Found"))
  369. }))
  370. defer ts.Close()
  371. rw := s.execute(ts.URL, nil, &options.ProcessingOptions{})
  372. res := rw.Result()
  373. s.Require().Equal(404, res.StatusCode)
  374. }
  375. // TestHandlerCookiePassthrough tests the cookie passthrough behavior of the streaming service.
  376. func (s *HandlerTestSuite) TestHandlerCookiePassthrough() {
  377. s.config().CookiePassthrough = true
  378. data := s.readTestFile("test1.png")
  379. ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  380. // Verify cookies are passed through
  381. cookie, cerr := r.Cookie("test_cookie")
  382. if cerr == nil {
  383. s.Equal("test_value", cookie.Value)
  384. }
  385. w.Header().Set(httpheaders.ContentType, "image/png")
  386. w.WriteHeader(200)
  387. w.Write(data)
  388. }))
  389. defer ts.Close()
  390. h := make(http.Header)
  391. h.Set(httpheaders.Cookie, "test_cookie=test_value")
  392. rw := s.execute(ts.URL, h, &options.ProcessingOptions{})
  393. res := rw.Result()
  394. s.Require().Equal(200, res.StatusCode)
  395. }
  396. // TestHandlerCanonicalHeader tests that the canonical header is set correctly
  397. func (s *HandlerTestSuite) TestHandlerCanonicalHeader() {
  398. data := s.readTestFile("test1.png")
  399. ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  400. w.Header().Set(httpheaders.ContentType, "image/png")
  401. w.WriteHeader(200)
  402. w.Write(data)
  403. }))
  404. defer ts.Close()
  405. for _, sc := range []bool{true, false} {
  406. s.rwConf().SetCanonicalHeader = sc
  407. rw := s.execute(ts.URL, nil, &options.ProcessingOptions{})
  408. res := rw.Result()
  409. s.Require().Equal(200, res.StatusCode)
  410. if sc {
  411. s.Require().Contains(res.Header.Get(httpheaders.Link), fmt.Sprintf(`<%s>; rel="canonical"`, ts.URL))
  412. } else {
  413. s.Require().Empty(res.Header.Get(httpheaders.Link))
  414. }
  415. }
  416. }
  417. func TestHandler(t *testing.T) {
  418. suite.Run(t, new(HandlerTestSuite))
  419. }