package middleware_test import ( "context" "crypto/sha256" "encoding/base64" "net/http" "net/http/httptest" "regexp" "strings" "testing" "bookhoard/internal/middleware" "bookhoard/templates" "github.com/labstack/echo/v5" ) // TestThemeFlashGuardHashMatchesCSP pins the CSP script hash to the theme // flash guard's rendered bytes: the guard is the app's ONLY inline script, // and script-src allow-lists it by content hash. If the guard's script text // is ever edited, this test fails and prints the replacement hash — copy it // into middleware.ScriptHashThemeFlashGuard. func TestThemeFlashGuardHashMatchesCSP(t *testing.T) { var sb strings.Builder if err := templates.ThemeFlashGuard().Render(context.Background(), &sb); err != nil { t.Fatalf("render ThemeFlashGuard: %v", err) } rendered := sb.String() m := regexp.MustCompile(`(?s)`).FindStringSubmatch(rendered) if m == nil { t.Fatalf("rendered guard contains no of templates/theme_flash_guard.templ, base64-encoded, then update middleware.ScriptHashThemeFlashGuard and the CSP header string.`, middleware.ScriptHashThemeFlashGuard, computed) } // The strict allow-list only holds the guard: no 'unsafe-inline' may // sneak back into script-src (style-src keeps it for htmx/Alpine). if strings.Contains(middleware.ScriptHashThemeFlashGuard, "unsafe-inline") { t.Error("script-src must not re-introduce 'unsafe-inline' alongside the hash") } } // HTML pages must always revalidate (Cache-Control: no-cache): a // heuristically cached PAGE referenced a stale main.js stamp and kept // the old bundle running long after a fix deployed (the 2026-10-27 // "search dropdown still broken" report). Static/API/media routes keep // their native caching — stamped bundles are content-frozen by URL. func TestHTMLPagesRevalidate(t *testing.T) { mw := middleware.SecurityHeadersMiddleware() render := func(path string) string { e := echo.New() req, _ := http.NewRequest("GET", path, nil) rec := httptest.NewRecorder() c := e.NewContext(req, rec) handler := mw(func(c *echo.Context) error { return c.HTML(http.StatusOK, "") }) if err := handler(c); err != nil { t.Fatalf("handler error: %v", err) } return rec.Header().Get("Cache-Control") } if got := render("/bookshelf"); got != "no-cache" { t.Errorf("/bookshelf: Cache-Control = %q, want no-cache", got) } if got := render("/series"); got != "no-cache" { t.Errorf("/series: Cache-Control = %q, want no-cache", got) } for _, p := range []string{"/static/main.js", "/api/media-items", "/uploads/library-x/book.epub", "/health"} { if got := render(p); got == "no-cache" { t.Errorf("%s: must keep native caching, got no-cache", p) } } }