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