github.com/pzeinlinger/servefiles@v3.1.0+incompatible/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 "net/http" 28 "net/http/httptest" 29 . "net/url" 30 "os" 31 "reflect" 32 "strings" 33 "testing" 34 "time" 35 "github.com/spf13/afero" 36 ) 37 38 var emptyStrings []string 39 40 func mustChdir(dir string) { 41 err := os.Chdir(dir) 42 if err != nil { 43 panic(err) 44 } 45 } 46 47 func init() { 48 mustChdir("test") 49 } 50 51 func TestChooseResourceSimpleNoGzip(t *testing.T) { 52 cases := []struct { 53 n int 54 maxAge time.Duration 55 url, path, cacheControl string 56 }{ 57 {0, 1, "http://localhost:8001/img/sort_asc.png", "assets/img/sort_asc.png", "public, maxAge=1"}, 58 {0, 3671, "http://localhost:8001/img/sort_asc.png", "assets/img/sort_asc.png", "public, maxAge=3671"}, 59 {3, 3671, "http://localhost:8001/x/y/z/img/sort_asc.png", "assets/img/sort_asc.png", "public, maxAge=3671"}, 60 } 61 62 for i, test := range cases { 63 etag := etagFor(test.path) 64 url := mustUrl(test.url) 65 request := &http.Request{Method: "GET", URL: url} 66 a := NewAssetHandler("./assets/").StripOff(test.n).WithMaxAge(test.maxAge * time.Second) 67 w := httptest.NewRecorder() 68 69 a.ServeHTTP(w, request) 70 71 isEqual(t, w.Code, 200, i) 72 //isEqual(t, message, "", test.path) 73 isEqual(t, len(w.Header()["Expires"]), 1, i) 74 isGte(t, len(w.Header()["Expires"][0]), 25, i) 75 //fmt.Println(headers["Expires"]) 76 isEqual(t, w.Header()["Cache-Control"], []string{test.cacheControl}, i) 77 isEqual(t, w.Header()["Etag"], []string{etag}, i) 78 isEqual(t, w.Body.Len(), 160, i) 79 } 80 } 81 82 func TestChooseResourceSimpleNonExistent(t *testing.T) { 83 cases := []struct { 84 n int 85 maxAge time.Duration 86 url string 87 }{ 88 {0, time.Second, "http://localhost:8001/img/nonexisting.png"}, 89 {1, time.Second, "http://localhost:8001/a/img/nonexisting.png"}, 90 {2, time.Second, "http://localhost:8001/a/b/img/nonexisting.png"}, 91 } 92 93 for i, test := range cases { 94 url := mustUrl(test.url) 95 request := &http.Request{Method: "GET", URL: url} 96 a := NewAssetHandler("./assets/").StripOff(test.n).WithMaxAge(test.maxAge) 97 w := httptest.NewRecorder() 98 99 a.ServeHTTP(w, request) 100 101 isEqual(t, w.Code, 404, i) 102 //t.Logf("header %v", w.Header()) 103 isGte(t, len(w.Header()), 4, i) 104 isEqual(t, w.Header().Get("Content-Type"), "text/plain; charset=utf-8", i) 105 isEqual(t, w.Header().Get("Cache-Control"), "public, maxAge=1", i) 106 isGte(t, len(w.Header().Get("Expires")), 25, i) 107 } 108 } 109 110 func TestServeHTTP200WithGzipAndGzipWithAcceptHeader(t *testing.T) { 111 cases := []struct { 112 n int 113 maxAge time.Duration 114 url, mime, path, cacheControl string 115 }{ 116 {0, 1, "http://localhost:8001/css/style1.css", "text/css; charset=utf-8", "assets/css/style1.css.gz", "public, maxAge=1"}, 117 {2, 1, "http://localhost:8001/a/b/css/style1.css", "text/css; charset=utf-8", "assets/css/style1.css.gz", "public, maxAge=1"}, 118 {0, 1, "http://localhost:8001/js/script1.js", "application/javascript", "assets/js/script1.js.gz", "public, maxAge=1"}, 119 {2, 1, "http://localhost:8001/a/b/js/script1.js", "application/javascript", "assets/js/script1.js.gz", "public, maxAge=1"}, 120 } 121 122 for _, test := range cases { 123 etag := etagFor(test.path) 124 url := mustUrl(test.url) 125 header := newHeader("Accept-Encoding", "xxxx, gzip, zzz") 126 request := &http.Request{Method: "GET", URL: url, Header: header} 127 a := NewAssetHandler("./assets/").StripOff(test.n).WithMaxAge(test.maxAge * time.Second) 128 w := httptest.NewRecorder() 129 130 a.ServeHTTP(w, request) 131 132 isEqual(t, w.Code, 200, test.path) 133 headers := w.Header() 134 //t.Logf("%+v\n", headers) 135 isGte(t, len(headers), 7, test.path) 136 isEqual(t, headers["Cache-Control"], []string{test.cacheControl}, test.path) 137 isEqual(t, headers["Content-Type"], []string{test.mime}, test.path) 138 isEqual(t, headers["X-Content-Type-Options"], []string{"nosniff"}, test.path) 139 isEqual(t, headers["Content-Encoding"], []string{"gzip"}, test.path) 140 isEqual(t, headers["Vary"], []string{"Accept-Encoding"}, test.path) 141 isEqual(t, headers["Etag"], []string{etag}, test.path) 142 isEqual(t, len(headers["Expires"]), 1, test.path) 143 isGte(t, len(headers["Expires"][0]), 25, test.path) 144 } 145 } 146 147 func TestServeHTTP200WithGzipButNoAcceptHeader(t *testing.T) { 148 cases := []struct { 149 n int 150 maxAge time.Duration 151 url, mime, path, cacheControl string 152 }{ 153 {0, 1, "http://localhost:8001/css/style1.css", "text/css; charset=utf-8", "assets/css/style1.css", "public, maxAge=1"}, 154 {2, 2, "http://localhost:8001/a/b/css/style1.css", "text/css; charset=utf-8", "assets/css/style1.css", "public, maxAge=2"}, 155 {0, 3, "http://localhost:8001/js/script1.js", "application/javascript", "assets/js/script1.js", "public, maxAge=3"}, 156 {2, 4, "http://localhost:8001/a/b/js/script1.js", "application/javascript", "assets/js/script1.js", "public, maxAge=4"}, 157 } 158 159 for _, test := range cases { 160 etag := etagFor(test.path) 161 url := mustUrl(test.url) 162 header := newHeader("Accept-Encoding", "xxxx, yyy, zzz") 163 request := &http.Request{Method: "GET", URL: url, Header: header} 164 a := NewAssetHandler("./assets/").StripOff(test.n).WithMaxAge(test.maxAge * time.Second) 165 w := httptest.NewRecorder() 166 167 a.ServeHTTP(w, request) 168 169 isEqual(t, w.Code, 200, test.path) 170 headers := w.Header() 171 //t.Logf("%+v\n", headers) 172 isGte(t, len(headers), 6, test.path) 173 isEqual(t, headers["Cache-Control"], []string{test.cacheControl}, test.path) 174 isEqual(t, headers["Content-Type"], []string{test.mime}, test.path) 175 isEqual(t, headers["Content-Encoding"], emptyStrings, test.path) 176 isEqual(t, headers["Vary"], emptyStrings, test.path) 177 isEqual(t, headers["Etag"], []string{etag}, test.path) 178 isEqual(t, len(headers["Expires"]), 1, test.path) 179 isGte(t, len(headers["Expires"][0]), 25, test.path) 180 } 181 } 182 183 func TestServeHTTP200WithGzipAcceptHeaderButNoGzippedFile(t *testing.T) { 184 cases := []struct { 185 n int 186 maxAge time.Duration 187 url, mime, path, cacheControl string 188 }{ 189 {0, 1, "http://localhost:8001/css/style2.css", "text/css; charset=utf-8", "assets/css/style2.css", "public, maxAge=1"}, 190 {2, 2, "http://localhost:8001/a/b/css/style2.css", "text/css; charset=utf-8", "assets/css/style2.css", "public, maxAge=2"}, 191 {0, 3, "http://localhost:8001/js/script2.js", "application/javascript", "assets/js/script2.js", "public, maxAge=3"}, 192 {2, 4, "http://localhost:8001/a/b/js/script2.js", "application/javascript", "assets/js/script2.js", "public, maxAge=4"}, 193 {0, 5, "http://localhost:8001/img/sort_asc.png", "image/png", "assets/img/sort_asc.png", "public, maxAge=5"}, 194 {2, 6, "http://localhost:8001/a/b/img/sort_asc.png", "image/png", "assets/img/sort_asc.png", "public, maxAge=6"}, 195 } 196 197 for _, test := range cases { 198 etag := etagFor(test.path) 199 url := mustUrl(test.url) 200 header := newHeader("Accept-Encoding", "xxxx, gzip, zzz") 201 request := &http.Request{Method: "GET", URL: url, Header: header} 202 a := NewAssetHandler("./assets/").StripOff(test.n).WithMaxAge(test.maxAge * time.Second) 203 w := httptest.NewRecorder() 204 205 a.ServeHTTP(w, request) 206 207 isEqual(t, w.Code, 200, test.path) 208 headers := w.Header() 209 //t.Logf("%+v\n", headers) 210 isGte(t, len(headers), 6, test.path) 211 isEqual(t, headers["Cache-Control"], []string{test.cacheControl}, test.path) 212 isEqual(t, headers["Content-Type"], []string{test.mime}, test.path) 213 isEqual(t, headers["Content-Encoding"], emptyStrings, test.path) 214 isEqual(t, headers["Vary"], emptyStrings, test.path) 215 isEqual(t, headers["Etag"], []string{etag}, test.path) 216 isEqual(t, len(headers["Expires"]), 1, test.path) 217 isGte(t, len(headers["Expires"][0]), 25, test.path) 218 } 219 } 220 221 //------------------------------------------------------------------------------------------------- 222 223 type h404 struct{} 224 225 func (h *h404) ServeHTTP(w http.ResponseWriter, r *http.Request) { 226 w.Header().Set("Content-Type", "text/html") 227 w.WriteHeader(404) 228 w.Write([]byte("<html>foo</html>")) 229 } 230 231 func Test404Handler(t *testing.T) { 232 cases := []struct { 233 path, conType, response string 234 notFound http.Handler 235 }{ 236 {"/img/nonexisting.png", "text/plain; charset=utf-8", "404 Not found\n", nil}, 237 {"/img/nonexisting.png", "text/html", "<html>foo</html>", &h404{}}, 238 } 239 240 for i, test := range cases { 241 url := mustUrl("http://localhost:8001" + test.path) 242 request := &http.Request{Method: "GET", URL: url} 243 a := NewAssetHandler("./assets/").WithNotFound(test.notFound) 244 isEqual(t, a.NotFound, test.notFound, i) 245 w := httptest.NewRecorder() 246 247 a.ServeHTTP(w, request) 248 249 isEqual(t, w.Code, 404, i) 250 isEqual(t, w.Header().Get("Content-Type"), test.conType, i) 251 isEqual(t, w.Body.String(), test.response, i) 252 } 253 } 254 255 func Test403Handling(t *testing.T) { 256 cases := []struct { 257 path string 258 header http.Header 259 }{ 260 {"http://localhost:8001/css/style1.css", newHeader()}, 261 {"http://localhost:8001/css/style1.css", newHeader("Accept-Encoding", "gzip")}, 262 } 263 264 for i, test := range cases { 265 url := mustUrl("http://localhost:8001" + test.path) 266 request := &http.Request{Method: "GET", URL: url, Header: test.header} 267 a := NewAssetHandlerFS(&fs403{os.ErrPermission}) 268 w := httptest.NewRecorder() 269 270 a.ServeHTTP(w, request) 271 272 isEqual(t, w.Code, 403, i) 273 isEqual(t, w.Header().Get("Content-Type"), "text/plain; charset=utf-8", i) 274 isEqual(t, w.Body.String(), "403 Forbidden\n", i) 275 } 276 } 277 278 func Test503Handling(t *testing.T) { 279 cases := []struct { 280 path string 281 header http.Header 282 }{ 283 {"http://localhost:8001/css/style1.css", newHeader()}, 284 {"http://localhost:8001/css/style1.css", newHeader("Accept-Encoding", "gzip")}, 285 } 286 287 for i, test := range cases { 288 url := mustUrl("http://localhost:8001" + test.path) 289 request := &http.Request{Method: "GET", URL: url, Header: test.header} 290 a := NewAssetHandlerFS(&fs403{os.ErrInvalid}) 291 w := httptest.NewRecorder() 292 293 a.ServeHTTP(w, request) 294 295 isEqual(t, w.Code, 503, i) 296 isEqual(t, w.Header().Get("Content-Type"), "text/plain; charset=utf-8", i) 297 isNotEqual(t, w.Header().Get("Retry-After"), "", i) 298 isEqual(t, w.Body.String(), "503 Service unavailable\n", i) 299 } 300 } 301 302 //------------------------------------------------------------------------------------------------- 303 304 func TestServeHTTP304(t *testing.T) { 305 cases := []struct { 306 url, path string 307 notFound http.Handler 308 }{ 309 {"http://localhost:8001/css/style1.css", "assets/css/style1.css.gz", nil}, 310 {"http://localhost:8001/css/style2.css", "assets/css/style2.css", nil}, 311 {"http://localhost:8001/img/sort_asc.png", "assets/img/sort_asc.png", nil}, 312 {"http://localhost:8001/js/script1.js", "assets/js/script1.js.gz", nil}, 313 {"http://localhost:8001/js/script2.js", "assets/js/script2.js", nil}, 314 315 {"http://localhost:8001/css/style1.css", "assets/css/style1.css.gz", &h404{}}, 316 {"http://localhost:8001/css/style2.css", "assets/css/style2.css", &h404{}}, 317 {"http://localhost:8001/img/sort_asc.png", "assets/img/sort_asc.png", &h404{}}, 318 {"http://localhost:8001/js/script1.js", "assets/js/script1.js.gz", &h404{}}, 319 {"http://localhost:8001/js/script2.js", "assets/js/script2.js", &h404{}}, 320 } 321 322 // net/http serveFiles handles conditional requests according to RFC723x specs. 323 // So we only need to check that a conditional request is correctly wired in. 324 325 for i, test := range cases { 326 etag := etagFor(test.path) 327 url := mustUrl(test.url) 328 header := newHeader("Accept-Encoding", "gzip", "If-None-Match", etag) 329 request := &http.Request{Method: "GET", URL: url, Header: header} 330 a := NewAssetHandler("./assets/").WithNotFound(test.notFound) 331 w := httptest.NewRecorder() 332 333 a.ServeHTTP(w, request) 334 335 isEqual(t, w.Code, 304, i) 336 headers := w.Header() 337 //t.Logf("%+v\n", headers) 338 isGte(t, len(headers), 1, i) 339 isEqual(t, headers["Cache-Control"], emptyStrings, i) 340 isEqual(t, headers["Content-Type"], emptyStrings, i) 341 isEqual(t, headers["Content-Length"], emptyStrings, i) 342 if strings.HasSuffix(test.path, ".gz") { 343 isEqual(t, headers["Content-Encoding"], []string{"gzip"}, i) 344 isEqual(t, headers["Vary"], []string{"Accept-Encoding"}, i) 345 isEqual(t, headers["Etag"], []string{etag}, i) 346 } else { 347 isEqual(t, headers["Content-Encoding"], emptyStrings, i) 348 isEqual(t, headers["Vary"], emptyStrings, i) 349 isEqual(t, headers["Etag"], []string{etag}, i) 350 } 351 } 352 } 353 354 //------------------------------------------------------------------------------------------------- 355 356 func Benchmark(t *testing.B) { 357 t.StopTimer() 358 359 cases := []struct { 360 strip int 361 url, enc string 362 sendEtagFor string 363 code int 364 }{ 365 {0, "css/style1.css", "gzip", "", 200}, // has Gzip 366 {1, "a/css/style1.css", "gzip", "", 200}, // has Gzip 367 {2, "a/b/css/style1.css", "gzip", "", 200}, // has Gzip 368 {2, "a/b/css/style1.css", "xxxx", "", 200}, // has Gzip 369 {2, "a/b/css/style1.css", "gzip", "assets/css/style1.css.gz", 304}, // has Gzip 370 {2, "a/b/css/style1.css", "xxxx", "assets/css/style1.css", 304}, // has Gzip 371 372 {2, "a/b/css/style2.css", "gzip", "", 200}, 373 {2, "a/b/css/style2.css", "xxxx", "", 200}, 374 {2, "a/b/css/style2.css", "gzip", "assets/css/style2.css", 304}, 375 {2, "a/a/css/style2.css", "xxxx", "assets/css/style2.css", 304}, 376 377 {2, "a/b/js/script1.js", "gzip", "", 200}, // has gzip 378 {2, "a/b/js/script1.js", "xxxx", "", 200}, // has gzip 379 {2, "a/b/js/script1.js", "gzip", "assets/js/script1.js.gz", 304}, // has gzip 380 {2, "a/a/js/script1.js", "xxxx", "assets/js/script1.js", 304}, // has gzip 381 382 {2, "a/b/js/script2.js", "gzip", "", 200}, 383 {2, "a/b/js/script2.js", "xxxx", "", 200}, 384 {2, "a/b/js/script2.js", "gzip", "assets/js/script2.js", 304}, 385 {2, "a/a/js/script2.js", "xxxx", "assets/js/script2.js", 304}, 386 387 {2, "a/b/img/sort_asc.png", "gzip", "", 200}, 388 {2, "a/b/img/sort_asc.png", "xxxx", "", 200}, 389 {2, "a/b/img/sort_asc.png", "gzip", "assets/img/sort_asc.png", 304}, 390 {2, "a/a/img/sort_asc.png", "xxxx", "assets/img/sort_asc.png", 304}, 391 392 {2, "a/b/img/nonexisting.png", "gzip", "", 404}, 393 {2, "a/b/img/nonexisting.png", "xxxx", "", 404}, 394 } 395 396 ages := []time.Duration{0, time.Hour} 397 398 for _, test := range cases { 399 header := newHeader("Accept-Encoding", test.enc) 400 etagOn := "no-etag" 401 if test.sendEtagFor != "" { 402 header = newHeader("Accept-Encoding", test.enc, "If-None-Match", etagFor(test.sendEtagFor)) 403 etagOn = "etag" 404 } 405 406 for _, age := range ages { 407 a := NewAssetHandler("./assets/").StripOff(test.strip).WithMaxAge(age) 408 409 t.Run(fmt.Sprintf("%s~%s~%v~%d~%v", test.url, test.enc, etagOn, test.code, age), func(b *testing.B) { 410 b.StopTimer() 411 412 for i := 0; i < b.N; i++ { 413 url := mustUrl("http://localhost:8001/" + test.url) 414 request := &http.Request{Method: "GET", URL: url, Header: header} 415 w := httptest.NewRecorder() 416 417 b.StartTimer() 418 a.ServeHTTP(w, request) 419 b.StopTimer() 420 421 if w.Code != test.code { 422 b.Fatalf("Expected %d but got %d", test.code, w.Code) 423 } 424 } 425 }) 426 } 427 } 428 } 429 430 //------------------------------------------------------------------------------------------------- 431 432 func isEqual(t *testing.T, a, b, hint interface{}) { 433 t.Helper() 434 if !reflect.DeepEqual(a, b) { 435 t.Errorf("Got %#v; expected %#v - for %v\n", a, b, hint) 436 } 437 } 438 439 func isNotEqual(t *testing.T, a, b, hint interface{}) { 440 t.Helper() 441 if reflect.DeepEqual(a, b) { 442 t.Errorf("Got %#v; expected something else - for %v\n", a, hint) 443 } 444 } 445 446 func isGte(t *testing.T, a, b int, hint interface{}) { 447 t.Helper() 448 if a < b { 449 t.Errorf("Got %d; expected at least %d - for %v\n", a, b, hint) 450 } 451 } 452 453 func mustUrl(s string) *URL { 454 parsed, err := Parse(s) 455 must(err) 456 return parsed 457 } 458 459 func newHeader(kv ...string) http.Header { 460 header := make(http.Header) 461 for i, x := range kv { 462 if i%2 == 0 { 463 header[x] = []string{kv[i+1]} 464 } 465 } 466 return header 467 } 468 469 // must abort the program on error, printing a stack trace. 470 func must(err error) { 471 if err != nil { 472 panic(err) 473 } 474 } 475 476 func mustStat(name string) os.FileInfo { 477 d, err := os.Stat(name) 478 if err != nil { 479 panic(err) 480 } 481 return d 482 } 483 484 func etagFor(name string) string { 485 d := mustStat(name) 486 t := "" 487 if strings.HasSuffix(name, ".gz") { 488 t = "W/" // weak etag 489 } 490 return fmt.Sprintf(`%s"%x-%x"`, t, d.ModTime().Unix(), d.Size()) 491 } 492 493 //------------------------------------------------------------------------------------------------- 494 495 type fs403 struct { 496 err error 497 } 498 499 func (fs fs403) Create(name string) (afero.File, error) { 500 return nil, fs.err 501 } 502 503 func (fs fs403) Mkdir(name string, perm os.FileMode) error { 504 return fs.err 505 } 506 507 func (fs fs403) MkdirAll(path string, perm os.FileMode) error { 508 return fs.err 509 } 510 511 func (fs fs403) Open(name string) (afero.File, error) { 512 return nil, fs.err 513 } 514 515 func (fs fs403) OpenFile(name string, flag int, perm os.FileMode) (afero.File, error) { 516 return nil, fs.err 517 } 518 519 func (fs fs403) Remove(name string) error { 520 return fs.err 521 } 522 523 func (fs fs403) RemoveAll(path string) error { 524 return fs.err 525 } 526 527 func (fs fs403) Rename(oldname, newname string) error { 528 return fs.err 529 } 530 531 func (fs fs403) Stat(name string) (os.FileInfo, error) { 532 return nil, fs.err 533 } 534 535 func (fs403) Name() string { 536 return "dumb" 537 } 538 539 func (fs fs403) Chmod(name string, mode os.FileMode) error { 540 return fs.err 541 } 542 543 func (fs fs403) Chtimes(name string, atime time.Time, mtime time.Time) error { 544 return fs.err 545 }