From 863fd181d699d2f1fc5ef258c56a37daaf3014cf Mon Sep 17 00:00:00 2001 From: Lars Laskowski Date: Sun, 23 Aug 2026 07:36:50 +0000 Subject: [PATCH 1/2] Derive static asset ETags from content instead of the build version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Handler applied one version-derived ETag to every embedded asset. With Cache-Control: no-cache that made unversioned builds serve permanently stale assets: `make run` leaves version at "dev", so the validator never changed, and an edited app.js kept revalidating as 304 until a hard reload. It was also semantically wrong — /app.js, /style.css and the rest are distinct resources sharing one validator — and the header was set before delegating to the file server, so 404s carried it too. Hash each embedded file once at construction (SHA-256, truncated to 16 bytes) and serve that as the asset's ETag, set only for paths that name a real asset. The asset set is fixed at compile time and tiny, so this costs microseconds and no per-request work. Handler no longer needs the version string, so drop the parameter; the package is internal with a single call site. Closes #102 --- cmd/pimonitor/main.go | 2 +- docs/ARCHITECTURE.md | 20 ++++-- internal/web/embed.go | 65 ++++++++++++++--- internal/web/embed_test.go | 141 +++++++++++++++++++++++-------------- 4 files changed, 159 insertions(+), 69 deletions(-) diff --git a/cmd/pimonitor/main.go b/cmd/pimonitor/main.go index 934d02f..799f6bf 100644 --- a/cmd/pimonitor/main.go +++ b/cmd/pimonitor/main.go @@ -68,7 +68,7 @@ func run(args []string) error { } coll := collector.New(collCfg, log) - staticHandler, err := web.Handler(version) + staticHandler, err := web.Handler() if err != nil { return fmt.Errorf("load embedded web assets: %w", err) } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 502e219..67a6c8a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -284,7 +284,7 @@ server-side; it also echoes back the build-time `version` injected via ## Web dashboard (`internal/web`) -`web.Handler(version)` embeds `internal/web/assets/*` (`index.html`, `app.js`, `chart.js`, +`web.Handler()` embeds `internal/web/assets/*` (`index.html`, `app.js`, `chart.js`, `gauge.js`, `theme-init.js`, `style.css`) into the binary via `//go:embed` and serves them with `http.FileServerFS`. There is **no frontend build step** — no bundler, no npm toolchain, no framework — the assets are plain HTML/CSS/JS shipped as-is, consistent with @@ -293,11 +293,19 @@ the project's "prefer the standard library, minimize dependency surface" philoso Because embedded files carry a zero `ModTime`, `http.FileServerFS` alone would emit no `Last-Modified`/`ETag` and no `Cache-Control`, forcing a full refetch of every asset on -every page load. `Handler` wraps the file server to set `Cache-Control: no-cache` and an -`Etag` derived from the build-time `version` (the same string injected via -`-ldflags -X main.version=...` and echoed by `GET /api/v1/config`), so browsers -revalidate with a cheap conditional request (a 304 when the version is unchanged) and -still refetch immediately once an upgrade changes `version`. +every page load. `Handler` wraps the file server to set `Cache-Control: no-cache` and a +**per-asset `Etag` derived from that asset's contents** — a SHA-256 of each embedded +file, truncated to 16 bytes and hex-encoded, computed once at construction (the asset set +is fixed at compile time and tiny, so this costs microseconds and no per-request work). +Browsers therefore revalidate with a cheap conditional request (a 304 while the file is +unchanged) and refetch as soon as its bytes change. + +The hash replaces an earlier `version`-derived ETag, which was wrong in two ways: it gave +every asset the *same* validator despite each URL being a distinct resource, and it never +changed across builds that share a version string — which is every unversioned `dev` +build, so `make run` served permanently stale JavaScript after an edit (issue #102). The +`Etag` is set only for paths that name a real asset, so a 404 carries no validator for a +body that has no stable identity. **Stored-XSS prevention is enforced by a repository rule, not just convention.** `internal/web/xss_test.go` (`TestAppJS_NoInnerHTMLInterpolation`) scans `app.js` at test diff --git a/internal/web/embed.go b/internal/web/embed.go index 052872e..8d21b63 100644 --- a/internal/web/embed.go +++ b/internal/web/embed.go @@ -3,10 +3,13 @@ package web import ( + "crypto/sha256" "embed" - "fmt" + "encoding/hex" "io/fs" "net/http" + "path" + "strings" ) //go:embed assets @@ -16,21 +19,67 @@ var assetsFS embed.FS // // Embedded files carry a zero ModTime, so http.FileServerFS would otherwise // emit no Last-Modified/ETag and no Cache-Control, forcing a full refetch of -// every asset on every page load. Handler sets a version-derived ETag and a -// Cache-Control: no-cache directive instead, so browsers revalidate cheaply -// (a 304 on an unchanged version) and still pick up new assets immediately -// after an upgrade changes version. -func Handler(version string) (http.Handler, error) { +// every asset on every page load. Handler sets Cache-Control: no-cache plus a +// per-asset ETag derived from that asset's contents, so browsers revalidate +// cheaply (a 304 while the file is unchanged) and pick up an edited asset +// immediately — including across builds that share a version string, which is +// every unversioned "dev" build from `make run`. +func Handler() (http.Handler, error) { sub, err := fs.Sub(assetsFS, "assets") if err != nil { return nil, err } fileServer := http.FileServerFS(sub) - etag := fmt.Sprintf(`"%s"`, version) + + // Hashed once at construction: the asset set is fixed at compile time and + // tiny, so this costs microseconds and no per-request work. + etags, err := assetETags(sub) + if err != nil { + return nil, err + } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-cache") - w.Header().Set("Etag", etag) + // Only for paths that name a real asset: a 404 body has no stable + // identity and must not carry a validator. + if etag, ok := etags[etagKey(r.URL.Path)]; ok { + w.Header().Set("Etag", etag) + } fileServer.ServeHTTP(w, r) }), nil } + +// etagKey maps a request path to the asset key used in the etags map, +// resolving the directory index the same way http.FileServerFS does. +func etagKey(p string) string { + p = strings.TrimPrefix(path.Clean("/"+p), "/") + if p == "" { + p = "index.html" + } + return p +} + +// assetETags walks the embedded asset tree and returns a strong ETag per file, +// derived from a SHA-256 of its contents. +func assetETags(fsys fs.FS) (map[string]string, error) { + etags := make(map[string]string) + err := fs.WalkDir(fsys, ".", func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + data, err := fs.ReadFile(fsys, p) + if err != nil { + return err + } + sum := sha256.Sum256(data) + etags[p] = `"` + hex.EncodeToString(sum[:16]) + `"` + return nil + }) + if err != nil { + return nil, err + } + return etags, nil +} diff --git a/internal/web/embed_test.go b/internal/web/embed_test.go index f325a5f..902f53f 100644 --- a/internal/web/embed_test.go +++ b/internal/web/embed_test.go @@ -7,15 +7,25 @@ import ( "testing" ) -func TestHandler_ServesIndex(t *testing.T) { - h, err := Handler("1.2.3") +func newHandler(t *testing.T) http.Handler { + t.Helper() + h, err := Handler() if err != nil { t.Fatalf("Handler: %v", err) } + return h +} - req := httptest.NewRequest(http.MethodGet, "/", nil) +func get(t *testing.T, h http.Handler, path string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodGet, path, nil) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) + return rec +} + +func TestHandler_ServesIndex(t *testing.T) { + rec := get(t, newHandler(t), "/") if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rec.Code) @@ -26,15 +36,10 @@ func TestHandler_ServesIndex(t *testing.T) { } func TestHandler_ServesStaticAssets(t *testing.T) { - h, err := Handler("1.2.3") - if err != nil { - t.Fatalf("Handler: %v", err) - } + h := newHandler(t) for _, path := range []string{"/style.css", "/app.js", "/chart.js", "/gauge.js", "/theme-init.js"} { - req := httptest.NewRequest(http.MethodGet, path, nil) - rec := httptest.NewRecorder() - h.ServeHTTP(rec, req) + rec := get(t, h, path) if rec.Code != http.StatusOK { t.Errorf("GET %s: status = %d, want 200", path, rec.Code) } @@ -42,16 +47,9 @@ func TestHandler_ServesStaticAssets(t *testing.T) { } func TestHandler_ServesThemeToggle(t *testing.T) { - h, err := Handler("1.2.3") - if err != nil { - t.Fatalf("Handler: %v", err) - } - - req := httptest.NewRequest(http.MethodGet, "/", nil) - rec := httptest.NewRecorder() - h.ServeHTTP(rec, req) + h := newHandler(t) - body := rec.Body.String() + body := get(t, h, "/").Body.String() if !strings.Contains(body, `id="theme-toggle"`) { t.Errorf("expected index.html to contain the theme toggle button") } @@ -63,53 +61,77 @@ func TestHandler_ServesThemeToggle(t *testing.T) { // The pre-paint script must key persistence off the same localStorage key // app.js uses, so a stored choice survives a reload without a flash. - req = httptest.NewRequest(http.MethodGet, "/theme-init.js", nil) - rec = httptest.NewRecorder() - h.ServeHTTP(rec, req) - if !strings.Contains(rec.Body.String(), "pimonitor-theme") { + if !strings.Contains(get(t, h, "/theme-init.js").Body.String(), "pimonitor-theme") { t.Errorf("expected theme-init.js to reference the pimonitor-theme storage key") } } func TestHandler_UnknownPath404s(t *testing.T) { - h, err := Handler("1.2.3") - if err != nil { - t.Fatalf("Handler: %v", err) - } - req := httptest.NewRequest(http.MethodGet, "/does-not-exist.js", nil) - rec := httptest.NewRecorder() - h.ServeHTTP(rec, req) + rec := get(t, newHandler(t), "/does-not-exist.js") if rec.Code != http.StatusNotFound { t.Fatalf("status = %d, want 404", rec.Code) } } func TestHandler_SetsCacheHeaders(t *testing.T) { - h, err := Handler("1.2.3") - if err != nil { - t.Fatalf("Handler: %v", err) - } - - req := httptest.NewRequest(http.MethodGet, "/", nil) - rec := httptest.NewRecorder() - h.ServeHTTP(rec, req) + rec := get(t, newHandler(t), "/") if got, want := rec.Header().Get("Cache-Control"), "no-cache"; got != want { t.Errorf("Cache-Control = %q, want %q", got, want) } - if got, want := rec.Header().Get("Etag"), `"1.2.3"`; got != want { - t.Errorf("Etag = %q, want %q", got, want) + // The value is a content hash, so assert its shape rather than a literal: + // a non-empty, double-quoted strong validator. + etag := rec.Header().Get("Etag") + if len(etag) < 3 || !strings.HasPrefix(etag, `"`) || !strings.HasSuffix(etag, `"`) { + t.Errorf("Etag = %q, want a non-empty double-quoted validator", etag) + } +} + +// Each asset is a distinct resource and must therefore carry its own +// validator. A single shared ETag (previously the build version) also meant an +// edited asset kept revalidating as unchanged across "dev" builds. +func TestHandler_ETagDiffersPerAsset(t *testing.T) { + h := newHandler(t) + + seen := make(map[string]string) + for _, path := range []string{"/", "/style.css", "/app.js", "/chart.js", "/gauge.js", "/theme-init.js"} { + etag := get(t, h, path).Header().Get("Etag") + if etag == "" { + t.Errorf("GET %s: no Etag header", path) + continue + } + if other, ok := seen[etag]; ok { + t.Errorf("GET %s and GET %s share the Etag %q", other, path, etag) + continue + } + seen[etag] = path + } +} + +// The hash is deterministic, so a restarted process must not invalidate a +// client's cached copy of an unchanged asset. +func TestHandler_ETagStableAcrossHandlers(t *testing.T) { + first, second := newHandler(t), newHandler(t) + + for _, path := range []string{"/", "/app.js", "/style.css"} { + a := get(t, first, path).Header().Get("Etag") + b := get(t, second, path).Header().Get("Etag") + if a != b { + t.Errorf("GET %s: Etag = %q on one handler, %q on another", path, a, b) + } } } func TestHandler_RevalidatesOnMatchingETag(t *testing.T) { - h, err := Handler("1.2.3") - if err != nil { - t.Fatalf("Handler: %v", err) + h := newHandler(t) + + etag := get(t, h, "/app.js").Header().Get("Etag") + if etag == "" { + t.Fatal("GET /app.js: no Etag header") } - req := httptest.NewRequest(http.MethodGet, "/", nil) - req.Header.Set("If-None-Match", `"1.2.3"`) + req := httptest.NewRequest(http.MethodGet, "/app.js", nil) + req.Header.Set("If-None-Match", etag) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) @@ -118,18 +140,29 @@ func TestHandler_RevalidatesOnMatchingETag(t *testing.T) { } } -func TestHandler_RefetchesOnVersionChange(t *testing.T) { - h, err := Handler("2.0.0") - if err != nil { - t.Fatalf("Handler: %v", err) - } - - req := httptest.NewRequest(http.MethodGet, "/", nil) - req.Header.Set("If-None-Match", `"1.2.3"`) +func TestHandler_RefetchesOnStaleETag(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/app.js", nil) + // The old version-derived validator, which every "dev" build emitted. + req.Header.Set("If-None-Match", `"dev"`) rec := httptest.NewRecorder() - h.ServeHTTP(rec, req) + newHandler(t).ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d when the client's cached ETag is stale", rec.Code, http.StatusOK) } + if rec.Body.Len() == 0 { + t.Error("expected a body when the client's cached ETag is stale") + } +} + +// A 404 body has no stable identity, so it must not carry a validator. +func TestHandler_UnknownPathHasNoETag(t *testing.T) { + rec := get(t, newHandler(t), "/does-not-exist.js") + + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404", rec.Code) + } + if etag := rec.Header().Get("Etag"); etag != "" { + t.Errorf("Etag = %q on a 404, want no Etag header", etag) + } } From 232c53f7ff71328841e90e87746f782f71220e32 Mon Sep 17 00:00:00 2001 From: Lars Laskowski Date: Sun, 23 Aug 2026 08:19:27 +0000 Subject: [PATCH 2/2] Strip the asset ETag from redirects the file server issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A map hit only says the request path names an asset, not that the response body will be that asset. http.FileServerFS redirects "*/index.html" to "./" and a trailing slash on a file to its base, and both branch before serveContent runs — so GET /index.html and GET /app.js/ answered 301 with a strong validator on an empty body. A 301 is cacheable by default, making this the same defect as emitting an ETag on a 404, and a conditional GET /index.html returned 301 + Etag instead of 304 or 200. Keep setting the header before delegating (http.ServeContent answers If-None-Match from the writer) and wrap the ResponseWriter to remove it again unless the status is 200, 206, or 304 — a 304 must repeat the validator that matched. Add the unit tests both helpers were missing: a table-driven TestETagKey over the path shapes that reach them, and fstest.MapFS-backed TestAssetETags cases asserting the hash tracks contents rather than names, plus handler tests for the redirect responses. --- docs/ARCHITECTURE.md | 13 +++- internal/web/embed.go | 41 +++++++++++- internal/web/embed_test.go | 134 +++++++++++++++++++++++++++++++++++++ 3 files changed, 182 insertions(+), 6 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 67a6c8a..49850e6 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -303,9 +303,16 @@ unchanged) and refetch as soon as its bytes change. The hash replaces an earlier `version`-derived ETag, which was wrong in two ways: it gave every asset the *same* validator despite each URL being a distinct resource, and it never changed across builds that share a version string — which is every unversioned `dev` -build, so `make run` served permanently stale JavaScript after an edit (issue #102). The -`Etag` is set only for paths that name a real asset, so a 404 carries no validator for a -body that has no stable identity. +build, so `make run` served permanently stale JavaScript after an edit (issue #102). + +The validator is attached only to a response that actually *is* the hashed asset. Naming +an asset is not enough: `http.FileServerFS` redirects `*/index.html` to `./` and a +trailing slash on a file to its base, and a 404 has no stable identity at all — so a small +`etagWriter` wrapper removes the header again unless the file server settles on `200`, +`206`, or `304` (a 304 must repeat the validator that matched). The header has to be set +*before* delegating, because `http.ServeContent` answers a conditional request from +whatever is already on the writer; stripping it afterwards is what keeps a cacheable 301 +or a 404 from carrying a strong validator for a body it does not describe. **Stored-XSS prevention is enforced by a repository rule, not just convention.** `internal/web/xss_test.go` (`TestAppJS_NoInnerHTMLInterpolation`) scans `app.js` at test diff --git a/internal/web/embed.go b/internal/web/embed.go index 8d21b63..cadaa9c 100644 --- a/internal/web/embed.go +++ b/internal/web/embed.go @@ -40,17 +40,52 @@ func Handler() (http.Handler, error) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-cache") - // Only for paths that name a real asset: a 404 body has no stable - // identity and must not carry a validator. + // Set before delegating, because http.ServeContent answers a + // conditional request from the header already on the writer. Naming an + // asset is not the same as serving one, though, so etagWriter takes the + // validator back off any response that is not that asset's bytes. if etag, ok := etags[etagKey(r.URL.Path)]; ok { w.Header().Set("Etag", etag) + w = &etagWriter{ResponseWriter: w} } fileServer.ServeHTTP(w, r) }), nil } +// etagWriter strips a pre-set Etag once the file server has settled on a status +// code that is not the asset's own representation. +// +// Two shapes reach here having matched a real asset yet never serving it: +// http.FileServerFS redirects "*/index.html" to "./" and a trailing slash on a +// file to its base, and both branch before serveContent runs. A 301 is +// cacheable by default, so leaving the header set would attach a strong +// validator to an empty body describing a different representation — the same +// defect as emitting one on a 404. +type etagWriter struct { + http.ResponseWriter + wroteHeader bool +} + +func (w *etagWriter) WriteHeader(code int) { + if w.wroteHeader { + return + } + w.wroteHeader = true + switch code { + // 200 and 206 carry the asset's bytes; a 304 must repeat the validator + // that matched (RFC 9110 15.4.5). Everything else — redirects, 404s, a + // range the file could not satisfy — gets none. + case http.StatusOK, http.StatusPartialContent, http.StatusNotModified: + default: + w.Header().Del("Etag") + } + w.ResponseWriter.WriteHeader(code) +} + // etagKey maps a request path to the asset key used in the etags map, -// resolving the directory index the same way http.FileServerFS does. +// resolving the directory index the same way http.FileServerFS does. A hit +// only means the path names an asset — whether that asset is what gets served +// is decided by the file server and enforced by etagWriter. func etagKey(p string) string { p = strings.TrimPrefix(path.Clean("/"+p), "/") if p == "" { diff --git a/internal/web/embed_test.go b/internal/web/embed_test.go index 902f53f..985b490 100644 --- a/internal/web/embed_test.go +++ b/internal/web/embed_test.go @@ -5,6 +5,7 @@ import ( "net/http/httptest" "strings" "testing" + "testing/fstest" ) func newHandler(t *testing.T) http.Handler { @@ -166,3 +167,136 @@ func TestHandler_UnknownPathHasNoETag(t *testing.T) { t.Errorf("Etag = %q on a 404, want no Etag header", etag) } } + +// http.FileServerFS redirects "*/index.html" to "./" and a trailing slash on a +// file to its base. Both paths name a real asset but never serve it, and a 301 +// is cacheable by default — so the empty redirect body must not inherit that +// asset's validator. +func TestHandler_RedirectsHaveNoETag(t *testing.T) { + h := newHandler(t) + + for _, path := range []string{"/index.html", "/app.js/", "/style.css/"} { + rec := get(t, h, path) + if rec.Code != http.StatusMovedPermanently { + t.Errorf("GET %s: status = %d, want %d", path, rec.Code, http.StatusMovedPermanently) + continue + } + if etag := rec.Header().Get("Etag"); etag != "" { + t.Errorf("GET %s: Etag = %q on a %d redirect, want no Etag header", path, etag, rec.Code) + } + } +} + +// A stale validator on a redirected path must not turn into a 304: the client +// would keep a cached body for a URL the server is redirecting away from. +func TestHandler_RedirectIgnoresConditionalRequest(t *testing.T) { + h := newHandler(t) + + etag := get(t, h, "/").Header().Get("Etag") // index.html's validator + if etag == "" { + t.Fatal("GET /: no Etag header") + } + + req := httptest.NewRequest(http.MethodGet, "/index.html", nil) + req.Header.Set("If-None-Match", etag) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusMovedPermanently { + t.Errorf("status = %d, want %d", rec.Code, http.StatusMovedPermanently) + } + if got := rec.Header().Get("Etag"); got != "" { + t.Errorf("Etag = %q on a redirect, want no Etag header", got) + } +} + +func TestETagKey_ResolvesRequestPaths(t *testing.T) { + tests := []struct { + name string + path string + want string + }{ + {"root serves the directory index", "/", "index.html"}, + {"plain file", "/app.js", "app.js"}, + {"index by name", "/index.html", "index.html"}, + {"trailing slash on a file", "/app.js/", "app.js"}, + {"dot segment", "/./app.js", "app.js"}, + {"parent segments cannot escape the root", "/../../etc/passwd", "etc/passwd"}, + {"nested path keeps its directory", "/sub/app.js", "sub/app.js"}, + {"unknown file maps to its own key", "/does-not-exist.js", "does-not-exist.js"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := etagKey(tt.path); got != tt.want { + t.Errorf("etagKey(%q) = %q, want %q", tt.path, got, tt.want) + } + }) + } +} + +func TestAssetETags_HashesFileContents(t *testing.T) { + etags, err := assetETags(fstest.MapFS{ + "index.html": &fstest.MapFile{Data: []byte("

