rastrillo / fiesta Public

Serve _headers in the test server, matched before the rewrite

The test server the www tests and browser drives use serves the built
site the way CARLOS's edge does, but it sent no-store on every file
and served _headers as a page. So nothing could show the one mistake
that matters most here: rules match the address the browser asked
for, before the /M/ rewrite, so /1/fiesta.css must get /1/*'s five
minutes and not its release's year. Matched after the rewrite, /1/
would be immutable in browsers for a year and never move to 1.2.

The handler now reads _headers with the edge's grammar (a file carlos
ship would refuse fails every test that serves the site), sends
no-cache and then every matching rule on a file served with a 200,
none on a redirect or a 404, and never serves either rules file.
TestTheSitesHeaders asks it for /1/fiesta.css, a release file, a
hashed asset and two pages, and checks /_headers is a 404.

The browser drives keep no-store. A drive that loads a page twice
must reach the handler both times, or Delay and Seen would miss the
second load once a browser may keep /1/ for minutes. The www browser
tests pass with the change.

Red: the new served-header assertions failed against the old handler
(no-store everywhere, /_headers a 200). Mutations, each red and
reverted: headers on every status; rules matched after the rewrite;
_headers served as a file; the first rule winning; the mirror taking
any header name; the mirror taking any origin.
Human

Prompt

If an internal/www test serves the built site the way the edge does, decide whether it should apply `_headers` too. If it can be done cheaply, add an assertion that a request for `/1/fiesta.css` would get `max-age=300` and not immutable, since rules match before the rewrite.

From this commit's own message. The prompt ledger has no entries for it — which is what a squashed, rebased or amended commit looks like, and what every commit looks like in a repo without a ledger.

Summary

The www test server applies _headers as CARLOS's edge does; the browser drives keep no-store.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 326efa03236dffb56ca2be5e06823564393c465e parent b1e5fd0
4 files changed, +159 −9
  • internal/www/headers_test.go +125 −0
  • internal/www/landing_browser_test.go +1 −0
  • internal/www/legacy_test.go +1 −1
  • internal/www/serve_test.go +32 −8
diff --git a/internal/www/headers_test.go b/internal/www/headers_test.go
index fc57fd3..a9a6d18 100644
--- a/internal/www/headers_test.go
+++ b/internal/www/headers_test.go
@@ -367,6 +367,38 @@ func TestTheSitesHeaders(t *testing.T) {
}
})
+ t.Run("served as the edge serves them", func(t *testing.T) {
+ h := newSiteHandler(t, tree)
+ major, _, _ := strings.Cut(fiesta.Version, ".")
+ for _, c := range []struct {
+ path, cache, origin string
+ }{
+ // Rules match before the rewrite, so /1/ is /1/*'s, not
+ // its release's.
+ {"/" + major + "/fiesta.css", fiveMinute, "*"},
+ {"/" + fiesta.Version + "/fiesta.css", yearLong, "*"},
+ {"/1.0.0/i18n/es.json", yearLong, "*"},
+ {hashed[0], yearLong, ""},
+ {"/design-system/", "no-cache", ""},
+ {"/", "no-cache", ""},
+ } {
+ res := serve(h, c.path)
+ if res.Code != http.StatusOK {
+ t.Errorf("%s answers %d", c.path, res.Code)
+ continue
+ }
+ if cc := res.Header().Get("Cache-Control"); cc != c.cache {
+ t.Errorf("%s gets Cache-Control %q; want %q", c.path, cc, c.cache)
+ }
+ if o := res.Header().Get("Access-Control-Allow-Origin"); o != c.origin {
+ t.Errorf("%s gets Access-Control-Allow-Origin %q; want %q", c.path, o, c.origin)
+ }
+ }
+ if res := serve(h, "/_headers"); res.Code != http.StatusNotFound {
+ t.Errorf("/_headers answers %d; the edge never serves it", res.Code)
+ }
+ })
+
t.Run("within the edge's limits", func(t *testing.T) {
longest := 0
for _, l := range strings.Split(string(data), "\n") {
@@ -378,3 +410,96 @@ func TestTheSitesHeaders(t *testing.T) {
t.Logf("_headers: %d paths, %d bytes, longest line %d characters", len(rules), len(data), longest)
})
}
+
+// TestServeSiteSendsHeadersLikeTheEdge holds the test server's headers
+// to the edge's: a file served with a 200 gets no-cache, then every
+// _headers rule that matches the address asked for, before any
+// rewrite, in file order, the last to set a header winning; ":name"
+// matches one segment; a redirect, a rewrite's 404 and a missing file
+// get none of them; and the file itself is never served. A test of
+// the test double, not of the site.
+func TestServeSiteSendsHeadersLikeTheEdge(t *testing.T) {
+ h := newSiteHandler(t, map[string][]byte{
+ "_redirects": []byte("/1/* /1.0.0/:splat 200\n" +
+ "/old /index.html 301\n" +
+ "/gone/* /index.html 404\n"),
+ "_headers": []byte("\ufeff# general first\n" +
+ "/*\n" +
+ " timing-allow-origin: *\n" +
+ "/1/*\n" +
+ " Cache-Control: public, max-age=300\n" +
+ "/1.0.0/*\n" +
+ " Cache-Control: public, max-age=31536000, immutable\n" +
+ " Access-Control-Allow-Origin: *\n" +
+ "\n" +
+ "/1.0.0/:file\n" +
+ " Cache-Control: no-transform\n" +
+ "/old\n" +
+ " Cache-Control: public, max-age=60\n" +
+ "/gone/*\n" +
+ " Cache-Control: public, max-age=60\n"),
+ "index.html": []byte("root"),
+ "1.0.0/fiesta.css": []byte("newest"),
+ "1.0.0/i18n/es.js": []byte("strings"),
+ })
+ for _, c := range []struct {
+ path string
+ status int
+ cache, origin string
+ timing string
+ }{
+ {"/", 200, "no-cache", "", "*"},
+ {"/1/fiesta.css", 200, "public, max-age=300", "", "*"},
+ {"/1.0.0/fiesta.css", 200, "no-transform", "*", "*"},
+ {"/1.0.0/i18n/es.js", 200, "public, max-age=31536000, immutable", "*", "*"},
+ {"/old", 301, "", "", ""},
+ {"/gone/x", 404, "", "", ""},
+ {"/nope", 404, "", "", ""},
+ {"/_headers", 404, "", "", ""},
+ } {
+ res := serve(h, c.path)
+ got := []string{res.Header().Get("Cache-Control"), res.Header().Get("Access-Control-Allow-Origin"), res.Header().Get("Timing-Allow-Origin")}
+ if want := []string{c.cache, c.origin, c.timing}; res.Code != c.status || !slices.Equal(got, want) {
+ t.Errorf("GET %s: %d, Cache-Control %q, Allow-Origin %q, Timing-Allow-Origin %q; want %d %q", c.path, res.Code, got[0], got[1], got[2], c.status, want)
+ }
+ }
+}
+
+// TestServeSiteRefusesAHeadersFileCarlosWouldRefuse: the test server
+// reads _headers with CARLOS's grammar, so a file `carlos ship` would
+// refuse is refused here too, and one it packs is read.
+func TestServeSiteRefusesAHeadersFileCarlosWouldRefuse(t *testing.T) {
+ var many strings.Builder
+ for i := 0; i <= headersMaxPaths; i++ {
+ fmt.Fprintf(&many, "/p%d\n Timing-Allow-Origin: *\n", i)
+ }
+ for _, file := range []string{
+ " Cache-Control: no-cache\n",
+ "/a b\n Cache-Control: no-cache\n",
+ "/a\n",
+ "/a\n Set-Cookie: a=b\n",
+ "/a\n Strict-Transport-Security: max-age=1\n",
+ "/a\n Access-Control-Allow-Origin: https://example.com\n",
+ "/a\n Cross-Origin-Resource-Policy: anywhere\n",
+ "/a\n Cache-Control: public max-age=1\n",
+ "/a\n Cache-Control:\n",
+ "/a\n ! Cache-Control\n",
+ "/a/*/b\n Cache-Control: no-cache\n",
+ "/a\n Cache-Control: " + strings.Repeat("x", headersMaxLine) + "\n",
+ many.String(),
+ } {
+ if _, err := parseHeaders([]byte(file)); err == nil {
+ t.Errorf("%.60q parsed; CARLOS refuses it", file)
+ }
+ }
+ for _, file := range []string{
+ "# a comment alone\n",
+ "/a\n cache-control: no-cache\n",
+ "/a/:x/*\n\tAccess-Control-Expose-Headers: ETag, Content-Length\n",
+ "https://fiesta.rastrillo.org/a\n Cache-Control: max-age=1, stale-if-error=\"60\"\n",
+ } {
+ if _, err := parseHeaders([]byte(file)); err != nil {
+ t.Errorf("%q was refused (%v); CARLOS accepts it", file, err)
+ }
+ }
+}
diff --git a/internal/www/landing_browser_test.go b/internal/www/landing_browser_test.go
index 3fa9705..d35396d 100644
--- a/internal/www/landing_browser_test.go
+++ b/internal/www/landing_browser_test.go
@@ -39,6 +39,7 @@ func siteRig(t *testing.T, opts ...harness.Option) (*harness.Rig, *siteHandler)
var h *siteHandler
rig := harness.New(t, func(string) http.Handler {
h = newSiteHandler(t, builtSite(t))
+ h.noStore = true
return h
}, opts...)
return rig, h
diff --git a/internal/www/legacy_test.go b/internal/www/legacy_test.go
index e54c2f1..9356077 100644
--- a/internal/www/legacy_test.go
+++ b/internal/www/legacy_test.go
@@ -168,7 +168,7 @@ func TestNoRedirectShadowsAPage(t *testing.T) {
}
h := newSiteHandler(t, tree)
for name := range tree {
- if name == "_redirects" {
+ if name == "_redirects" || name == "_headers" {
continue
}
paths := []string{"/" + name}
diff --git a/internal/www/serve_test.go b/internal/www/serve_test.go
index c2dcf81..0cab78d 100644
--- a/internal/www/serve_test.go
+++ b/internal/www/serve_test.go
@@ -53,12 +53,23 @@ func builtSite(t *testing.T) map[string][]byte {
// file of the tree and a 301, 302, 303, 307 or 308 answering with its
// Location (redirects_test.go); anything else is a 404; and any method
// other than GET or HEAD is a 405, as on the edge, so a form a page
-// should never submit cannot pass a test by accident. It records every
-// request's path and can be told to delay one path, for the
-// first-paint drive.
+// should never submit cannot pass a test by accident. A file served
+// with a 200 gets Cache-Control: no-cache and then every _headers rule
+// matching the address asked for, before any rewrite
+// (headers_test.go); a redirect or a 404 gets none. Neither rules file
+// is served. It records every request's path and can be told to delay
+// one path, for the first-paint drive.
type siteHandler struct {
- tree map[string][]byte
- rules []rule
+ tree map[string][]byte
+ rules []rule
+ headers []headerRule
+
+ // noStore sends Cache-Control: no-store on every file in place of
+ // the edge's, for the browser drives: _headers lets a browser keep
+ // /1/ for minutes and a release for a year, and a drive that loads
+ // a page twice must reach the handler both times, or Delay and
+ // Seen would miss the second load.
+ noStore bool
mu sync.Mutex
seen []string
@@ -73,6 +84,11 @@ func newSiteHandler(t *testing.T, tree map[string][]byte) *siteHandler {
t.Fatalf("_redirects: %v", err)
}
h.rules = rules
+ headers, err := parseHeaders(tree["_headers"])
+ if err != nil {
+ t.Fatalf("_headers: %v", err)
+ }
+ h.headers = headers
return h
}
@@ -99,10 +115,10 @@ func (h *siteHandler) Seen(reset bool) []string {
// file finds the tree entry for a cleaned, rooted path: the file
// itself, or the index of the directory there, and whether it was a
-// directory. The rules file answers nothing.
+// directory. The rules files answer nothing.
func (h *siteHandler) file(p string) (data []byte, name string, dir, ok bool) {
name = strings.TrimPrefix(p, "/")
- if name == "_redirects" {
+ if name == "_redirects" || name == "_headers" {
return nil, "", false, false
}
if data, ok = h.tree[name]; ok && name != "" {
@@ -161,7 +177,15 @@ func (h *siteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ct = "application/octet-stream"
}
w.Header().Set("Content-Type", ct)
- w.Header().Set("Cache-Control", "no-store")
+ if status == http.StatusOK {
+ w.Header().Set("Cache-Control", "no-cache")
+ for name, values := range lookupHeaders(h.headers, host, p) {
+ w.Header()[name] = values
+ }
+ }
+ if h.noStore {
+ w.Header().Set("Cache-Control", "no-store")
+ }
w.WriteHeader(status)
w.Write(data)
}