package bm import ( "net/http" "net/http/httptest" "os" "path/filepath" "regexp" "strings" "testing" "testing/fstest" _ "git.golem15.com/golem15/sm-user-plugin" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/compass" "git.golem15.com/golem15/summercms/modules/party" "git.golem15.com/golem15/summercms/modules/surf" ) const ( ctHTML = "text/html; charset=utf-8" indexBody = "
" ) // fixturePublic is a built public/ tree in miniature. func fixturePublic() fstest.MapFS { files := map[string]string{ "index.html": indexBody, "favicon.svg": "", "manifest.webmanifest": `{"name":"BM"}`, "assets/index-abc.js": "console.log(1)", "assets/index-abc.css": "body{}", "assets/font-abc.woff2": "wOF2", ".hidden": "secret", ".git/config": "secret", } fsys := fstest.MapFS{} for name, body := range files { fsys[name] = &fstest.MapFile{Data: []byte(body)} } return fsys } func handler(t *testing.T, admin string) http.Handler { t.Helper() h, err := newStaticApp(fixturePublic(), admin) if err != nil { t.Fatalf("newStaticApp: %v", err) } return h } func get(h http.Handler, method, target string, header http.Header) *httptest.ResponseRecorder { r := httptest.NewRequest(method, "/", nil) r.URL.Path = target for k, vs := range header { for _, v := range vs { r.Header.Add(k, v) } } rec := httptest.NewRecorder() h.ServeHTTP(rec, r) return rec } func TestStaticFilesAndFallback(t *testing.T) { h := handler(t, "/bmadmin") cases := []struct { path, ctype, cache, body string status int }{ {"/", ctHTML, cacheNoCache, indexBody, 200}, {"/index.html", ctHTML, cacheNoCache, indexBody, 200}, {"/favicon.svg", "image/svg+xml", cacheNoCache, "", 200}, {"/manifest.webmanifest", "application/manifest+json", cacheNoCache, `{"name":"BM"}`, 200}, {"/assets/index-abc.js", "text/javascript; charset=utf-8", cacheImmutable, "console.log(1)", 200}, {"/assets/index-abc.css", "text/css; charset=utf-8", cacheImmutable, "body{}", 200}, {"/assets/font-abc.woff2", "font/woff2", cacheImmutable, "wOF2", 200}, // Client routes fall back to the index. {"/plan", ctHTML, cacheNoCache, indexBody, 200}, {"/przedmioty/12", ctHTML, cacheNoCache, indexBody, 200}, {"/logowanie/", ctHTML, cacheNoCache, indexBody, 200}, {"/reset-password", ctHTML, cacheNoCache, indexBody, 200}, // A missing asset is a 404, never the index. {"/assets/missing.js", "text/plain; charset=utf-8", "", "404 page not found\n", 404}, {"/robots.txt", "text/plain; charset=utf-8", "", "404 page not found\n", 404}, // Dot files and traversal. {"/.hidden", "text/plain; charset=utf-8", "", "404 page not found\n", 404}, {"/.git/config", "text/plain; charset=utf-8", "", "404 page not found\n", 404}, {"/assets/../.hidden", "text/plain; charset=utf-8", "", "404 page not found\n", 404}, {`/assets\..\.hidden`, "text/plain; charset=utf-8", "", "404 page not found\n", 404}, } for _, c := range cases { t.Run(c.path, func(t *testing.T) { rec := get(h, http.MethodGet, c.path, nil) if rec.Code != c.status { t.Fatalf("status %d, want %d", rec.Code, c.status) } if got := rec.Header().Get("Content-Type"); got != c.ctype { t.Fatalf("Content-Type %q, want %q", got, c.ctype) } if got := rec.Header().Get("Cache-Control"); got != c.cache { t.Fatalf("Cache-Control %q, want %q", got, c.cache) } if rec.Body.String() != c.body { t.Fatalf("body %q, want %q", rec.Body.String(), c.body) } }) } } func TestStaticReservedPaths(t *testing.T) { h := handler(t, "/bmadmin") for _, p := range []string{ "/_user", "/_user/", "/_user/api/v1/nope", "/_bm/api/v1/nope", "/_quizzes/api/v1/nope", "/bmadmin", "/bmadmin/", "/bmadmin/login", "/storage/app/uploads/public/x.pdf", "/storage", "/_user/../_user/x", "//_bm/api", } { rec := get(h, http.MethodGet, p, nil) if rec.Code != http.StatusNotFound || strings.Contains(rec.Body.String(), "app") { t.Fatalf("GET %s = %d %q, want a plain 404", p, rec.Code, rec.Body.String()) } } // Names that merely start like a reserved prefix are client routes. for _, p := range []string{"/bmadmins", "/storage-info", "/_users"} { if rec := get(h, http.MethodGet, p, nil); rec.Code != http.StatusOK { t.Fatalf("GET %s = %d, want the fallback", p, rec.Code) } } // A custom admin mount is reserved too, the default is then a client route. h = handler(t, "/panel/") if rec := get(h, http.MethodGet, "/panel/x", nil); rec.Code != http.StatusNotFound { t.Fatalf("GET /panel/x = %d, want 404", rec.Code) } if rec := get(h, http.MethodGet, "/bmadmin", nil); rec.Code != http.StatusOK { t.Fatalf("GET /bmadmin with admin at /panel = %d, want 200", rec.Code) } } var etagForm = regexp.MustCompile(`^"[0-9a-f]{16}"$`) func TestStaticConditionalHeadAndRange(t *testing.T) { h := handler(t, "/bmadmin") for _, p := range []string{"/", "/plan", "/assets/index-abc.js"} { rec := get(h, http.MethodGet, p, nil) etag := rec.Header().Get("ETag") if !etagForm.MatchString(etag) { t.Fatalf("GET %s ETag %q", p, etag) } rec = get(h, http.MethodGet, p, http.Header{"If-None-Match": {etag}}) if rec.Code != http.StatusNotModified || rec.Body.Len() != 0 { t.Fatalf("GET %s If-None-Match = %d (%d bytes), want an empty 304", p, rec.Code, rec.Body.Len()) } if rec := get(h, http.MethodGet, p, http.Header{"If-None-Match": {`"0000000000000000"`}}); rec.Code != http.StatusOK { t.Fatalf("GET %s stale tag = %d, want 200", p, rec.Code) } } if a, b := get(h, http.MethodGet, "/", nil).Header().Get("ETag"), get(h, http.MethodGet, "/assets/index-abc.css", nil).Header().Get("ETag"); a == b { t.Fatal("different files share an ETag") } rec := get(h, http.MethodHead, "/", nil) if rec.Code != 200 || rec.Body.Len() != 0 || rec.Header().Get("Content-Length") == "" { t.Fatalf("HEAD /: %d, %d body bytes, Content-Length %q", rec.Code, rec.Body.Len(), rec.Header().Get("Content-Length")) } rec = get(h, http.MethodGet, "/assets/font-abc.woff2", http.Header{"Range": {"bytes=0-1"}}) if rec.Code != http.StatusPartialContent || rec.Body.String() != "wO" { t.Fatalf("Range = %d %q", rec.Code, rec.Body.String()) } if got := get(h, http.MethodGet, "/", nil).Header().Get("X-Content-Type-Options"); got != "nosniff" { t.Fatalf("X-Content-Type-Options = %q", got) } } func TestStaticNeedsIndex(t *testing.T) { for name, fsys := range map[string]fstest.MapFS{ "empty": {}, "no index": {"assets/a.js": {Data: []byte("x")}}, } { if _, err := newStaticApp(fsys, "/bmadmin"); err == nil || !strings.Contains(err.Error(), "index.html missing") { t.Fatalf("%s: err = %v", name, err) } } // A dot directory is skipped whole while loading. s, err := newStaticApp(fstest.MapFS{ "index.html": {Data: []byte("x")}, ".git/config": {Data: []byte("s")}, "a/.env": {Data: []byte("s")}, "a/b.txt": {Data: []byte("ok")}, }, "/bmadmin") if err != nil { t.Fatal(err) } if len(s.files) != 2 { t.Fatalf("loaded %d files, want index.html and a/b.txt", len(s.files)) } } func TestEmbeddedPublicHasIndex(t *testing.T) { h, err := newStaticHandler(nil, nil) if err != nil { t.Fatalf("the embedded public/ is not a built app: %v", err) } rec := get(h, http.MethodGet, "/plan", nil) if rec.Code != 200 || !strings.Contains(strings.ToLower(rec.Body.String()), "