one

")}, + "app.js": &fstest.MapFile{Data: []byte("console.log(1);")}, + "copy.js": &fstest.MapFile{Data: []byte("console.log(1);")}, + "sub/x.css": &fstest.MapFile{Data: []byte("body{}")}, + }) + if err != nil { + t.Fatalf("assetETags: %v", err) + } + + want := []string{"index.html", "app.js", "copy.js", "sub/x.css"} + if len(etags) != len(want) { + t.Errorf("got %d entries (%v), want %d", len(etags), etags, len(want)) + } + for _, key := range want { + etag, ok := etags[key] + if !ok { + t.Errorf("no ETag for %q", key) + continue + } + if len(etag) < 3 || !strings.HasPrefix(etag, `"`) || !strings.HasSuffix(etag, `"`) { + t.Errorf("ETag for %q = %q, want a non-empty double-quoted validator", key, etag) + } + } + + // Derived from contents, not from the name: differing bytes must differ, + // identical bytes must match. + if etags["app.js"] == etags["index.html"] { + t.Errorf("files with different contents share the ETag %q", etags["app.js"]) + } + if etags["app.js"] != etags["copy.js"] { + t.Errorf("byte-identical files got different ETags: %q vs %q", etags["app.js"], etags["copy.js"]) + } +} + +// Editing an asset must change its validator — the whole point of hashing +// contents rather than the build version. +func TestAssetETags_ChangesWithContent(t *testing.T) { + before, err := assetETags(fstest.MapFS{"app.js": &fstest.MapFile{Data: []byte("console.log(1);")}}) + if err != nil { + t.Fatalf("assetETags: %v", err) + } + after, err := assetETags(fstest.MapFS{"app.js": &fstest.MapFile{Data: []byte("console.log(1);\nconsole.log('changed');")}}) + if err != nil { + t.Fatalf("assetETags: %v", err) + } + + if before["app.js"] == after["app.js"] { + t.Errorf("an edited asset kept the ETag %q", before["app.js"]) + } +} + +func TestAssetETags_SkipsDirectories(t *testing.T) { + etags, err := assetETags(fstest.MapFS{"sub/x.css": &fstest.MapFile{Data: []byte("body{}")}}) + if err != nil { + t.Fatalf("assetETags: %v", err) + } + + for _, key := range []string{".", "sub"} { + if etag, ok := etags[key]; ok { + t.Errorf("directory %q got an ETag %q", key, etag) + } + } +}