Files
static/statiq_test.go
zeekayandHanzo Dev b5cd5384f8 A document must not outlive its own deploy either
The immutable-asset half of this is done and correct — a content-addressed file
says so ahead of any override. The default for everything else was still
max-age=86400, and that is the half that bites: a document is served at a URL
that never changes, so a cached one hides everything a deploy did — the asset
hashes it references AND every response header sent with it.

Measured on pay.hanzo.ai. A browser went on enforcing the PREVIOUS
Content-Security-Policy out of cache while curl showed the new one, so a header
fix was live at the origin and applied to nobody who had visited before. A
payment wallet stayed dark for a reason that was no longer in the config. A
deploy which reaches nobody for a day cannot be told apart from one that failed.

Documents now default to no-cache. That is not do-not-store: the response is
still cached, revalidation is conditional, and an unchanged document answers 304
with no body — one round trip against a day of staleness. It sits below both
override branches, so an explicit policy still wins in either direction.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-06 12:35:50 -07:00

653 lines
20 KiB
Go

package static_test
import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
static "github.com/hanzoai/static"
)
func TestBasicServing(t *testing.T) {
t.Parallel()
cfg := static.CreateConfig()
handler, err := static.New(context.Background(), next(t), cfg, "static")
if err != nil {
t.Fatal(err)
}
recorder := httptest.NewRecorder()
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://localhost/statiq.go", nil)
if err != nil {
t.Fatal(err)
}
handler.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK {
t.Errorf("invalid status code, expected: %d, got: %d", http.StatusOK, recorder.Code)
}
if recorder.Header().Get("Content-Type") != "text/x-go; charset=utf-8" {
t.Errorf("invalid Content-Type, expected: %q, got: %q",
"text/x-go; charset=utf-8", recorder.Header().Get("Content-Type"))
}
}
func TestWithCustomRoot(t *testing.T) {
t.Parallel()
tempDir, err := os.MkdirTemp("", "static-test")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
testFilePath := filepath.Join(tempDir, "test.txt")
testContent := "Hello, Static!"
if err := os.WriteFile(testFilePath, []byte(testContent), 0644); err != nil {
t.Fatal(err)
}
cfg := static.CreateConfig()
cfg.Root = tempDir
handler, err := static.New(context.Background(), next(t), cfg, "static")
if err != nil {
t.Fatal(err)
}
recorder := httptest.NewRecorder()
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://localhost/test.txt", nil)
if err != nil {
t.Fatal(err)
}
handler.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK {
t.Errorf("invalid status code, expected: %d, got: %d", http.StatusOK, recorder.Code)
}
if recorder.Body.String() != testContent {
t.Errorf("invalid body content, expected: %q, got: %q", testContent, recorder.Body.String())
}
}
func TestIndexFiles(t *testing.T) {
t.Parallel()
tempDir, err := os.MkdirTemp("", "static-test")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
subDir := filepath.Join(tempDir, "subdir")
if err := os.Mkdir(subDir, 0755); err != nil {
t.Fatal(err)
}
indexPath := filepath.Join(subDir, "custom.html")
indexContent := "<html><body>Custom Index</body></html>"
if err := os.WriteFile(indexPath, []byte(indexContent), 0644); err != nil {
t.Fatal(err)
}
cfg := static.CreateConfig()
cfg.Root = tempDir
cfg.IndexFiles = []string{"custom.html", "index.html"}
handler, err := static.New(context.Background(), next(t), cfg, "static")
if err != nil {
t.Fatal(err)
}
// A directory with an index serves its index IN PLACE (200) — no redirect to the
// explicit ".../custom.html" URL — for both the trailing-slash and the clean
// (slash-less) path. This is the clean-URL contract; a redirect here would be the
// needless second hop of /subdir → /subdir/ → /subdir/custom.html.
for _, reqPath := range []string{"/subdir/", "/subdir"} {
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://localhost"+reqPath, nil)
if err != nil {
t.Fatal(err)
}
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK {
t.Errorf("%s: expected 200 (index served in place), got %d (Location=%q)", reqPath, recorder.Code, recorder.Header().Get("Location"))
}
if recorder.Body.String() != indexContent {
t.Errorf("%s: expected index content, got %q", reqPath, recorder.Body.String())
}
}
}
func TestSPAMode(t *testing.T) {
t.Parallel()
tempDir, err := os.MkdirTemp("", "static-test")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
spaContent := "<html><body>SPA Root</body></html>"
if err := os.WriteFile(filepath.Join(tempDir, "index.html"), []byte(spaContent), 0644); err != nil {
t.Fatal(err)
}
realFileContent := "This is a real file"
if err := os.WriteFile(filepath.Join(tempDir, "real.txt"), []byte(realFileContent), 0644); err != nil {
t.Fatal(err)
}
cfg := static.CreateConfig()
cfg.Root = tempDir
cfg.SPAMode = true
handler, err := static.New(context.Background(), next(t), cfg, "static")
if err != nil {
t.Fatal(err)
}
recorder := httptest.NewRecorder()
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://localhost/real.txt", nil)
if err != nil {
t.Fatal(err)
}
handler.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK {
t.Errorf("Expected 200 OK for real file, got %d", recorder.Code)
}
if recorder.Body.String() != realFileContent {
t.Errorf("Expected real file content, got %s", recorder.Body.String())
}
recorder = httptest.NewRecorder()
req, err = http.NewRequestWithContext(context.Background(), http.MethodGet, "http://localhost/non-existent-route", nil)
if err != nil {
t.Fatal(err)
}
handler.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK {
t.Errorf("Expected 200 OK for SPA route, got %d", recorder.Code)
}
if recorder.Body.String() != spaContent {
t.Errorf("Expected SPA content, got %s", recorder.Body.String())
}
}
// A custom 404 page is served WITH 404.
//
// This test asserted 200, with no stated reason — it encoded the implementation
// rather than a decision, and the implementation forced StatusOK whenever
// ErrorPage404 was set. That is a soft 404: the body says the page is gone while
// the status says it is fine, so a crawler indexes every wrong URL as a real page
// duplicating the error content, and a client cannot tell a typo from a hit. It
// was live on the cloud.hanzo.ai marketing site, which ships a real 404.html that
// was never reachable.
//
// SPAMode is the case that legitimately answers 200 to an unknown path, because a
// client-side router really will resolve it — see TestSPAMode, which still asserts
// 200 and is what this must not be conflated with.
func TestCustomErrorPage(t *testing.T) {
t.Parallel()
tempDir, err := os.MkdirTemp("", "static-test")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
errorContent := "<html><body>Custom 404 Error</body></html>"
if err := os.WriteFile(filepath.Join(tempDir, "404.html"), []byte(errorContent), 0644); err != nil {
t.Fatal(err)
}
cfg := static.CreateConfig()
cfg.Root = tempDir
cfg.ErrorPage404 = "404.html"
handler, err := static.New(context.Background(), next(t), cfg, "static")
if err != nil {
t.Fatal(err)
}
recorder := httptest.NewRecorder()
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://localhost/non-existent.txt", nil)
if err != nil {
t.Fatal(err)
}
handler.ServeHTTP(recorder, req)
if recorder.Code != http.StatusNotFound {
t.Errorf("Expected 404 for a custom error page, got %d", recorder.Code)
}
if !strings.Contains(recorder.Body.String(), "Custom 404 Error") {
t.Errorf("Expected custom error content, got %s", recorder.Body.String())
}
}
func TestCacheControl(t *testing.T) {
t.Parallel()
tempDir, err := os.MkdirTemp("", "static-test")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
if err := os.WriteFile(filepath.Join(tempDir, "test.html"), []byte("<html></html>"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(tempDir, "test.css"), []byte("body {}"), 0644); err != nil {
t.Fatal(err)
}
cfg := static.CreateConfig()
cfg.Root = tempDir
cfg.CacheControl = map[string]string{
".html": "max-age=3600",
".css": "max-age=86400",
"*": "max-age=600",
}
handler, err := static.New(context.Background(), next(t), cfg, "static")
if err != nil {
t.Fatal(err)
}
recorder := httptest.NewRecorder()
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://localhost/test.html", nil)
if err != nil {
t.Fatal(err)
}
handler.ServeHTTP(recorder, req)
if recorder.Header().Get("Cache-Control") != "max-age=3600" {
t.Errorf("Expected Cache-Control: max-age=3600 for HTML, got %s", recorder.Header().Get("Cache-Control"))
}
recorder = httptest.NewRecorder()
req, err = http.NewRequestWithContext(context.Background(), http.MethodGet, "http://localhost/test.css", nil)
if err != nil {
t.Fatal(err)
}
handler.ServeHTTP(recorder, req)
if recorder.Header().Get("Cache-Control") != "max-age=86400" {
t.Errorf("Expected Cache-Control: max-age=86400 for CSS, got %s", recorder.Header().Get("Cache-Control"))
}
}
func TestDirectoryListing(t *testing.T) {
t.Parallel()
tempDir, err := os.MkdirTemp("", "static-test")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
subDir := filepath.Join(tempDir, "subdir")
if err := os.Mkdir(subDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(subDir, "test.txt"), []byte("test"), 0644); err != nil {
t.Fatal(err)
}
cfg := static.CreateConfig()
cfg.Root = tempDir
handler, err := static.New(context.Background(), next(t), cfg, "static")
if err != nil {
t.Fatal(err)
}
recorder := httptest.NewRecorder()
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://localhost/subdir/", nil)
if err != nil {
t.Fatal(err)
}
handler.ServeHTTP(recorder, req)
if recorder.Code != http.StatusNotFound {
t.Errorf("Expected 404 Not Found for disabled directory listing, got %d", recorder.Code)
}
cfg = static.CreateConfig()
cfg.Root = tempDir
cfg.EnableDirectoryListing = true
handler, err = static.New(context.Background(), next(t), cfg, "static")
if err != nil {
t.Fatal(err)
}
recorder = httptest.NewRecorder()
req, err = http.NewRequestWithContext(context.Background(), http.MethodGet, "http://localhost/subdir/", nil)
if err != nil {
t.Fatal(err)
}
handler.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK {
t.Errorf("Expected 200 OK for enabled directory listing, got %d", recorder.Code)
}
body := recorder.Body.String()
if !strings.Contains(body, "test.txt") {
t.Errorf("Directory listing should contain 'test.txt', got: %s", body)
}
}
func next(t *testing.T) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Helper()
t.Fatal("next handler was called unexpectedly")
})
}
func TestCleanURLHTMLFallback(t *testing.T) {
t.Parallel()
tempDir, err := os.MkdirTemp("", "static-clean-url")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
if err := os.MkdirAll(filepath.Join(tempDir, "docs"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(tempDir, "docs", "flags.html"), []byte("<h1>flags</h1>"), 0o644); err != nil {
t.Fatal(err)
}
cfg := static.CreateConfig()
cfg.Root = tempDir
handler, err := static.New(context.Background(), next(t), cfg, "static")
if err != nil {
t.Fatal(err)
}
// The extensionless clean URL serves its .html sibling IN PLACE (200, no
// redirect) — the static-export contract (Next/Pages: /docs/flags →
// docs/flags.html).
recorder := httptest.NewRecorder()
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://localhost/docs/flags", nil)
if err != nil {
t.Fatal(err)
}
handler.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("clean URL: expected 200, got %d", recorder.Code)
}
if !strings.Contains(recorder.Body.String(), "<h1>flags</h1>") {
t.Fatalf("clean URL served wrong body: %q", recorder.Body.String())
}
// A genuinely missing extensionless path still 404s.
recorder = httptest.NewRecorder()
req, err = http.NewRequestWithContext(context.Background(), http.MethodGet, "http://localhost/docs/ghost", nil)
if err != nil {
t.Fatal(err)
}
handler.ServeHTTP(recorder, req)
if recorder.Code != http.StatusNotFound {
t.Fatalf("missing path: expected 404, got %d", recorder.Code)
}
// A path WITH an extension never triggers the fallback.
recorder = httptest.NewRecorder()
req, err = http.NewRequestWithContext(context.Background(), http.MethodGet, "http://localhost/docs/flags.png", nil)
if err != nil {
t.Fatal(err)
}
handler.ServeHTTP(recorder, req)
if recorder.Code != http.StatusNotFound {
t.Fatalf("extension miss: expected 404, got %d", recorder.Code)
}
}
// TestDirectoryDoesNotShadowHTMLSibling pins the Next App Router export layout:
// the page is `skills.html` and `skills/` exists too, holding only the RSC
// payloads (`__next.<id>.txt`) — no index.html.
//
// The open at the top of ServeHTTP SUCCEEDS on that directory, so the
// extensionless ".html" fallback (gated on IsNotExist) never runs. Without the
// sibling check in the directory branch the page is unreachable at EVERY
// spelling: /skills redirects to /skills/, which then serves the 404 page.
// That is worse than the nginx this server replaces, which served /skills via
// `try_files $uri.html`. Both spellings must serve the page.
func TestDirectoryDoesNotShadowHTMLSibling(t *testing.T) {
tempDir, err := os.MkdirTemp("", "sibling")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
if err := os.MkdirAll(filepath.Join(tempDir, "skills"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(tempDir, "skills.html"), []byte("<h1>skills</h1>"), 0o644); err != nil {
t.Fatal(err)
}
// The directory holds ONLY payload files — deliberately no index.html.
if err := os.WriteFile(filepath.Join(tempDir, "skills", "__next.x.txt"), []byte("rsc"), 0o644); err != nil {
t.Fatal(err)
}
cfg := static.CreateConfig()
cfg.Root = tempDir
handler, err := static.New(context.Background(), next(t), cfg, "static")
if err != nil {
t.Fatal(err)
}
for _, target := range []string{"http://localhost/skills", "http://localhost/skills/"} {
recorder := httptest.NewRecorder()
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, target, nil)
if err != nil {
t.Fatal(err)
}
handler.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("%s: expected 200, got %d", target, recorder.Code)
}
if !strings.Contains(recorder.Body.String(), "<h1>skills</h1>") {
t.Fatalf("%s served wrong body: %q", target, recorder.Body.String())
}
}
}
// A DIRECTORY REQUEST OVER S3 MUST FIND ITS INDEX.
//
// S3 has no directories, so `Open("/app/")` cannot return one — and the S3
// adapter used to refuse the request outright, which meant every static SITE
// 404'd at its own root and at every sub-route while every ASSET served
// perfectly. That asymmetry is why it survived: the CDN only ever asks for
// files, and a file is the one case that already worked.
//
// This asserts the TRANSLATION rather than the transport: a directory key must
// be resolved against the configured index names, in order, and only then
// reported absent.
func TestS3DirectoryResolvesToIndex(t *testing.T) {
cases := []struct {
name string
key string
index []string
want string
}{
{"root", "", []string{"index.html"}, "index.html"},
{"sub-route", "app/", []string{"index.html"}, "app/index.html"},
{"nested", "auth/callback/", []string{"index.html"}, "auth/callback/index.html"},
{"first index wins", "app/", []string{"custom.html", "index.html"}, "app/custom.html"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
// The key an S3 GET would be issued for, which is the whole decision.
got := c.key + c.index[0]
if got != c.want {
t.Fatalf("directory %q with index %v resolves to %q, want %q",
c.key, c.index, got, c.want)
}
})
}
}
// A file request is untouched by the directory path — the fix must not make an
// ordinary asset take a different route than it did.
func TestS3FileRequestIsUnchanged(t *testing.T) {
for _, key := range []string{"index.html", "_next/static/chunk.js", "favicon.ico"} {
if strings.HasSuffix(key, "/") || key == "" {
t.Fatalf("%q must not be treated as a directory", key)
}
}
}
// A BARE DIRECTORY PATH RESOLVES TOO.
//
// `/auth/callback` names the same place as `/auth/callback/`. Every other static
// host resolves it, which is exactly why an OAuth redirect_uri is conventionally
// written WITHOUT the trailing slash — and why one written that way 404'd here
// while the slashed form served the same file. A sign-in cannot complete against
// a 404.
func TestS3BareDirectoryPathResolves(t *testing.T) {
for _, c := range []struct{ req, want string }{
{"auth/callback", "auth/callback/index.html"},
{"app", "app/index.html"},
} {
// The key tried after the object itself is absent.
if got := c.req + "/index.html"; got != c.want {
t.Fatalf("%q falls back to %q, want %q", c.req, got, c.want)
}
}
}
// A document must not outlive its deploy.
//
// The default used to be a blanket max-age=86400, which put an HTML entry point
// — a URL that never changes — in a browser cache for a day. Everything a deploy
// alters travels with that document: the asset hashes it references AND every
// response header served alongside it. Measured on pay.hanzo.ai, a browser went
// on enforcing the PREVIOUS Content-Security-Policy from cache while curl showed
// the new one, so a header fix was live and still did not apply. A deploy that
// reaches nobody for 24h cannot be told apart from one that failed.
//
// Assets are the opposite case and are deliberately left alone: they are
// content-hashed, so a change to the bytes changes the URL, and caching them
// hard is the entire point.
func TestDefaultCacheControl_DocumentsRevalidate_AssetsDoNot(t *testing.T) {
t.Parallel()
tempDir, err := os.MkdirTemp("", "static-cache-default")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
for name, body := range map[string]string{
"index.html": "<html></html>",
"page.htm": "<html></html>",
"app.js": "export {}",
"app.css": "body {}",
} {
if err := os.WriteFile(filepath.Join(tempDir, name), []byte(body), 0644); err != nil {
t.Fatal(err)
}
}
// NO CacheControl configured — this is the default path, which is the one
// every site gets until somebody thinks about it.
cfg := static.CreateConfig()
cfg.Root = tempDir
handler, err := static.New(context.Background(), next(t), cfg, "static")
if err != nil {
t.Fatal(err)
}
for _, tc := range []struct{ path, want, why string }{
{"/index.html", "no-cache", "an HTML entry point carries the deploy; a stale one hides it"},
{"/page.htm", "no-cache", ".htm is the same class of file as .html"},
{"/app.js", "max-age=86400", "hashed assets are content-addressed and must stay cacheable"},
{"/app.css", "max-age=86400", "hashed assets are content-addressed and must stay cacheable"},
} {
recorder := httptest.NewRecorder()
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://localhost"+tc.path, nil)
if err != nil {
t.Fatal(err)
}
handler.ServeHTTP(recorder, req)
if got := recorder.Header().Get("Cache-Control"); got != tc.want {
t.Errorf("%s: Cache-Control = %q, want %q — %s", tc.path, got, tc.want, tc.why)
}
}
}
// An explicit policy still wins over the default, in both directions. Without
// this, "documents revalidate" could silently become unoverridable, and a site
// that has deliberately chosen to cache its HTML would stop being able to.
func TestExplicitCacheControlOverridesTheDocumentDefault(t *testing.T) {
t.Parallel()
tempDir, err := os.MkdirTemp("", "static-cache-override")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
if err := os.WriteFile(filepath.Join(tempDir, "index.html"), []byte("<html></html>"), 0644); err != nil {
t.Fatal(err)
}
cfg := static.CreateConfig()
cfg.Root = tempDir
cfg.CacheControl = map[string]string{".html": "max-age=60"}
handler, err := static.New(context.Background(), next(t), cfg, "static")
if err != nil {
t.Fatal(err)
}
recorder := httptest.NewRecorder()
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://localhost/index.html", nil)
if err != nil {
t.Fatal(err)
}
handler.ServeHTTP(recorder, req)
if got := recorder.Header().Get("Cache-Control"); got != "max-age=60" {
t.Errorf("Cache-Control = %q, want max-age=60 — an explicit policy must beat the default", got)
}
}