stream_test.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467
  1. package main
  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/server"
  16. )
  17. type StreamTestSuite struct {
  18. suite.Suite
  19. router *server.Router
  20. }
  21. func (s *StreamTestSuite) SetupSuite() {
  22. config.Reset()
  23. wd, err := os.Getwd()
  24. s.Require().NoError(err)
  25. s.T().Setenv("IMGPROXY_LOCAL_FILESYSTEM_ROOT", filepath.Join(wd, "/testdata"))
  26. s.T().Setenv("IMGPROXY_CLIENT_KEEP_ALIVE_TIMEOUT", "0")
  27. err = initialize()
  28. s.Require().NoError(err)
  29. logrus.SetOutput(io.Discard)
  30. s.router = buildRouter(server.NewRouter(server.NewConfigFromEnv()))
  31. }
  32. func (s *StreamTestSuite) TeardownSuite() {
  33. shutdown()
  34. logrus.SetOutput(os.Stdout)
  35. }
  36. func (s *StreamTestSuite) SetupTest() {
  37. config.Reset()
  38. config.AllowLoopbackSourceAddresses = true
  39. }
  40. func (s *StreamTestSuite) send(path string, header http.Header) *httptest.ResponseRecorder {
  41. req := httptest.NewRequest(http.MethodGet, path, nil)
  42. rw := httptest.NewRecorder()
  43. req.Header = header
  44. s.router.ServeHTTP(rw, req)
  45. return rw
  46. }
  47. func (s *StreamTestSuite) readTestFile(name string) []byte {
  48. wd, err := os.Getwd()
  49. s.Require().NoError(err)
  50. data, err := os.ReadFile(filepath.Join(wd, "testdata", name))
  51. s.Require().NoError(err)
  52. return data
  53. }
  54. // TestStreamBasicRequest checks basic streaming request
  55. func (s *StreamTestSuite) TestStreamBasicRequest() {
  56. rw := s.send("/unsafe/raw:1/plain/local:///test1.png", nil)
  57. res := rw.Result()
  58. s.Require().Equal(200, res.StatusCode)
  59. s.Require().Equal("image/png", res.Header.Get("Content-Type"))
  60. // Verify we get the original image data without processing
  61. expected := s.readTestFile("test1.png")
  62. actual := rw.Body.Bytes()
  63. s.Require().Equal(expected, actual)
  64. }
  65. // TestStreamResponseHeadersPassthrough checks that original response headers are
  66. // passed through to the client
  67. func (s *StreamTestSuite) TestStreamResponseHeadersPassthrough() {
  68. data := s.readTestFile("test1.png")
  69. contentLength := len(data)
  70. ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
  71. rw.Header().Set("Content-Type", "image/png")
  72. rw.Header().Set("Content-Length", strconv.Itoa(contentLength))
  73. rw.Header().Set("Accept-Ranges", "bytes")
  74. rw.Header().Set("ETag", "etag")
  75. rw.WriteHeader(200)
  76. rw.Write(data)
  77. }))
  78. defer ts.Close()
  79. rw := s.send("/unsafe/raw:1/plain/"+ts.URL, nil)
  80. res := rw.Result()
  81. s.Require().Equal(200, res.StatusCode)
  82. s.Require().Equal("image/png", res.Header.Get("Content-Type"))
  83. s.Require().Equal(strconv.Itoa(contentLength), res.Header.Get("Content-Length"))
  84. s.Require().Equal("bytes", res.Header.Get("Accept-Ranges"))
  85. s.Require().Equal("etag", res.Header.Get("ETag"))
  86. }
  87. // TestStreamLastModifiedPassthrough checks that Last-Modified header is passed through from the
  88. // server to the response regardless of config.LastModifiedEnabled setting
  89. func (s *StreamTestSuite) TestStreamLastModifiedPassthrough() {
  90. config.LastModifiedEnabled = false
  91. data := s.readTestFile("test1.png")
  92. lastModified := "Wed, 21 Oct 2015 07:28:00 GMT"
  93. ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
  94. rw.Header().Set("Last-Modified", lastModified)
  95. rw.Header().Set("Content-Type", "image/png")
  96. rw.WriteHeader(200)
  97. rw.Write(data)
  98. }))
  99. defer ts.Close()
  100. rw := s.send("/unsafe/raw:1/plain/"+ts.URL, nil)
  101. res := rw.Result()
  102. s.Require().Equal(200, res.StatusCode)
  103. s.Require().Equal(lastModified, res.Header.Get("Last-Modified"))
  104. }
  105. // TestStreamRequestHeadersPassthrough checks that original request headers are passed through
  106. // to the server
  107. func (s *StreamTestSuite) TestStreamRequestHeadersPassthrough() {
  108. etag := `"test-etag-123"`
  109. data := s.readTestFile("test1.png")
  110. ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
  111. // Verify that If-None-Match header is passed through
  112. s.Equal(etag, r.Header.Get("If-None-Match"))
  113. s.Equal("test", r.Header.Get("If-Modified-Since"))
  114. s.Equal("gzip", r.Header.Get("Accept-Encoding"))
  115. s.Equal("bytes=*", r.Header.Get("Range"))
  116. rw.Header().Set("ETag", etag)
  117. rw.WriteHeader(200)
  118. rw.Write(data)
  119. }))
  120. defer ts.Close()
  121. header := make(http.Header)
  122. header.Set("If-None-Match", etag)
  123. header.Set("If-Modified-Since", "test")
  124. header.Set("Accept-Encoding", "gzip")
  125. header.Set("Range", "bytes=*")
  126. rw := s.send("/unsafe/raw:1/plain/"+ts.URL, header)
  127. res := rw.Result()
  128. s.Require().Equal(200, res.StatusCode)
  129. s.Require().Equal(etag, res.Header.Get("ETag"))
  130. }
  131. // TestStreamContentDisposition checks that Content-Disposition header is set correctly
  132. func (s *StreamTestSuite) TestStreamContentDisposition() {
  133. data := s.readTestFile("test1.png")
  134. ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
  135. rw.Header().Set("Content-Type", "image/png")
  136. rw.WriteHeader(200)
  137. rw.Write(data)
  138. }))
  139. defer ts.Close()
  140. // Test with attachment
  141. rw := s.send("/unsafe/raw:1/fn:custom_name/att:1/plain/"+ts.URL, nil)
  142. res := rw.Result()
  143. s.Require().Equal(200, res.StatusCode)
  144. s.Require().Contains(res.Header.Get("Content-Disposition"), "custom_name.png")
  145. s.Require().Contains(res.Header.Get("Content-Disposition"), "attachment")
  146. }
  147. // TestStreamCacheControl checks that Cache-Control header is set correctly in different cases
  148. func (s *StreamTestSuite) TestStreamCacheControl() {
  149. type testCase struct {
  150. name string
  151. cacheControlPassthrough bool
  152. setupOriginHeaders func(http.ResponseWriter)
  153. urlPath string
  154. timestampOffset *time.Duration // nil for no timestamp, otherwise the offset from now
  155. expectedStatusCode int
  156. validate func(*testing.T, *http.Response)
  157. }
  158. // Duration variables for test cases
  159. var (
  160. oneHour = time.Hour
  161. thirtyMinutes = 30 * time.Minute
  162. fortyFiveMinutes = 45 * time.Minute
  163. twoHours = time.Hour * 2
  164. oneMinuteDelta = float64(time.Minute)
  165. )
  166. // Set this explicitly for testing purposes
  167. config.TTL = 4242
  168. testCases := []testCase{
  169. {
  170. name: "Passthrough",
  171. cacheControlPassthrough: true,
  172. setupOriginHeaders: func(rw http.ResponseWriter) {
  173. rw.Header().Set("Cache-Control", "max-age=3600, public")
  174. },
  175. urlPath: "/unsafe/raw:1/plain/%s",
  176. timestampOffset: nil,
  177. expectedStatusCode: 200,
  178. validate: func(t *testing.T, res *http.Response) {
  179. s.Require().Equal("max-age=3600, public", res.Header.Get("Cache-Control"))
  180. },
  181. },
  182. // Checks that expires gets convert to cache-control
  183. {
  184. name: "ExpiresPassthrough",
  185. cacheControlPassthrough: true,
  186. setupOriginHeaders: func(rw http.ResponseWriter) {
  187. rw.Header().Set("Expires", time.Now().Add(oneHour).UTC().Format(http.TimeFormat))
  188. },
  189. urlPath: "/unsafe/raw:1/plain/%s",
  190. timestampOffset: nil,
  191. expectedStatusCode: 200,
  192. validate: func(t *testing.T, res *http.Response) {
  193. // When expires is converted to cache-control, the expires header should be empty
  194. s.Require().Empty(res.Header.Get("Expires"))
  195. s.Require().InDelta(oneHour, s.maxAgeValue(res), oneMinuteDelta)
  196. },
  197. },
  198. // It would be set to something like default ttl
  199. {
  200. name: "PassthroughDisabled",
  201. cacheControlPassthrough: false,
  202. setupOriginHeaders: func(rw http.ResponseWriter) {
  203. rw.Header().Set("Cache-Control", "max-age=3600, public")
  204. },
  205. urlPath: "/unsafe/raw:1/plain/%s",
  206. timestampOffset: nil,
  207. expectedStatusCode: 200,
  208. validate: func(t *testing.T, res *http.Response) {
  209. s.Require().Equal(s.maxAgeValue(res), time.Duration(config.TTL)*time.Second)
  210. },
  211. },
  212. // When expires is set in processing options, but not present in the response
  213. {
  214. name: "WithProcessingOptionsExpires",
  215. cacheControlPassthrough: false,
  216. setupOriginHeaders: func(rw http.ResponseWriter) {}, // No origin headers
  217. urlPath: "/unsafe/raw:1/exp:%d/plain/%s",
  218. timestampOffset: &oneHour,
  219. expectedStatusCode: 200,
  220. validate: func(t *testing.T, res *http.Response) {
  221. s.Require().InDelta(oneHour, s.maxAgeValue(res), oneMinuteDelta)
  222. },
  223. },
  224. // When expires is set in processing options, and is present in the response,
  225. // and passthrough is enabled
  226. {
  227. name: "ProcessingOptionsOverridesOrigin",
  228. cacheControlPassthrough: true,
  229. setupOriginHeaders: func(rw http.ResponseWriter) {
  230. // Origin has a longer cache time
  231. rw.Header().Set("Cache-Control", "max-age=7200, public")
  232. },
  233. urlPath: "/unsafe/raw:1/exp:%d/plain/%s",
  234. timestampOffset: &thirtyMinutes,
  235. expectedStatusCode: 200,
  236. validate: func(t *testing.T, res *http.Response) {
  237. s.Require().InDelta(thirtyMinutes, s.maxAgeValue(res), oneMinuteDelta)
  238. },
  239. },
  240. // When expires is not set in po, but both expires and cc are present in response,
  241. // and passthrough is enabled
  242. {
  243. name: "BothHeadersPassthroughEnabled",
  244. cacheControlPassthrough: true,
  245. setupOriginHeaders: func(rw http.ResponseWriter) {
  246. // Origin has both Cache-Control and Expires headers
  247. rw.Header().Set("Cache-Control", "max-age=1800, public")
  248. rw.Header().Set("Expires", time.Now().Add(oneHour).UTC().Format(http.TimeFormat))
  249. },
  250. urlPath: "/unsafe/raw:1/plain/%s",
  251. timestampOffset: nil,
  252. expectedStatusCode: 200,
  253. validate: func(t *testing.T, res *http.Response) {
  254. // Cache-Control should take precedence over Expires when both are present
  255. s.Require().InDelta(thirtyMinutes, s.maxAgeValue(res), oneMinuteDelta)
  256. s.Require().Empty(res.Header.Get("Expires"))
  257. },
  258. },
  259. // When expires is set in PO AND both cache-control and expires are present in response,
  260. // and passthrough is enabled
  261. {
  262. name: "ProcessingOptionsOverridesBothOriginHeaders",
  263. cacheControlPassthrough: true,
  264. setupOriginHeaders: func(rw http.ResponseWriter) {
  265. // Origin has both Cache-Control and Expires headers with longer cache times
  266. rw.Header().Set("Cache-Control", "max-age=7200, public")
  267. rw.Header().Set("Expires", time.Now().Add(twoHours).UTC().Format(http.TimeFormat))
  268. },
  269. urlPath: "/unsafe/raw:1/exp:%d/plain/%s",
  270. timestampOffset: &fortyFiveMinutes, // Shorter than origin headers
  271. expectedStatusCode: 200,
  272. validate: func(t *testing.T, res *http.Response) {
  273. s.Require().InDelta(fortyFiveMinutes, s.maxAgeValue(res), oneMinuteDelta)
  274. s.Require().Empty(res.Header.Get("Expires"))
  275. },
  276. },
  277. // No headers set
  278. {
  279. name: "NoOriginHeaders",
  280. cacheControlPassthrough: false,
  281. setupOriginHeaders: func(rw http.ResponseWriter) {}, // Origin has no cache headers
  282. urlPath: "/unsafe/raw:1/plain/%s",
  283. timestampOffset: nil,
  284. expectedStatusCode: 200,
  285. validate: func(t *testing.T, res *http.Response) {
  286. s.Require().Equal(s.maxAgeValue(res), time.Duration(config.TTL)*time.Second)
  287. },
  288. },
  289. }
  290. for _, tc := range testCases {
  291. s.Run(tc.name, func() {
  292. config.CacheControlPassthrough = tc.cacheControlPassthrough
  293. data := s.readTestFile("test1.png")
  294. ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
  295. tc.setupOriginHeaders(rw)
  296. rw.Header().Set("Content-Type", "image/png")
  297. rw.WriteHeader(200)
  298. rw.Write(data)
  299. }))
  300. defer ts.Close()
  301. var url string
  302. if tc.timestampOffset != nil {
  303. timestamp := time.Now().Add(*tc.timestampOffset).Unix()
  304. url = fmt.Sprintf(tc.urlPath, timestamp, ts.URL)
  305. } else {
  306. url = fmt.Sprintf(tc.urlPath, ts.URL)
  307. }
  308. rw := s.send(url, nil)
  309. res := rw.Result()
  310. s.Require().Equal(tc.expectedStatusCode, res.StatusCode)
  311. tc.validate(s.T(), res)
  312. })
  313. }
  314. }
  315. // maxAgeValue parses max-age from cache-control
  316. func (s *StreamTestSuite) maxAgeValue(res *http.Response) time.Duration {
  317. cacheControl := res.Header.Get("Cache-Control")
  318. if cacheControl == "" {
  319. return 0
  320. }
  321. var maxAge int
  322. fmt.Sscanf(cacheControl, "max-age=%d", &maxAge)
  323. return time.Duration(maxAge) * time.Second
  324. }
  325. // TestStreamSecurityHeaders tests the security headers set by the streaming service.
  326. func (s *StreamTestSuite) TestStreamSecurityHeaders() {
  327. data := s.readTestFile("test1.png")
  328. ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
  329. rw.Header().Set("Content-Type", "image/png")
  330. rw.WriteHeader(200)
  331. rw.Write(data)
  332. }))
  333. defer ts.Close()
  334. rw := s.send("/unsafe/raw:1/plain/"+ts.URL, nil)
  335. res := rw.Result()
  336. s.Require().Equal(200, res.StatusCode)
  337. s.Require().Equal("script-src 'none'", res.Header.Get("Content-Security-Policy"))
  338. }
  339. // TestStreamErrorResponse tests the error responses from the streaming service.
  340. func (s *StreamTestSuite) TestStreamErrorResponse() {
  341. ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
  342. rw.WriteHeader(404)
  343. rw.Write([]byte("Not Found"))
  344. }))
  345. defer ts.Close()
  346. rw := s.send("/unsafe/raw:1/plain/"+ts.URL, nil)
  347. res := rw.Result()
  348. s.Require().Equal(404, res.StatusCode)
  349. }
  350. // TestStreamCookiePassthrough tests the cookie passthrough behavior of the streaming service.
  351. func (s *StreamTestSuite) TestStreamCookiePassthrough() {
  352. config.CookiePassthrough = true
  353. data := s.readTestFile("test1.png")
  354. ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
  355. // Verify cookies are passed through
  356. cookie, err := r.Cookie("test_cookie")
  357. if err == nil {
  358. s.Equal("test_value", cookie.Value)
  359. }
  360. rw.Header().Set("Content-Type", "image/png")
  361. rw.WriteHeader(200)
  362. rw.Write(data)
  363. }))
  364. defer ts.Close()
  365. header := make(http.Header)
  366. header.Set("Cookie", "test_cookie=test_value")
  367. rw := s.send("/unsafe/raw:1/plain/"+ts.URL, header)
  368. res := rw.Result()
  369. s.Require().Equal(200, res.StatusCode)
  370. }
  371. // TestStreamCanonicalHeader tests that the canonical header is set correctly
  372. func (s *StreamTestSuite) TestStreamCanonicalHeader() {
  373. data := s.readTestFile("test1.png")
  374. ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
  375. rw.Header().Set("Content-Type", "image/png")
  376. rw.WriteHeader(200)
  377. rw.Write(data)
  378. }))
  379. defer ts.Close()
  380. for _, sc := range []bool{true, false} {
  381. config.SetCanonicalHeader = sc
  382. rw := s.send("/unsafe/raw:1/plain/"+ts.URL, nil)
  383. res := rw.Result()
  384. s.Require().Equal(200, res.StatusCode)
  385. if sc {
  386. s.Require().Contains(res.Header.Get("Link"), fmt.Sprintf(`<%s>; rel="canonical"`, ts.URL))
  387. } else {
  388. s.Require().Empty(res.Header.Get("Link"))
  389. }
  390. }
  391. }
  392. func TestStream(t *testing.T) {
  393. suite.Run(t, new(StreamTestSuite))
  394. }