|
@@ -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))
|
|
|
+}
|