package webui import ( "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" ) // TestServeFromDiskPinsContentType ensures static admin assets are returned // with an explicit, RFC-compliant Content-Type that does not depend on // mime.TypeByExtension. On Windows mime.TypeByExtension consults the registry // (HKEY_CLASSES_ROOT) which third-party software can corrupt — for example // installing certain editors rewrites .css to application/xml — and Chrome // then refuses to apply a stylesheet whose Content-Type is not text/css, // breaking the /admin page entirely. Pinning the type by file extension makes // the response deterministic across operating systems and machine state. func TestServeFromDiskPinsContentType(t *testing.T) { staticDir := t.TempDir() assetsDir := filepath.Join(staticDir, "assets") if err := os.MkdirAll(assetsDir, 0o755); err != nil { t.Fatalf("mkdir assets: %v", err) } files := map[string]string{ "index.html": "", "assets/index.css": "body{}", "assets/index.js": "console.log(1)", "assets/icon.svg": ``, "assets/source.js.map": `{"version":3}`, } for rel, body := range files { full := filepath.Join(staticDir, filepath.FromSlash(rel)) if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { t.Fatalf("mkdir %s: %v", rel, err) } if err := os.WriteFile(full, []byte(body), 0o644); err != nil { t.Fatalf("write %s: %v", rel, err) } } h := &Handler{StaticDir: staticDir} cases := []struct { urlPath string wantPrefix string wantCacheCtl string }{ {"/admin/assets/index.css", "text/css", "public, max-age=31536000, immutable"}, {"/admin/assets/index.js", "text/javascript", "public, max-age=31536000, immutable"}, {"/admin/assets/icon.svg", "image/svg+xml", "public, max-age=31536000, immutable"}, {"/admin/assets/source.js.map", "application/json", "public, max-age=31536000, immutable"}, // "/admin/index.html" is intentionally omitted: http.ServeFile redirects // requests for index.html to "./", matching Go's net/http behavior. The // route the SPA actually lands on is "/admin/" below. {"/admin/", "text/html", "no-store, must-revalidate"}, } for _, tc := range cases { t.Run(tc.urlPath, func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, tc.urlPath, nil) rec := httptest.NewRecorder() h.serveFromDisk(rec, req, staticDir) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rec.Code) } ct := rec.Header().Get("Content-Type") if !strings.HasPrefix(ct, tc.wantPrefix) { t.Fatalf("Content-Type = %q, want prefix %q", ct, tc.wantPrefix) } if got := rec.Header().Get("Cache-Control"); got != tc.wantCacheCtl { t.Fatalf("Cache-Control = %q, want %q", got, tc.wantCacheCtl) } }) } } // TestSetStaticContentTypeUnknownExtensionFallsThrough verifies that unknown // extensions leave the Content-Type header unset, so http.ServeFile can apply // its own detection (sniffing or mime.TypeByExtension) for cases the pinned // table does not cover. func TestSetStaticContentTypeUnknownExtensionFallsThrough(t *testing.T) { rec := httptest.NewRecorder() setStaticContentType(rec, "/tmp/data.unknownext") if got := rec.Header().Get("Content-Type"); got != "" { t.Fatalf("Content-Type = %q, want empty for unknown extension", got) } } // TestSetStaticContentTypeIsCaseInsensitive guards against a regression where // uppercase extensions (e.g. STYLE.CSS shipped from some build pipelines) // would bypass the pinned table and fall back to the registry on Windows. func TestSetStaticContentTypeIsCaseInsensitive(t *testing.T) { rec := httptest.NewRecorder() setStaticContentType(rec, "/tmp/STYLE.CSS") if got := rec.Header().Get("Content-Type"); !strings.HasPrefix(got, "text/css") { t.Fatalf("Content-Type = %q, want text/css prefix", got) } }