1
0
Viktor Sokolov 1 сар өмнө
parent
commit
7622ecacb4
1 өөрчлөгдсөн 467 нэмэгдсэн , 0 устгасан
  1. 467 0
      stream_test.go

+ 467 - 0
stream_test.go

@@ -0,0 +1,467 @@
+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))
+}