package assets_test import ( "embed" "io/fs" "net/http" "net/http/httptest" "os" "path/filepath" "testing" "testing/fstest" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sourcecraft.dev/bigbes/sr-ht-ecore/assets" ) // staticFS stands in for the embedded static tree of a real service: a hashed // stylesheet, a hashed module bundle and an unhashed logo, which is exactly the // mix the cache policy has to split. // //go:embed testdata/static var staticFS embed.FS const cssGlob = "testdata/static/main.min.*.css" // staticSub is the tree a service mounts: fs.Sub of the embed, so a request for // /static/main.min.0badc0de.css names "main.min.0badc0de.css" in it. func staticSub(t *testing.T) fs.FS { t.Helper() sub, err := fs.Sub(staticFS, "testdata/static") require.NoError(t, err) return sub } // dirFS writes the same three files to a temporary directory and returns // os.DirFS over it — dolt's shape, whose static tree lives on disk and is // configured rather than embedded. func dirFS(t *testing.T) fs.FS { t.Helper() dir := t.TempDir() for _, name := range []string{"main.min.0badc0de.css", "bundle.0badc0de.mjs", "logo.svg"} { body, err := staticFS.ReadFile("testdata/static/" + name) require.NoError(t, err) require.NoError(t, os.WriteFile(filepath.Join(dir, name), body, 0o600)) } return os.DirFS(dir) } func TestResolveFindsTheHashedAssetInAnEmbeddedTree(t *testing.T) { href, err := assets.Resolve(staticFS, cssGlob, assets.DefaultPrefix) require.NoError(t, err) assert.Equal(t, "/static/main.min.0badc0de.css", href) assert.True(t, assets.IsHashed(href), "a hashed asset is cacheable forever") } func TestResolveFindsTheHashedAssetOnDisk(t *testing.T) { // dolt configures a directory rather than embedding one, so the glob has no // "static/" component in it and the URL prefix is the only thing that puts // the asset under /static/. Both spellings must produce the same href. href, err := assets.Resolve(dirFS(t), "main.min.*.css", assets.DefaultPrefix) require.NoError(t, err) assert.Equal(t, "/static/main.min.0badc0de.css", href) } func TestResolveAnswersEmptyWhenTheAssetWasNeverBuilt(t *testing.T) { // The hashed CSS is a build product, so this is the state of every checkout: // Resolve answers "" without an error and the layout renders no at // all rather than one pointing at a file nothing will serve. href, err := assets.Resolve(fstest.MapFS{}, cssGlob, assets.DefaultPrefix) require.NoError(t, err) assert.Empty(t, href) } func TestResolveTakesTheUrlPrefixAsGiven(t *testing.T) { fsys := fstest.MapFS{ "static/main.min.deadbeef.css": &fstest.MapFile{Data: []byte("body{}")}, } for prefix, want := range map[string]string{ "": "/static/main.min.deadbeef.css", "/static": "/static/main.min.deadbeef.css", "/static/": "/static/main.min.deadbeef.css", "assets": "/assets/main.min.deadbeef.css", "/a/b/": "/a/b/main.min.deadbeef.css", } { href, err := assets.Resolve(fsys, "static/main.min.*.css", prefix) require.NoError(t, err, prefix) assert.Equal(t, want, href, prefix) } } func TestResolveReportsAMalformedGlob(t *testing.T) { _, err := assets.Resolve(fstest.MapFS{}, "static/[", assets.DefaultPrefix) require.Error(t, err) assert.Contains(t, err.Error(), "assets: glob") } func TestIsHashedRecognisesTheWholeAssetFamily(t *testing.T) { for _, name := range []string{ "main.min.0badc0de.css", "bundle.0badc0de.mjs", "uplot.iife.min.0badc0dedeadbeef.js", "/static/main.min.0badc0de.css", } { assert.True(t, assets.IsHashed(name), name) } for _, name := range []string{ "main.css", // the dev build, whose bytes change under the name "main.min.abc123.css", // six hex digits is a version, not a digest "logo.svg", // not a build product of the css/js family "main.min.0badc0de.svg", "main.min.0badc0de.css.map", } { assert.False(t, assets.IsHashed(name), name) } } func TestCacheControlSplitsOnTheHash(t *testing.T) { // The split is the whole policy: a name that changes with the bytes may be // kept forever, a name that does not gets an hour so a replacement is not // stuck in caches until a rotation that will never come. assert.Equal(t, "public, max-age=31536000, immutable", assets.CacheControl("main.min.0badc0de.css")) assert.Equal(t, "public, max-age=31536000, immutable", assets.CacheControl("bundle.0badc0de.mjs")) assert.Equal(t, "public, max-age=3600", assets.CacheControl("logo.svg")) assert.Equal(t, "public, max-age=3600", assets.CacheControl("main.css")) } func TestLookupRefusesWhatIsNotAFileUnderThePrefix(t *testing.T) { fsys := staticSub(t) for _, urlPath := range []string{ "/staticky/main.min.0badc0de.css", // a prefix that only looks like ours "/static", // the mount point itself "/static/", // the directory, i.e. a listing "/static/../assets.go", // traversal "/static//main.min.0badc0de.css", // an empty path element io/fs rejects "/static/nothing.css", } { _, ok := assets.Lookup(fsys, assets.DefaultPrefix, urlPath) assert.False(t, ok, urlPath) } name, ok := assets.Lookup(fsys, assets.DefaultPrefix, "/static/main.min.0badc0de.css") assert.True(t, ok) assert.Equal(t, "main.min.0badc0de.css", name) } func TestHandlerServesAHashedAssetImmutableAndDropsVary(t *testing.T) { h := assets.Handler(staticSub(t), assets.DefaultPrefix, nil) w := httptest.NewRecorder() // The Vary a page middleware would have set before the route was reached: an // asset served identically to everybody but declared to vary on Cookie is // one no shared cache will reuse. w.Header().Set("Vary", "Cookie") h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/static/main.min.0badc0de.css", nil)) require.Equal(t, http.StatusOK, w.Code) assert.Equal(t, "public, max-age=31536000, immutable", w.Header().Get("Cache-Control")) assert.Empty(t, w.Header().Get("Vary")) assert.Contains(t, w.Body.String(), "margin:0") } func TestHandlerServesAnUnhashedAssetForAnHour(t *testing.T) { h := assets.Handler(staticSub(t), assets.DefaultPrefix, nil) w := httptest.NewRecorder() h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/static/logo.svg", nil)) require.Equal(t, http.StatusOK, w.Code) assert.Equal(t, "public, max-age=3600", w.Header().Get("Cache-Control")) } func TestHandlerTypesAModuleScriptItself(t *testing.T) { // The host's mime tables may not know ".mjs" at all, and a browser refuses // to execute a module script whose type is not a JavaScript MIME type — so // the same binary would render a working page on one image and a broken one // on the next if this were left to mime.TypeByExtension. h := assets.Handler(staticSub(t), assets.DefaultPrefix, nil) w := httptest.NewRecorder() h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/static/bundle.0badc0de.mjs", nil)) require.Equal(t, http.StatusOK, w.Code) assert.Equal(t, "text/javascript; charset=utf-8", w.Header().Get("Content-Type")) assert.Equal(t, "public, max-age=31536000, immutable", w.Header().Get("Cache-Control")) } func TestHandlerServesAnOnDiskTreeTheSameWay(t *testing.T) { h := assets.Handler(dirFS(t), assets.DefaultPrefix, nil) w := httptest.NewRecorder() h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/static/main.min.0badc0de.css", nil)) require.Equal(t, http.StatusOK, w.Code) assert.Equal(t, "public, max-age=31536000, immutable", w.Header().Get("Cache-Control")) } func TestHandlerNeverPublishesAListing(t *testing.T) { // /static/ under http.FileServer is the inventory of the binary — every // vendored bundle and the hashed stylesheet name, which is a build // fingerprint nothing else on the surface discloses. h := assets.Handler(staticSub(t), assets.DefaultPrefix, nil) w := httptest.NewRecorder() h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/static/", nil)) require.Equal(t, http.StatusNotFound, w.Code) assert.NotContains(t, w.Body.String(), "main.min.0badc0de.css") } func TestHandlerDelegatesEverythingElseToNotFound(t *testing.T) { // This is where a service passes its chrome-wrapped 404, so an asset URL // typed by hand answers with a page that has a nav to get out of. notFound := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte("")) }) h := assets.Handler(staticSub(t), assets.DefaultPrefix, notFound) w := httptest.NewRecorder() h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/static/nothing.css", nil)) require.Equal(t, http.StatusNotFound, w.Code) assert.Contains(t, w.Body.String(), "the chrome 404") } func TestAnAnswerThatIsNotAnAssetKeepsThePagePolicy(t *testing.T) { // The reason the cache directives are stamped at commit time rather than // before delegating: the header map outlives the decision. A 404 rendered as // a page — with a viewer's login block in it — must not inherit the public, // hour-long policy of the asset that was never served. notFound := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) }) h := assets.Handler(staticSub(t), assets.DefaultPrefix, notFound) w := httptest.NewRecorder() w.Header().Set("Cache-Control", "private, no-store") w.Header().Set("Vary", "Cookie") h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/static/nothing.css", nil)) require.Equal(t, http.StatusNotFound, w.Code) assert.Equal(t, "private, no-store", w.Header().Get("Cache-Control")) assert.Equal(t, "Cookie", w.Header().Get("Vary")) } func TestHandlerStampsAConditionalRequestToo(t *testing.T) { // A 304 describes the same public bytes as the 200 it replaces, so it // carries the same policy — a cache that revalidated would otherwise lose // the lifetime it revalidated for. // // The on-disk tree and not the embedded one: files in an embed.FS have a // zero modification time, so net/http emits no Last-Modified for them and a // conditional request is unanswerable in the first place. h := assets.Handler(dirFS(t), assets.DefaultPrefix, nil) w := httptest.NewRecorder() h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/static/main.min.0badc0de.css", nil)) require.Equal(t, http.StatusOK, w.Code) lastModified := w.Header().Get("Last-Modified") require.NotEmpty(t, lastModified) r := httptest.NewRequest(http.MethodGet, "/static/main.min.0badc0de.css", nil) r.Header.Set("If-Modified-Since", lastModified) w = httptest.NewRecorder() h.ServeHTTP(w, r) require.Equal(t, http.StatusNotModified, w.Code) assert.Equal(t, "public, max-age=31536000, immutable", w.Header().Get("Cache-Control")) } func TestNormalizePrefixIsWhatKeepsTheHrefAndTheMountTogether(t *testing.T) { for given, want := range map[string]string{ "": "/static/", "/static/": "/static/", "/static": "/static/", "static": "/static/", "assets/": "/assets/", } { assert.Equal(t, want, assets.NormalizePrefix(given), given) } } // TestDirFSRefusesToServeTheFilesystemRoot pins the one-key hole: os.DirFS("") // resolves every name against /, so an unset static directory would turn the // handler into a reader of the host. func TestDirFSRefusesToServeTheFilesystemRoot(t *testing.T) { empty := assets.DirFS("") _, err := fs.ReadFile(empty, "etc/passwd") require.Error(t, err) assert.ErrorIs(t, err, fs.ErrNotExist) names, err := fs.Glob(empty, "*") require.NoError(t, err) assert.Empty(t, names, "nothing exists in it, so a glob finds nothing") // A configured directory behaves as os.DirFS does. dir := t.TempDir() require.NoError(t, os.WriteFile(dir+"/main.min.0badc0de.css", []byte("body{}"), 0o644)) got, err := fs.ReadFile(assets.DirFS(dir), "main.min.0badc0de.css") require.NoError(t, err) assert.Equal(t, "body{}", string(got)) }