Files
MeshTender/internal/core/cache_headers_test.go
T

160 lines
5.8 KiB
Go

package core
import (
"io"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"regexp"
"strings"
"testing"
)
// hasToken reports whether a comma-separated, possibly multi-field-line header
// value contains a token. Several headers here legitimately arrive as more than
// one field-line — scs Adds `Cache-Control: no-cache="Set-Cookie"` and
// `Vary: Cookie`, and chi's compressor Adds `Vary: Accept-Encoding` — and RFC 9110
// says repeated field-lines combine into one comma-separated list. Header.Get
// returns only the FIRST line, so asserting with it silently misses the rest.
// Always pass Header.Values(name) here.
func hasToken(values []string, token string) bool {
for _, v := range values {
for _, part := range strings.Split(v, ",") {
if strings.EqualFold(strings.TrimSpace(part), token) {
return true
}
}
}
return false
}
// assertNoStore checks every Cache-Control field-line on the response, not just
// the first — see hasToken. An authenticated response carries both our no-store
// and scs's no-cache="Set-Cookie"; no-store is the strictest directive, so it
// governs.
func assertNoStore(t *testing.T, resp *http.Response, what string) {
t.Helper()
values := resp.Header.Values("Cache-Control")
joined := strings.Join(values, ", ")
if !hasToken(values, "no-store") {
t.Errorf("%s Cache-Control = %q, want it to include no-store", what, joined)
return
}
// Nothing may license storing the response after no-store said not to.
for _, bad := range []string{"public", "max-age", "immutable", "s-maxage"} {
if strings.Contains(strings.ToLower(joined), bad) {
t.Errorf("%s Cache-Control has no-store but also %q: %q", what, bad, joined)
}
}
}
// fingerprintedAsset scrapes the content-hashed URL for a logical asset stem
// (e.g. "ui" → "/static/ui.7ef750bd.js") out of a rendered page, so the test uses
// the same URL a browser would without reaching into web's unexported manifest.
func fingerprintedAsset(t *testing.T, ts *httptest.Server, host, page, stem string) string {
t.Helper()
resp := do(t, ts, host, page)
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read %s: %v", page, err)
}
re := regexp.MustCompile(`/static/` + regexp.QuoteMeta(stem) + `\.[0-9a-f]+\.js`)
match := re.Find(body)
if match == nil {
t.Fatalf("no fingerprinted /static/%s.<hash>.js reference on %s", stem, page)
}
return string(match)
}
// TestNoStoreOnSessionSurfaces locks the response-caching policy across the split
// hosts:
// - every session-bearing route on the app and auth hosts sends
// Cache-Control: no-store
// - fingerprinted static assets keep their one-year immutable caching, on every
// host, because they're registered ahead of the session middleware
// - the public root (discovery) pages stay cacheable — no no-store
//
// Regression for the pre-release audit finding that authenticated HTML carried no
// Cache-Control at all, making it heuristically cacheable: the back button would
// re-render a signed-in dashboard after sign-out, handing the next person on a
// shared machine the previous user's data.
func TestNoStoreOnSessionSurfaces(t *testing.T) {
t.Parallel()
_, _, ts, h := splitServer(t)
// Unauthenticated app-host routes still run the session middleware, so they
// carry the header too — enough to prove the wiring without seeding data.
t.Run("app host session routes", func(t *testing.T) {
for _, path := range []string{"/", "/repeaters", "/orgs", "/nonexistent-page"} {
resp := do(t, ts, h.app, path)
resp.Body.Close()
assertNoStore(t, resp, "app "+path)
}
})
t.Run("auth host session routes", func(t *testing.T) {
for _, path := range []string{"/login", "/signup", "/account", "/nonexistent-page"} {
resp := do(t, ts, h.auth, path)
resp.Body.Close()
assertNoStore(t, resp, "auth "+path)
}
})
t.Run("authenticated page", func(t *testing.T) {
jar, err := cookiejar.New(nil)
if err != nil {
t.Fatalf("cookiejar: %v", err)
}
st, ctx, ts2, h2 := splitServer(t)
seedSession(t, ts2, st, ctx, jar, "nostoreuser")
req, _ := http.NewRequest(http.MethodGet, ts2.URL+"/repeaters", nil)
req.Host = h2.app
for _, c := range jar.Cookies(mustURL(t, ts2.URL)) {
req.AddCookie(c)
}
resp, err := noRedirect().Do(req)
if err != nil {
t.Fatalf("get /repeaters: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("/repeaters = %d, want 200 (session not established?)", resp.StatusCode)
}
assertNoStore(t, resp, "signed-in /repeaters")
})
// The whole point of scoping no-store to the session groups: immutable assets
// must survive it, including on the app/auth hosts.
t.Run("fingerprinted assets stay immutable", func(t *testing.T) {
// Discover the hashed URL the way a browser does — from the rendered page —
// rather than reaching into web's unexported manifest.
asset := fingerprintedAsset(t, ts, h.root, "/", "ui")
for _, host := range []struct{ name, h string }{{"app", h.app}, {"auth", h.auth}, {"root", h.root}} {
resp := do(t, ts, host.h, asset)
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("%s %s = %d, want 200", host.name, asset, resp.StatusCode)
}
got := resp.Header.Get("Cache-Control")
if !strings.Contains(got, "immutable") {
t.Errorf("%s %s Cache-Control = %q, want immutable", host.name, asset, got)
}
if strings.Contains(got, "no-store") {
t.Errorf("%s %s got no-store, which would defeat asset caching: %q", host.name, asset, got)
}
}
})
t.Run("public root pages stay cacheable", func(t *testing.T) {
for _, path := range []string{"/", "/orgs", "/docs"} {
resp := do(t, ts, h.root, path)
resp.Body.Close()
if got := resp.Header.Get("Cache-Control"); strings.Contains(got, "no-store") {
t.Errorf("root %s should stay cacheable, got Cache-Control = %q", path, got)
}
}
})
}