github.com/pzeinlinger/servefiles/v3@v3.6.0/assets_test.go (about)

     1  // MIT License
     2  //
     3  // Copyright (c) 2016 Rick Beton
     4  //
     5  // Permission is hereby granted, free of charge, to any person obtaining a copy
     6  // of this software and associated documentation files (the "Software"), to deal
     7  // in the Software without restriction, including without limitation the rights
     8  // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
     9  // copies of the Software, and to permit persons to whom the Software is
    10  // furnished to do so, subject to the following conditions:
    11  //
    12  // The above copyright notice and this permission notice shall be included in all
    13  // copies or substantial portions of the Software.
    14  //
    15  // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    16  // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    17  // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    18  // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    19  // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    20  // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    21  // SOFTWARE.
    22  
    23  package servefiles
    24  
    25  import (
    26  	"fmt"
    27  	"log"
    28  	"net/http"
    29  	"net/http/httptest"
    30  	. "net/url"
    31  	"os"
    32  	"reflect"
    33  	"strings"
    34  	"testing"
    35  	"time"
    36  
    37  	"github.com/spf13/afero"
    38  )
    39  
    40  var emptyStrings []string
    41  
    42  func mustChdir(dir string) {
    43  	err := os.Chdir(dir)
    44  	if err != nil {
    45  		panic(err)
    46  	}
    47  }
    48  
    49  func init() {
    50  	mustChdir("testdata")
    51  }
    52  
    53  const (
    54  	cssMimeType        = "text/css; charset=utf-8"
    55  	javascriptMimeType = "text/javascript; charset=utf-8"
    56  )
    57  
    58  func TestChooseResourceSimpleDirNoGzip(t *testing.T) {
    59  	cases := []struct {
    60  		n                       int
    61  		maxAge                  time.Duration
    62  		url, path, cacheControl string
    63  	}{
    64  		{0, 1, "/", "assets/index.html", "public, max-age=1"},
    65  	}
    66  
    67  	for i, test := range cases {
    68  		etag := etagFor(test.path)
    69  		url := mustUrl(test.url)
    70  		request := &http.Request{Method: "GET", URL: url}
    71  		a := NewAssetHandler("./assets/").WithCacheDirective(CacheDirectivePublic).StripOff(test.n).WithMaxAge(test.maxAge * time.Second)
    72  		w := httptest.NewRecorder()
    73  
    74  		a.ServeHTTP(w, request)
    75  
    76  		isEqual(t, w.Code, 200, i)
    77  		//isEqual(t, message, "", test.path)
    78  		isEqual(t, w.Header()["Cache-Control"], []string{test.cacheControl}, i)
    79  		isEqual(t, w.Header()["Etag"], []string{etag}, i)
    80  	}
    81  }
    82  
    83  func TestChooseResourceSimpleNoGzip(t *testing.T) {
    84  	cases := []struct {
    85  		n                       int
    86  		maxAge                  time.Duration
    87  		url, path, cacheControl string
    88  	}{
    89  		{0, 1, "/img/sort_asc.png", "assets/img/sort_asc.png", "public, max-age=1"},
    90  		{0, 3671, "/img/sort_asc.png", "assets/img/sort_asc.png", "public, max-age=3671"},
    91  		{3, 3671, "/x/y/z/img/sort_asc.png", "assets/img/sort_asc.png", "public, max-age=3671"},
    92  	}
    93  
    94  	for i, test := range cases {
    95  		etag := etagFor(test.path)
    96  		url := mustUrl(test.url)
    97  		request := &http.Request{Method: "GET", URL: url}
    98  		a := NewAssetHandler("./assets/").WithCacheDirective(CacheDirectivePublic).StripOff(test.n).WithMaxAge(test.maxAge * time.Second)
    99  		w := httptest.NewRecorder()
   100  
   101  		a.ServeHTTP(w, request)
   102  
   103  		isEqual(t, w.Code, 200, i)
   104  		//isEqual(t, message, "", test.path)
   105  		isEqual(t, w.Header()["Cache-Control"], []string{test.cacheControl}, i)
   106  		isEqual(t, w.Header()["Etag"], []string{etag}, i)
   107  		isEqual(t, w.Body.Len(), 160, i)
   108  	}
   109  }
   110  
   111  func TestChooseResourceSimpleNonExistent(t *testing.T) {
   112  	cases := []struct {
   113  		n      int
   114  		maxAge time.Duration
   115  		url    string
   116  	}{
   117  		{0, time.Second, "/img/nonexisting.png"},
   118  		{1, time.Second, "/a/img/nonexisting.png"},
   119  		{2, time.Second, "/a/b/img/nonexisting.png"},
   120  	}
   121  
   122  	for i, test := range cases {
   123  		url := mustUrl(test.url)
   124  		request := &http.Request{Method: "GET", URL: url}
   125  		a := NewAssetHandler("./assets/").WithCacheDirective(CacheDirectivePublic).StripOff(test.n).WithMaxAge(test.maxAge)
   126  		w := httptest.NewRecorder()
   127  
   128  		a.ServeHTTP(w, request)
   129  
   130  		isEqual(t, w.Code, 404, i)
   131  		//t.Logf("header %v", w.Header())
   132  		isGte(t, len(w.Header()), 4, i)
   133  		isEqual(t, w.Header().Get("Content-Type"), "text/plain; charset=utf-8", i)
   134  		isEqual(t, w.Header().Get("Cache-Control"), "public, max-age=1", i)
   135  	}
   136  }
   137  
   138  func TestServeHTTP200WithGzipAndGzipWithAcceptHeader(t *testing.T) {
   139  	cases := []struct {
   140  		n                                       int
   141  		maxAge                                  time.Duration
   142  		url, mime, encoding, path, cacheControl string
   143  	}{
   144  		{0, 1, "/css/style1.css", cssMimeType, "xx, gzip, zzz", "assets/css/style1.css.gz", "public, max-age=1"},
   145  		{2, 1, "/a/b/css/style1.css", cssMimeType, "xx, gzip, zzz", "assets/css/style1.css.gz", "public, max-age=1"},
   146  		{0, 1, "/js/script1.js", javascriptMimeType, "xx, gzip, zzz", "assets/js/script1.js.gz", "public, max-age=1"},
   147  		{2, 1, "/a/b/js/script1.js", javascriptMimeType, "xx, gzip, zzz", "assets/js/script1.js.gz", "public, max-age=1"},
   148  	}
   149  
   150  	for _, test := range cases {
   151  		etag := etagFor(test.path)
   152  		url := mustUrl(test.url)
   153  		header := newHeader("Accept-Encoding", test.encoding)
   154  		request := &http.Request{Method: "GET", URL: url, Header: header}
   155  		a := NewAssetHandler("./assets/").WithCacheDirective(CacheDirectivePublic).StripOff(test.n).WithMaxAge(test.maxAge * time.Second)
   156  		w := httptest.NewRecorder()
   157  
   158  		a.ServeHTTP(w, request)
   159  
   160  		isEqual(t, w.Code, 200, test.path)
   161  		headers := w.Header()
   162  		//t.Logf("%+v\n", headers)
   163  		isGte(t, len(headers), 7, test.path)
   164  		isEqual(t, headers["Cache-Control"], []string{test.cacheControl}, test.path)
   165  		isEqual(t, headers["Content-Type"], []string{test.mime}, test.path)
   166  		isEqual(t, headers["X-Content-Type-Options"], []string{"nosniff"}, test.path)
   167  		isEqual(t, headers["Content-Encoding"], []string{"gzip"}, test.path)
   168  		isEqual(t, headers["Vary"], []string{"Accept-Encoding"}, test.path)
   169  		isEqual(t, headers["Etag"], []string{"W/" + etag}, test.path)
   170  	}
   171  }
   172  
   173  func TestServeHTTP200WithBrAndBrWithAcceptHeader(t *testing.T) {
   174  	cases := []struct {
   175  		n                                       int
   176  		maxAge                                  time.Duration
   177  		url, mime, encoding, path, cacheControl string
   178  	}{
   179  		{0, 1, "/css/style1.css", cssMimeType, "br, gzip, zzz", "assets/css/style1.css.br", "public, max-age=1"},
   180  		{2, 1, "/a/b/css/style1.css", cssMimeType, "br, gzip, zzz", "assets/css/style1.css.br", "public, max-age=1"},
   181  		{0, 1, "/js/script1.js", javascriptMimeType, "br, gzip, zzz", "assets/js/script1.js.br", "public, max-age=1"},
   182  		{2, 1, "/a/b/js/script1.js", javascriptMimeType, "br, gzip, zzz", "assets/js/script1.js.br", "public, max-age=1"},
   183  	}
   184  
   185  	for _, test := range cases {
   186  		etag := etagFor(test.path)
   187  		url := mustUrl(test.url)
   188  		header := newHeader("Accept-Encoding", test.encoding)
   189  		request := &http.Request{Method: "GET", URL: url, Header: header}
   190  		a := NewAssetHandler("./assets/").WithCacheDirective(CacheDirectivePublic).StripOff(test.n).WithMaxAge(test.maxAge * time.Second)
   191  		w := httptest.NewRecorder()
   192  
   193  		a.ServeHTTP(w, request)
   194  
   195  		isEqual(t, w.Code, 200, test.path)
   196  		headers := w.Header()
   197  		//t.Logf("%+v\n", headers)
   198  		isGte(t, len(headers), 7, test.path)
   199  		isEqual(t, headers["Cache-Control"], []string{test.cacheControl}, test.path)
   200  		isEqual(t, headers["Content-Type"], []string{test.mime}, test.path)
   201  		isEqual(t, headers["X-Content-Type-Options"], []string{"nosniff"}, test.path)
   202  		isEqual(t, headers["Content-Encoding"], []string{"br"}, test.path)
   203  		isEqual(t, headers["Vary"], []string{"Accept-Encoding"}, test.path)
   204  		isEqual(t, headers["Etag"], []string{"W/" + etag}, test.path)
   205  	}
   206  }
   207  
   208  func TestServeHTTP200WithGzipButNoAcceptHeader(t *testing.T) {
   209  	cases := []struct {
   210  		n                                       int
   211  		maxAge                                  time.Duration
   212  		url, mime, encoding, path, cacheControl string
   213  	}{
   214  		{0, 1, "/css/style1.css", cssMimeType, "xx, yy, zzz", "assets/css/style1.css", "public, max-age=1"},
   215  		{2, 2, "/a/b/css/style1.css", cssMimeType, "xx, yy, zzz", "assets/css/style1.css", "public, max-age=2"},
   216  		{0, 3, "/js/script1.js", javascriptMimeType, "xx, yy, zzz", "assets/js/script1.js", "public, max-age=3"},
   217  		{2, 4, "/a/b/js/script1.js", javascriptMimeType, "xx, yy, zzz", "assets/js/script1.js", "public, max-age=4"},
   218  	}
   219  
   220  	for _, test := range cases {
   221  		etag := etagFor(test.path)
   222  		url := mustUrl(test.url)
   223  		header := newHeader("Accept-Encoding", test.encoding)
   224  		request := &http.Request{Method: "GET", URL: url, Header: header}
   225  		a := NewAssetHandler("./assets/").WithCacheDirective(CacheDirectivePublic).StripOff(test.n).WithMaxAge(test.maxAge * time.Second)
   226  		w := httptest.NewRecorder()
   227  
   228  		a.ServeHTTP(w, request)
   229  
   230  		isEqual(t, w.Code, 200, test.path)
   231  		headers := w.Header()
   232  		//t.Logf("%+v\n", headers)
   233  		isGte(t, len(headers), 6, test.path)
   234  		isEqual(t, headers["Cache-Control"], []string{test.cacheControl}, test.path)
   235  		isEqual(t, headers["Content-Type"], []string{test.mime}, test.path)
   236  		isEqual(t, headers["Content-Encoding"], emptyStrings, test.path)
   237  		isEqual(t, headers["Vary"], emptyStrings, test.path)
   238  		isEqual(t, headers["Etag"], []string{etag}, test.path)
   239  	}
   240  }
   241  
   242  func TestServeHTTP200WithGzipAcceptHeaderButNoGzippedFile(t *testing.T) {
   243  	cases := []struct {
   244  		n                                       int
   245  		maxAge                                  time.Duration
   246  		url, mime, encoding, path, cacheControl string
   247  	}{
   248  		{0, 1, "/css/style2.css", cssMimeType, "xx, gzip, zzz", "assets/css/style2.css", "public, max-age=1"},
   249  		{0, 1, "/css/style2.css", cssMimeType, "br, gzip, zzz", "assets/css/style2.css", "public, max-age=1"},
   250  		{2, 2, "/a/b/css/style2.css", cssMimeType, "xx, gzip, zzz", "assets/css/style2.css", "public, max-age=2"},
   251  		{2, 2, "/a/b/css/style2.css", cssMimeType, "br, gzip, zzz", "assets/css/style2.css", "public, max-age=2"},
   252  		{0, 3, "/js/script2.js", javascriptMimeType, "xx, gzip, zzz", "assets/js/script2.js", "public, max-age=3"},
   253  		{0, 3, "/js/script2.js", javascriptMimeType, "br, gzip, zzz", "assets/js/script2.js", "public, max-age=3"},
   254  		{2, 4, "/a/b/js/script2.js", javascriptMimeType, "xx, gzip, zzz", "assets/js/script2.js", "public, max-age=4"},
   255  		{2, 4, "/a/b/js/script2.js", javascriptMimeType, "br, gzip, zzz", "assets/js/script2.js", "public, max-age=4"},
   256  		{0, 5, "/img/sort_asc.png", "image/png", "xx, gzip, zzz", "assets/img/sort_asc.png", "public, max-age=5"},
   257  		{0, 5, "/img/sort_asc.png", "image/png", "br, gzip, zzz", "assets/img/sort_asc.png", "public, max-age=5"},
   258  		{2, 6, "/a/b/img/sort_asc.png", "image/png", "xx, gzip, zzz", "assets/img/sort_asc.png", "public, max-age=6"},
   259  		{2, 6, "/a/b/img/sort_asc.png", "image/png", "br, gzip, zzz", "assets/img/sort_asc.png", "public, max-age=6"},
   260  	}
   261  
   262  	for _, test := range cases {
   263  		etag := etagFor(test.path)
   264  		url := mustUrl(test.url)
   265  		header := newHeader("Accept-Encoding", test.encoding)
   266  		request := &http.Request{Method: "GET", URL: url, Header: header}
   267  		a := NewAssetHandler("./assets/").WithCacheDirective(CacheDirectivePublic).StripOff(test.n).WithMaxAge(test.maxAge * time.Second)
   268  		w := httptest.NewRecorder()
   269  
   270  		a.ServeHTTP(w, request)
   271  
   272  		isEqual(t, w.Code, 200, test.path)
   273  		headers := w.Header()
   274  		//t.Logf("%+v\n", headers)
   275  		isGte(t, len(headers), 6, test.path)
   276  		isEqual(t, headers["Cache-Control"], []string{test.cacheControl}, test.path)
   277  		isEqual(t, headers["Content-Type"], []string{test.mime}, test.path)
   278  		isEqual(t, headers["Content-Encoding"], emptyStrings, test.path)
   279  		isEqual(t, headers["Vary"], emptyStrings, test.path)
   280  		isEqual(t, headers["Etag"], []string{etag}, test.path)
   281  	}
   282  }
   283  
   284  //-------------------------------------------------------------------------------------------------
   285  
   286  type h404 struct{}
   287  
   288  func (h *h404) ServeHTTP(w http.ResponseWriter, r *http.Request) {
   289  	w.Header().Set("Content-Type", "text/html")
   290  	w.WriteHeader(404)
   291  	w.Write([]byte("<html>foo</html>"))
   292  }
   293  
   294  func Test404Handler(t *testing.T) {
   295  	cases := []struct {
   296  		path, conType, response string
   297  		notFound                http.Handler
   298  	}{
   299  		{"/img/nonexisting.png", "text/plain; charset=utf-8", "404 Not found\n", nil},
   300  		{"/img/nonexisting.png", "text/html", "<html>foo</html>", &h404{}},
   301  	}
   302  
   303  	for i, test := range cases {
   304  		url := mustUrl("" + test.path)
   305  		request := &http.Request{Method: "GET", URL: url}
   306  		a := NewAssetHandler("./assets/").WithCacheDirective(CacheDirectivePublic).WithNotFound(test.notFound)
   307  		isEqual(t, a.NotFound, test.notFound, i)
   308  		w := httptest.NewRecorder()
   309  
   310  		a.ServeHTTP(w, request)
   311  
   312  		isEqual(t, w.Code, 404, i)
   313  		isEqual(t, w.Header().Get("Content-Type"), test.conType, i)
   314  		isEqual(t, w.Body.String(), test.response, i)
   315  	}
   316  }
   317  
   318  func Test403Handling(t *testing.T) {
   319  	cases := []struct {
   320  		path   string
   321  		header http.Header
   322  	}{
   323  		{"/css/style1.css", newHeader()},
   324  		{"/css/style1.css", newHeader("Accept-Encoding", "gzip")},
   325  	}
   326  
   327  	for i, test := range cases {
   328  		url := mustUrl("" + test.path)
   329  		request := &http.Request{Method: "GET", URL: url, Header: test.header}
   330  		a := NewAssetHandlerFS(&fs403{os.ErrPermission})
   331  		w := httptest.NewRecorder()
   332  
   333  		a.ServeHTTP(w, request)
   334  
   335  		isEqual(t, w.Code, 403, i)
   336  		isEqual(t, w.Header().Get("Content-Type"), "text/plain; charset=utf-8", i)
   337  		isEqual(t, w.Body.String(), "403 Forbidden\n", i)
   338  	}
   339  }
   340  
   341  func Test503Handling(t *testing.T) {
   342  	cases := []struct {
   343  		path   string
   344  		header http.Header
   345  	}{
   346  		{"/css/style1.css", newHeader()},
   347  		{"/css/style1.css", newHeader("Accept-Encoding", "gzip")},
   348  	}
   349  
   350  	for i, test := range cases {
   351  		url := mustUrl("" + test.path)
   352  		request := &http.Request{Method: "GET", URL: url, Header: test.header}
   353  		a := NewAssetHandlerFS(&fs403{os.ErrInvalid})
   354  		w := httptest.NewRecorder()
   355  
   356  		a.ServeHTTP(w, request)
   357  
   358  		isEqual(t, w.Code, 503, i)
   359  		isEqual(t, w.Header().Get("Content-Type"), "text/plain; charset=utf-8", i)
   360  		isNotEqual(t, w.Header().Get("Retry-After"), "", i)
   361  		isEqual(t, w.Body.String(), "503 Service unavailable\n", i)
   362  	}
   363  }
   364  
   365  //-------------------------------------------------------------------------------------------------
   366  
   367  func TestServeHTTP304(t *testing.T) {
   368  	cases := []struct {
   369  		url, path, encoding string
   370  		notFound            http.Handler
   371  	}{
   372  		{"/css/style1.css", "assets/css/style1.css.gz", "gzip", nil},
   373  		{"/css/style1.css", "assets/css/style1.css.br", "br", nil},
   374  		{"/css/style2.css", "assets/css/style2.css", "xx", nil},
   375  		{"/img/sort_asc.png", "assets/img/sort_asc.png", "xx", nil},
   376  		{"/js/script1.js", "assets/js/script1.js.gz", "gzip", nil},
   377  		{"/js/script1.js", "assets/js/script1.js.br", "br", nil},
   378  		{"/js/script2.js", "assets/js/script2.js", "xx", nil},
   379  
   380  		{"/css/style1.css", "assets/css/style1.css.gz", "gzip", &h404{}},
   381  		{"/css/style1.css", "assets/css/style1.css.br", "br", &h404{}},
   382  		{"/css/style2.css", "assets/css/style2.css", "xx", &h404{}},
   383  		{"/img/sort_asc.png", "assets/img/sort_asc.png", "xx", &h404{}},
   384  		{"/js/script1.js", "assets/js/script1.js.gz", "gzip", &h404{}},
   385  		{"/js/script1.js", "assets/js/script1.js.br", "br", &h404{}},
   386  		{"/js/script2.js", "assets/js/script2.js", "xx", &h404{}},
   387  	}
   388  
   389  	// net/http serveFiles handles conditional requests according to RFC723x specs.
   390  	// So we only need to check that a conditional request is correctly wired in.
   391  
   392  	for i, test := range cases {
   393  		etag := etagFor(test.path)
   394  		url := mustUrl(test.url)
   395  		header := newHeader("Accept-Encoding", test.encoding, "If-None-Match", etag)
   396  		request := &http.Request{Method: "GET", URL: url, Header: header}
   397  		a := NewAssetHandler("./assets/").WithCacheDirective(CacheDirectivePublic).WithNotFound(test.notFound)
   398  		w := httptest.NewRecorder()
   399  
   400  		a.ServeHTTP(w, request)
   401  
   402  		isEqual(t, w.Code, 304, i)
   403  		isEqual(t, request.URL.Path, test.url, i)
   404  		headers := w.Header()
   405  		//t.Logf("%+v\n", headers)
   406  		isGte(t, len(headers), 1, i)
   407  		isEqual(t, headers["Cache-Control"], emptyStrings, i)
   408  		isEqual(t, headers["Content-Type"], emptyStrings, i)
   409  		isEqual(t, headers["Content-Length"], emptyStrings, i)
   410  		if strings.HasSuffix(test.path, ".gz") {
   411  			isEqual(t, headers["Content-Encoding"], []string{"gzip"}, i)
   412  			isEqual(t, headers["Vary"], []string{"Accept-Encoding"}, i)
   413  			isEqual(t, headers["Etag"], []string{"W/" + etag}, i)
   414  		} else if strings.HasSuffix(test.path, ".br") {
   415  			isEqual(t, headers["Content-Encoding"], []string{"br"}, i)
   416  			isEqual(t, headers["Vary"], []string{"Accept-Encoding"}, i)
   417  			isEqual(t, headers["Etag"], []string{"W/" + etag}, i)
   418  		} else {
   419  			isEqual(t, headers["Content-Encoding"], emptyStrings, i)
   420  			isEqual(t, headers["Vary"], emptyStrings, i)
   421  			isEqual(t, headers["Etag"], []string{etag}, i)
   422  		}
   423  	}
   424  }
   425  
   426  func TestSPA(t *testing.T) {
   427  	cases := []struct {
   428  		path, conType, response string
   429  		code                    int
   430  		headers                 map[string][]string
   431  	}{
   432  		{"/img/nonexisting", "text/html; charset=utf-8", "<html></html>", 200, map[string][]string{
   433  			"Cache-Control": {"no-store, max-age=0"},
   434  		}},
   435  		{"/", "text/html; charset=utf-8", "<html></html>", 200, map[string][]string{
   436  			"Cache-Control": {"no-store, max-age=0"},
   437  		}},
   438  		{"/index.html", "", "", 301, map[string][]string{
   439  			"Location": {"./"},
   440  		}},
   441  		{"/img/nonexisting.js", "text/plain; charset=utf-8", "404 Not found\n", 404, map[string][]string{
   442  			"Cache-Control": {"public, max-age=1"},
   443  		}},
   444  		{"/img.de/nonexisting", "text/html; charset=utf-8", "<html></html>", 200, map[string][]string{
   445  			"Cache-Control": {"no-store, max-age=0"},
   446  		}},
   447  	}
   448  	a := NewAssetHandler("./assets/").WithCacheDirective(CacheDirectivePublic).WithSPA().WithMaxAge(1 * time.Second)
   449  	for i, test := range cases {
   450  		url := mustUrl("http://localhost:8001" + test.path)
   451  		request := &http.Request{Method: "GET", URL: url}
   452  		isEqual(t, a.Spa, true, i)
   453  		w := httptest.NewRecorder()
   454  
   455  		a.ServeHTTP(w, request)
   456  		log.Printf(w.Body.String())
   457  		isEqual(t, w.Code, test.code, i)
   458  		isEqual(t, w.Header().Get("Content-Type"), test.conType, i)
   459  		isEqual(t, w.Body.String(), test.response, i)
   460  
   461  		if test.headers != nil {
   462  			headers := w.Header()
   463  			for header, strings := range test.headers {
   464  				isEqual(t, headers[header], strings, i)
   465  			}
   466  		}
   467  	}
   468  }
   469  
   470  func TestCacheDirectives(t *testing.T) {
   471  	cases := []struct {
   472  		handler      *Assets
   473  		path         string
   474  		cacheControl string
   475  	}{
   476  		// No duration set
   477  		{NewAssetHandler("./assets/").WithCacheDirective(CacheDirectiveImmutable), "/css/style1.css", ""},
   478  		{NewAssetHandler("./assets/").WithMaxAge(10 * time.Second), "/css/style1.css", "public, max-age=10"},
   479  		{NewAssetHandler("./assets/").WithMaxAge(10 * time.Second).WithCacheDirective(CacheDirectivePrivate), "/css/style1.css", "private, max-age=10"},
   480  		{NewAssetHandler("./assets/").WithMaxAge(10 * time.Second).WithCacheDirective(CacheDirectiveImmutable), "/css/style1.css", "immutable, max-age=10"},
   481  	}
   482  	for i, test := range cases {
   483  		url := mustUrl("http://localhost:8081" + test.path)
   484  		request := &http.Request{Method: "GET", URL: url}
   485  		w := httptest.NewRecorder()
   486  
   487  		test.handler.ServeHTTP(w, request)
   488  		isEqual(t, w.Header().Get("Cache-Control"), test.cacheControl, i)
   489  	}
   490  }
   491  
   492  //-------------------------------------------------------------------------------------------------
   493  
   494  func Benchmark(t *testing.B) {
   495  	t.StopTimer()
   496  
   497  	cases := []struct {
   498  		strip       int
   499  		url, enc    string
   500  		sendEtagFor string
   501  		code        int
   502  	}{
   503  		{0, "css/style1.css", "gzip", "", 200},                             // has Gzip
   504  		{0, "css/style1.css", "br", "", 200},                               // has Brotli
   505  		{1, "a/css/style1.css", "gzip", "", 200},                           // has Gzip
   506  		{1, "a/css/style1.css", "br", "", 200},                             // has Brotli
   507  		{2, "a/b/css/style1.css", "gzip", "", 200},                         // has Gzip
   508  		{2, "a/b/css/style1.css", "br", "", 200},                           // has Brotli
   509  		{2, "a/b/css/style1.css", "xxxx", "", 200},                         // has Gzip
   510  		{2, "a/b/css/style1.css", "gzip", "assets/css/style1.css.gz", 304}, // has Gzip
   511  		{2, "a/b/css/style1.css", "br", "assets/css/style1.css.br", 304},   // has Brotli
   512  		{2, "a/b/css/style1.css", "xxxx", "assets/css/style1.css", 304},    // has Gzip
   513  
   514  		{2, "a/b/css/style2.css", "gzip", "", 200},
   515  		{2, "a/b/css/style2.css", "xxxx", "", 200},
   516  		{2, "a/b/css/style2.css", "gzip", "assets/css/style2.css", 304},
   517  		{2, "a/a/css/style2.css", "xxxx", "assets/css/style2.css", 304},
   518  
   519  		{2, "a/b/js/script1.js", "gzip", "", 200},                        // has gzip
   520  		{2, "a/b/js/script1.js", "br", "", 200},                          // has Brotli
   521  		{2, "a/b/js/script1.js", "xxxx", "", 200},                        // has gzip
   522  		{2, "a/b/js/script1.js", "gzip", "assets/js/script1.js.gz", 304}, // has gzip
   523  		{2, "a/b/js/script1.js", "br", "assets/js/script1.js.br", 304},   // has Brotli
   524  		{2, "a/a/js/script1.js", "xxxx", "assets/js/script1.js", 304},    // has gzip
   525  
   526  		{2, "a/b/js/script2.js", "gzip", "", 200},
   527  		{2, "a/b/js/script2.js", "xxxx", "", 200},
   528  		{2, "a/b/js/script2.js", "gzip", "assets/js/script2.js", 304},
   529  		{2, "a/a/js/script2.js", "xxxx", "assets/js/script2.js", 304},
   530  
   531  		{2, "a/b/img/sort_asc.png", "gzip", "", 200},
   532  		{2, "a/b/img/sort_asc.png", "xxxx", "", 200},
   533  		{2, "a/b/img/sort_asc.png", "gzip", "assets/img/sort_asc.png", 304},
   534  		{2, "a/a/img/sort_asc.png", "xxxx", "assets/img/sort_asc.png", 304},
   535  
   536  		{2, "a/b/img/nonexisting.png", "gzip", "", 404},
   537  		{2, "a/b/img/nonexisting.png", "xxxx", "", 404},
   538  	}
   539  
   540  	ages := []time.Duration{0, time.Hour}
   541  
   542  	for _, test := range cases {
   543  		header := newHeader("Accept-Encoding", test.enc)
   544  		etagOn := "no-etag"
   545  		if test.sendEtagFor != "" {
   546  			header = newHeader("Accept-Encoding", test.enc, "If-None-Match", etagFor(test.sendEtagFor))
   547  			etagOn = "etag"
   548  		}
   549  
   550  		for _, age := range ages {
   551  			a := NewAssetHandler("./assets/").WithCacheDirective(CacheDirectivePublic).StripOff(test.strip).WithMaxAge(age)
   552  
   553  			t.Run(fmt.Sprintf("%s~%s~%v~%d~%v", test.url, test.enc, etagOn, test.code, age), func(b *testing.B) {
   554  				b.StopTimer()
   555  
   556  				for i := 0; i < b.N; i++ {
   557  					url := mustUrl("/" + test.url)
   558  					request := &http.Request{Method: "GET", URL: url, Header: header}
   559  					w := httptest.NewRecorder()
   560  
   561  					b.StartTimer()
   562  					a.ServeHTTP(w, request)
   563  					b.StopTimer()
   564  
   565  					if w.Code != test.code {
   566  						b.Fatalf("Expected %d but got %d", test.code, w.Code)
   567  					}
   568  				}
   569  			})
   570  		}
   571  	}
   572  }
   573  
   574  //-------------------------------------------------------------------------------------------------
   575  
   576  func isEqual(t *testing.T, a, b, hint interface{}) {
   577  	t.Helper()
   578  	if !reflect.DeepEqual(a, b) {
   579  		t.Errorf("Got %#v; expected %#v - for %v\n", a, b, hint)
   580  	}
   581  }
   582  
   583  func isNotEqual(t *testing.T, a, b, hint interface{}) {
   584  	t.Helper()
   585  	if reflect.DeepEqual(a, b) {
   586  		t.Errorf("Got %#v; expected something else - for %v\n", a, hint)
   587  	}
   588  }
   589  
   590  func isGte(t *testing.T, a, b int, hint interface{}) {
   591  	t.Helper()
   592  	if a < b {
   593  		t.Errorf("Got %d; expected at least %d - for %v\n", a, b, hint)
   594  	}
   595  }
   596  
   597  func mustUrl(s string) *URL {
   598  	parsed, err := Parse(s)
   599  	must(err)
   600  	return parsed
   601  }
   602  
   603  func newHeader(kv ...string) http.Header {
   604  	header := make(http.Header)
   605  	for i, x := range kv {
   606  		if i%2 == 0 {
   607  			header[x] = []string{kv[i+1]}
   608  		}
   609  	}
   610  	return header
   611  }
   612  
   613  // must abort the program on error, printing a stack trace.
   614  func must(err error) {
   615  	if err != nil {
   616  		panic(err)
   617  	}
   618  }
   619  
   620  func mustStat(name string) os.FileInfo {
   621  	d, err := os.Stat(name)
   622  	if err != nil {
   623  		panic(err)
   624  	}
   625  	return d
   626  }
   627  
   628  func etagFor(name string) string {
   629  	d := mustStat(name)
   630  	t := ""
   631  	return fmt.Sprintf(`%s"%x-%x"`, t, d.ModTime().Unix(), d.Size())
   632  }
   633  
   634  //-------------------------------------------------------------------------------------------------
   635  
   636  type fs403 struct {
   637  	err error
   638  }
   639  
   640  func (fs fs403) Create(name string) (afero.File, error) {
   641  	return nil, fs.err
   642  }
   643  
   644  func (fs fs403) Mkdir(name string, perm os.FileMode) error {
   645  	return fs.err
   646  }
   647  
   648  func (fs fs403) MkdirAll(path string, perm os.FileMode) error {
   649  	return fs.err
   650  }
   651  
   652  func (fs fs403) Open(name string) (afero.File, error) {
   653  	return nil, fs.err
   654  }
   655  
   656  func (fs fs403) OpenFile(name string, flag int, perm os.FileMode) (afero.File, error) {
   657  	return nil, fs.err
   658  }
   659  
   660  func (fs fs403) Remove(name string) error {
   661  	return fs.err
   662  }
   663  
   664  func (fs fs403) RemoveAll(path string) error {
   665  	return fs.err
   666  }
   667  
   668  func (fs fs403) Rename(oldname, newname string) error {
   669  	return fs.err
   670  }
   671  
   672  func (fs fs403) Stat(name string) (os.FileInfo, error) {
   673  	return nil, fs.err
   674  }
   675  
   676  func (fs403) Name() string {
   677  	return "dumb"
   678  }
   679  
   680  func (fs fs403) Chmod(name string, mode os.FileMode) error {
   681  	return fs.err
   682  }
   683  
   684  func (fs fs403) Chown(name string, uid, gid int) error {
   685  	return fs.err
   686  }
   687  
   688  func (fs fs403) Chtimes(name string, atime time.Time, mtime time.Time) error {
   689  	return fs.err
   690  }