123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467 |
- package main
- import (
- "fmt"
- "io"
- "net/http"
- "net/http/httptest"
- "os"
- "path/filepath"
- "strconv"
- "testing"
- "time"
- "github.com/sirupsen/logrus"
- "github.com/stretchr/testify/suite"
- "github.com/imgproxy/imgproxy/v3/config"
- "github.com/imgproxy/imgproxy/v3/server"
- )
- type StreamTestSuite struct {
- suite.Suite
- router *server.Router
- }
- func (s *StreamTestSuite) SetupSuite() {
- config.Reset()
- wd, err := os.Getwd()
- s.Require().NoError(err)
- s.T().Setenv("IMGPROXY_LOCAL_FILESYSTEM_ROOT", filepath.Join(wd, "/testdata"))
- s.T().Setenv("IMGPROXY_CLIENT_KEEP_ALIVE_TIMEOUT", "0")
- err = initialize()
- s.Require().NoError(err)
- logrus.SetOutput(io.Discard)
- s.router = buildRouter(server.NewRouter(server.NewConfigFromEnv()))
- }
- func (s *StreamTestSuite) TeardownSuite() {
- shutdown()
- logrus.SetOutput(os.Stdout)
- }
- func (s *StreamTestSuite) SetupTest() {
- config.Reset()
- config.AllowLoopbackSourceAddresses = true
- }
- func (s *StreamTestSuite) send(path string, header http.Header) *httptest.ResponseRecorder {
- req := httptest.NewRequest(http.MethodGet, path, nil)
- rw := httptest.NewRecorder()
- req.Header = header
- s.router.ServeHTTP(rw, req)
- return rw
- }
- func (s *StreamTestSuite) readTestFile(name string) []byte {
- wd, err := os.Getwd()
- s.Require().NoError(err)
- data, err := os.ReadFile(filepath.Join(wd, "testdata", name))
- s.Require().NoError(err)
- return data
- }
- // TestStreamBasicRequest checks basic streaming request
- func (s *StreamTestSuite) TestStreamBasicRequest() {
- rw := s.send("/unsafe/raw:1/plain/local:///test1.png", nil)
- res := rw.Result()
- s.Require().Equal(200, res.StatusCode)
- s.Require().Equal("image/png", res.Header.Get("Content-Type"))
- // Verify we get the original image data without processing
- expected := s.readTestFile("test1.png")
- actual := rw.Body.Bytes()
- s.Require().Equal(expected, actual)
- }
- // TestStreamResponseHeadersPassthrough checks that original response headers are
- // passed through to the client
- func (s *StreamTestSuite) TestStreamResponseHeadersPassthrough() {
- data := s.readTestFile("test1.png")
- contentLength := len(data)
- ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
- rw.Header().Set("Content-Type", "image/png")
- rw.Header().Set("Content-Length", strconv.Itoa(contentLength))
- rw.Header().Set("Accept-Ranges", "bytes")
- rw.Header().Set("ETag", "etag")
- rw.WriteHeader(200)
- rw.Write(data)
- }))
- defer ts.Close()
- rw := s.send("/unsafe/raw:1/plain/"+ts.URL, nil)
- res := rw.Result()
- s.Require().Equal(200, res.StatusCode)
- s.Require().Equal("image/png", res.Header.Get("Content-Type"))
- s.Require().Equal(strconv.Itoa(contentLength), res.Header.Get("Content-Length"))
- s.Require().Equal("bytes", res.Header.Get("Accept-Ranges"))
- s.Require().Equal("etag", res.Header.Get("ETag"))
- }
- // TestStreamLastModifiedPassthrough checks that Last-Modified header is passed through from the
- // server to the response regardless of config.LastModifiedEnabled setting
- func (s *StreamTestSuite) TestStreamLastModifiedPassthrough() {
- config.LastModifiedEnabled = false
- data := s.readTestFile("test1.png")
- lastModified := "Wed, 21 Oct 2015 07:28:00 GMT"
- ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
- rw.Header().Set("Last-Modified", lastModified)
- rw.Header().Set("Content-Type", "image/png")
- rw.WriteHeader(200)
- rw.Write(data)
- }))
- defer ts.Close()
- rw := s.send("/unsafe/raw:1/plain/"+ts.URL, nil)
- res := rw.Result()
- s.Require().Equal(200, res.StatusCode)
- s.Require().Equal(lastModified, res.Header.Get("Last-Modified"))
- }
- // TestStreamRequestHeadersPassthrough checks that original request headers are passed through
- // to the server
- func (s *StreamTestSuite) TestStreamRequestHeadersPassthrough() {
- etag := `"test-etag-123"`
- data := s.readTestFile("test1.png")
- ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
- // Verify that If-None-Match header is passed through
- s.Equal(etag, r.Header.Get("If-None-Match"))
- s.Equal("test", r.Header.Get("If-Modified-Since"))
- s.Equal("gzip", r.Header.Get("Accept-Encoding"))
- s.Equal("bytes=*", r.Header.Get("Range"))
- rw.Header().Set("ETag", etag)
- rw.WriteHeader(200)
- rw.Write(data)
- }))
- defer ts.Close()
- header := make(http.Header)
- header.Set("If-None-Match", etag)
- header.Set("If-Modified-Since", "test")
- header.Set("Accept-Encoding", "gzip")
- header.Set("Range", "bytes=*")
- rw := s.send("/unsafe/raw:1/plain/"+ts.URL, header)
- res := rw.Result()
- s.Require().Equal(200, res.StatusCode)
- s.Require().Equal(etag, res.Header.Get("ETag"))
- }
- // TestStreamContentDisposition checks that Content-Disposition header is set correctly
- func (s *StreamTestSuite) TestStreamContentDisposition() {
- data := s.readTestFile("test1.png")
- ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
- rw.Header().Set("Content-Type", "image/png")
- rw.WriteHeader(200)
- rw.Write(data)
- }))
- defer ts.Close()
- // Test with attachment
- rw := s.send("/unsafe/raw:1/fn:custom_name/att:1/plain/"+ts.URL, nil)
- res := rw.Result()
- s.Require().Equal(200, res.StatusCode)
- s.Require().Contains(res.Header.Get("Content-Disposition"), "custom_name.png")
- s.Require().Contains(res.Header.Get("Content-Disposition"), "attachment")
- }
- // TestStreamCacheControl checks that Cache-Control header is set correctly in different cases
- func (s *StreamTestSuite) TestStreamCacheControl() {
- type testCase struct {
- name string
- cacheControlPassthrough bool
- setupOriginHeaders func(http.ResponseWriter)
- urlPath string
- timestampOffset *time.Duration // nil for no timestamp, otherwise the offset from now
- expectedStatusCode int
- validate func(*testing.T, *http.Response)
- }
- // Duration variables for test cases
- var (
- oneHour = time.Hour
- thirtyMinutes = 30 * time.Minute
- fortyFiveMinutes = 45 * time.Minute
- twoHours = time.Hour * 2
- oneMinuteDelta = float64(time.Minute)
- )
- // Set this explicitly for testing purposes
- config.TTL = 4242
- testCases := []testCase{
- {
- name: "Passthrough",
- cacheControlPassthrough: true,
- setupOriginHeaders: func(rw http.ResponseWriter) {
- rw.Header().Set("Cache-Control", "max-age=3600, public")
- },
- urlPath: "/unsafe/raw:1/plain/%s",
- timestampOffset: nil,
- expectedStatusCode: 200,
- validate: func(t *testing.T, res *http.Response) {
- s.Require().Equal("max-age=3600, public", res.Header.Get("Cache-Control"))
- },
- },
- // Checks that expires gets convert to cache-control
- {
- name: "ExpiresPassthrough",
- cacheControlPassthrough: true,
- setupOriginHeaders: func(rw http.ResponseWriter) {
- rw.Header().Set("Expires", time.Now().Add(oneHour).UTC().Format(http.TimeFormat))
- },
- urlPath: "/unsafe/raw:1/plain/%s",
- timestampOffset: nil,
- expectedStatusCode: 200,
- validate: func(t *testing.T, res *http.Response) {
- // When expires is converted to cache-control, the expires header should be empty
- s.Require().Empty(res.Header.Get("Expires"))
- s.Require().InDelta(oneHour, s.maxAgeValue(res), oneMinuteDelta)
- },
- },
- // It would be set to something like default ttl
- {
- name: "PassthroughDisabled",
- cacheControlPassthrough: false,
- setupOriginHeaders: func(rw http.ResponseWriter) {
- rw.Header().Set("Cache-Control", "max-age=3600, public")
- },
- urlPath: "/unsafe/raw:1/plain/%s",
- timestampOffset: nil,
- expectedStatusCode: 200,
- validate: func(t *testing.T, res *http.Response) {
- s.Require().Equal(s.maxAgeValue(res), time.Duration(config.TTL)*time.Second)
- },
- },
- // When expires is set in processing options, but not present in the response
- {
- name: "WithProcessingOptionsExpires",
- cacheControlPassthrough: false,
- setupOriginHeaders: func(rw http.ResponseWriter) {}, // No origin headers
- urlPath: "/unsafe/raw:1/exp:%d/plain/%s",
- timestampOffset: &oneHour,
- expectedStatusCode: 200,
- validate: func(t *testing.T, res *http.Response) {
- s.Require().InDelta(oneHour, s.maxAgeValue(res), oneMinuteDelta)
- },
- },
- // When expires is set in processing options, and is present in the response,
- // and passthrough is enabled
- {
- name: "ProcessingOptionsOverridesOrigin",
- cacheControlPassthrough: true,
- setupOriginHeaders: func(rw http.ResponseWriter) {
- // Origin has a longer cache time
- rw.Header().Set("Cache-Control", "max-age=7200, public")
- },
- urlPath: "/unsafe/raw:1/exp:%d/plain/%s",
- timestampOffset: &thirtyMinutes,
- expectedStatusCode: 200,
- validate: func(t *testing.T, res *http.Response) {
- s.Require().InDelta(thirtyMinutes, s.maxAgeValue(res), oneMinuteDelta)
- },
- },
- // When expires is not set in po, but both expires and cc are present in response,
- // and passthrough is enabled
- {
- name: "BothHeadersPassthroughEnabled",
- cacheControlPassthrough: true,
- setupOriginHeaders: func(rw http.ResponseWriter) {
- // Origin has both Cache-Control and Expires headers
- rw.Header().Set("Cache-Control", "max-age=1800, public")
- rw.Header().Set("Expires", time.Now().Add(oneHour).UTC().Format(http.TimeFormat))
- },
- urlPath: "/unsafe/raw:1/plain/%s",
- timestampOffset: nil,
- expectedStatusCode: 200,
- validate: func(t *testing.T, res *http.Response) {
- // Cache-Control should take precedence over Expires when both are present
- s.Require().InDelta(thirtyMinutes, s.maxAgeValue(res), oneMinuteDelta)
- s.Require().Empty(res.Header.Get("Expires"))
- },
- },
- // When expires is set in PO AND both cache-control and expires are present in response,
- // and passthrough is enabled
- {
- name: "ProcessingOptionsOverridesBothOriginHeaders",
- cacheControlPassthrough: true,
- setupOriginHeaders: func(rw http.ResponseWriter) {
- // Origin has both Cache-Control and Expires headers with longer cache times
- rw.Header().Set("Cache-Control", "max-age=7200, public")
- rw.Header().Set("Expires", time.Now().Add(twoHours).UTC().Format(http.TimeFormat))
- },
- urlPath: "/unsafe/raw:1/exp:%d/plain/%s",
- timestampOffset: &fortyFiveMinutes, // Shorter than origin headers
- expectedStatusCode: 200,
- validate: func(t *testing.T, res *http.Response) {
- s.Require().InDelta(fortyFiveMinutes, s.maxAgeValue(res), oneMinuteDelta)
- s.Require().Empty(res.Header.Get("Expires"))
- },
- },
- // No headers set
- {
- name: "NoOriginHeaders",
- cacheControlPassthrough: false,
- setupOriginHeaders: func(rw http.ResponseWriter) {}, // Origin has no cache headers
- urlPath: "/unsafe/raw:1/plain/%s",
- timestampOffset: nil,
- expectedStatusCode: 200,
- validate: func(t *testing.T, res *http.Response) {
- s.Require().Equal(s.maxAgeValue(res), time.Duration(config.TTL)*time.Second)
- },
- },
- }
- for _, tc := range testCases {
- s.Run(tc.name, func() {
- config.CacheControlPassthrough = tc.cacheControlPassthrough
- data := s.readTestFile("test1.png")
- ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
- tc.setupOriginHeaders(rw)
- rw.Header().Set("Content-Type", "image/png")
- rw.WriteHeader(200)
- rw.Write(data)
- }))
- defer ts.Close()
- var url string
- if tc.timestampOffset != nil {
- timestamp := time.Now().Add(*tc.timestampOffset).Unix()
- url = fmt.Sprintf(tc.urlPath, timestamp, ts.URL)
- } else {
- url = fmt.Sprintf(tc.urlPath, ts.URL)
- }
- rw := s.send(url, nil)
- res := rw.Result()
- s.Require().Equal(tc.expectedStatusCode, res.StatusCode)
- tc.validate(s.T(), res)
- })
- }
- }
- // maxAgeValue parses max-age from cache-control
- func (s *StreamTestSuite) maxAgeValue(res *http.Response) time.Duration {
- cacheControl := res.Header.Get("Cache-Control")
- if cacheControl == "" {
- return 0
- }
- var maxAge int
- fmt.Sscanf(cacheControl, "max-age=%d", &maxAge)
- return time.Duration(maxAge) * time.Second
- }
- // TestStreamSecurityHeaders tests the security headers set by the streaming service.
- func (s *StreamTestSuite) TestStreamSecurityHeaders() {
- data := s.readTestFile("test1.png")
- ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
- rw.Header().Set("Content-Type", "image/png")
- rw.WriteHeader(200)
- rw.Write(data)
- }))
- defer ts.Close()
- rw := s.send("/unsafe/raw:1/plain/"+ts.URL, nil)
- res := rw.Result()
- s.Require().Equal(200, res.StatusCode)
- s.Require().Equal("script-src 'none'", res.Header.Get("Content-Security-Policy"))
- }
- // TestStreamErrorResponse tests the error responses from the streaming service.
- func (s *StreamTestSuite) TestStreamErrorResponse() {
- ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
- rw.WriteHeader(404)
- rw.Write([]byte("Not Found"))
- }))
- defer ts.Close()
- rw := s.send("/unsafe/raw:1/plain/"+ts.URL, nil)
- res := rw.Result()
- s.Require().Equal(404, res.StatusCode)
- }
- // TestStreamCookiePassthrough tests the cookie passthrough behavior of the streaming service.
- func (s *StreamTestSuite) TestStreamCookiePassthrough() {
- config.CookiePassthrough = true
- data := s.readTestFile("test1.png")
- ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
- // Verify cookies are passed through
- cookie, err := r.Cookie("test_cookie")
- if err == nil {
- s.Equal("test_value", cookie.Value)
- }
- rw.Header().Set("Content-Type", "image/png")
- rw.WriteHeader(200)
- rw.Write(data)
- }))
- defer ts.Close()
- header := make(http.Header)
- header.Set("Cookie", "test_cookie=test_value")
- rw := s.send("/unsafe/raw:1/plain/"+ts.URL, header)
- res := rw.Result()
- s.Require().Equal(200, res.StatusCode)
- }
- // TestStreamCanonicalHeader tests that the canonical header is set correctly
- func (s *StreamTestSuite) TestStreamCanonicalHeader() {
- data := s.readTestFile("test1.png")
- ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
- rw.Header().Set("Content-Type", "image/png")
- rw.WriteHeader(200)
- rw.Write(data)
- }))
- defer ts.Close()
- for _, sc := range []bool{true, false} {
- config.SetCanonicalHeader = sc
- rw := s.send("/unsafe/raw:1/plain/"+ts.URL, nil)
- res := rw.Result()
- s.Require().Equal(200, res.StatusCode)
- if sc {
- s.Require().Contains(res.Header.Get("Link"), fmt.Sprintf(`<%s>; rel="canonical"`, ts.URL))
- } else {
- s.Require().Empty(res.Header.Get("Link"))
- }
- }
- }
- func TestStream(t *testing.T) {
- suite.Run(t, new(StreamTestSuite))
- }
|