handler_test.go 15 KB

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