mirror of
https://github.com/MeshTender/MeshTender.git
synced 2026-09-02 01:38:17 +00:00
160 lines
5.8 KiB
Go
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)
|
|
}
|
|
}
|
|
})
|
|
}
|