feat(server): negotiate static compression and fix asset SPA fallback

Serve precompressed .br/.gz with Cache-Control and Vary, and return 404
for missing /assets and /fonts instead of index.html.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-17 08:18:11 +00:00
co-authored by Cursor
parent 3dbd9770b5
commit 9335c4c90c
5 changed files with 682 additions and 49 deletions
+2 -1
View File
@@ -13,6 +13,7 @@
| owner | task | branch | worktree | mutex | status |
|-------|------|--------|----------|-------|--------|
| Cursor | 2026-09-17-initial-load-perf-u2 | feature/initial-load-perf-u2 | - | web + static-delivery | in_progress |
| Codex | 2026-09-17-initial-load-performance-plan | feature/initial-load-performance-plan | .worktrees/feature-initial-load-performance-plan | loading-performance-audit | done |
| Kimi | 2026-09-17-system-admin-console | feature/system-admin-console | - | web + sysadmin | done |
| Cursor-Auto | 2026-09-17-engineering-dashboard | feature/engineering-dashboard | .worktrees/feature-engineering-dashboard | engineering-dashboard | done |
@@ -124,7 +125,7 @@
| holder | phase | since | scope | queue |
|--------|-------|-------|-------|-------|
| - | idle | - | - | - |
| Cursor | merging | 2026-09-17T08:08:45Z | initial-load-perf-u2(字体/压缩/静态缓存) | - |
**phase**: `idle` | `merging` | `smoke` | `e2e`
@@ -0,0 +1,53 @@
---
title: "首次加载 U2:字体自托管与静态资源交付"
status: in_progress
created: 2026-09-17
updated: 2026-09-17
owner: Cursor
branch: feature/initial-load-perf-u2
worktree: "-"
mutex: web + static-delivery
---
# 首次加载 U2:字体自托管与静态资源交付
## 目标
登录页不再依赖 Google Fonts 阻塞 CSS;8080/8081 静态资源支持 gzip/br 协商与分层缓存;缺失 `/assets`/`/fonts` 返回 404 而非 HTML。
## 范围
- 自托管 Inter / JetBrains Mono(latin + latin-ext 可变 WOFF2)
- 构建期预压缩 `.br` / `.gz`
- Go `serveSPA` 压缩协商、Cache-Control、Vary、assets 404
- 单元测试与 rebuild 后 curl/浏览器验收
## 非目标
- U1 完整 Playwright 性能套件
- U3 拆包懒加载 / katex 入口 CSS
- U4–U6 初始化扇出、认证、SQL
- U7 完整重启归因(本轮仅借 rebuild 采集起点证据)
## 步骤
1. [x] 诊断/方案文档落盘并合入 `dev`
2. [x] 自托管字体 + 移除外链
3. [x] 预压缩脚本接入 build
4. [x] Go 静态交付修复
5. [ ] 测试与 rebuild 验收
6. [ ] 测试报告与文档同步
## 验收标准
- [ ] 登录页无 `fonts.googleapis.com` / `fonts.gstatic.com` 请求
- [ ] 主 JS 响应带 `Content-Encoding: br` 或 `gzip`,体积显著下降
- [ ] `/assets/missing.js` → 404 且非 HTML
- [ ] HTML `Cache-Control: no-cache`;哈希资源 `immutable`
- [ ] SSE / API 缓存行为未回归
## 相关链接
- 方案: [../../remediation/2026-09-17-initial-load-performance-plan.md](../../remediation/2026-09-17-initial-load-performance-plan.md)
- 审计: [../../audit/2026-09-17-initial-load-performance-audit.md](../../audit/2026-09-17-initial-load-performance-audit.md)
- Test report: (验收后补)
-48
View File
@@ -4,9 +4,6 @@ import (
"context"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
@@ -339,51 +336,6 @@ func (s *Server) Routes() http.Handler {
return s.corsMiddleware(s.bodyLimitMiddleware(s.authMiddleware(mux)))
}
// serveSPA 托管 web/dist 静态产物并提供前端路由自动回退
func (s *Server) serveSPA(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api/") {
http.NotFound(w, r)
return
}
candidates := []string{
"web/dist",
"/app/web/dist",
"../web/dist",
"../../web/dist",
filepath.Join(s.cfg.DataDir, "web/dist"),
filepath.Join(s.cfg.DataDir, "../web/dist"),
}
var distDir string
for _, dir := range candidates {
if fi, err := os.Stat(filepath.Join(dir, "index.html")); err == nil && !fi.IsDir() {
distDir = dir
break
}
}
if distDir == "" {
http.NotFound(w, r)
return
}
reqPath := filepath.Clean(r.URL.Path)
if reqPath == "/" {
http.ServeFile(w, r, filepath.Join(distDir, "index.html"))
return
}
targetFile := filepath.Join(distDir, reqPath)
if fi, err := os.Stat(targetFile); err == nil && !fi.IsDir() {
http.ServeFile(w, r, targetFile)
return
}
// SPA Fallback 默认回退 index.html
http.ServeFile(w, r, filepath.Join(distDir, "index.html"))
}
// Start 启动 HTTP 守护进程
func (s *Server) Start() error {
if s.rt != nil {
+282
View File
@@ -0,0 +1,282 @@
package server
import (
"mime"
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
)
// serveSPA 托管 web/dist 静态产物并提供前端路由自动回退。
// /assets 与 /fonts 下缺失文件返回 404(不回退 HTML),避免旧 chunk 拿到 HTML 当 JS。
func (s *Server) serveSPA(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api/") {
http.NotFound(w, r)
return
}
distDir := resolveDistDir(s.cfg.DataDir)
if distDir == "" {
http.NotFound(w, r)
return
}
reqPath := filepath.Clean(r.URL.Path)
if reqPath == "/" || reqPath == "." {
serveStaticFile(w, r, filepath.Join(distDir, "index.html"), "no-cache", true)
return
}
// Prevent path escape outside distDir.
targetFile := filepath.Join(distDir, strings.TrimPrefix(reqPath, string(filepath.Separator)))
if !isPathInside(distDir, targetFile) {
http.NotFound(w, r)
return
}
noFallback := strings.HasPrefix(reqPath, "/assets/") || strings.HasPrefix(reqPath, "/fonts/")
if fi, err := os.Stat(targetFile); err == nil && !fi.IsDir() {
serveStaticFile(w, r, targetFile, cacheControlFor(reqPath), true)
return
}
if noFallback {
http.NotFound(w, r)
return
}
// SPA client-route fallback
serveStaticFile(w, r, filepath.Join(distDir, "index.html"), "no-cache", true)
}
func resolveDistDir(dataDir string) string {
candidates := []string{
"web/dist",
"/app/web/dist",
"../web/dist",
"../../web/dist",
filepath.Join(dataDir, "web/dist"),
filepath.Join(dataDir, "../web/dist"),
}
for _, dir := range candidates {
if fi, err := os.Stat(filepath.Join(dir, "index.html")); err == nil && !fi.IsDir() {
return dir
}
}
return ""
}
func isPathInside(root, candidate string) bool {
absRoot, err1 := filepath.Abs(root)
absCand, err2 := filepath.Abs(candidate)
if err1 != nil || err2 != nil {
return false
}
rel, err := filepath.Rel(absRoot, absCand)
if err != nil {
return false
}
return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
}
func cacheControlFor(reqPath string) string {
if strings.HasPrefix(reqPath, "/assets/") || strings.HasPrefix(reqPath, "/fonts/") {
return "public, max-age=31536000, immutable"
}
base := filepath.Base(reqPath)
if base == "index.html" {
return "no-cache"
}
// Root non-hashed assets (favicon, etc.)
return "public, max-age=3600"
}
func serveStaticFile(w http.ResponseWriter, r *http.Request, filePath, cacheControl string, negotiateCompress bool) {
w.Header().Set("Cache-Control", cacheControl)
fi, err := os.Stat(filePath)
if err != nil || fi.IsDir() {
http.NotFound(w, r)
return
}
// Range requests: serve identity only (encoded byte ranges are ambiguous).
useCompress := negotiateCompress && r.Header.Get("Range") == ""
encoding, variantPath := "", filePath
if useCompress {
encoding, variantPath = pickCompressedVariant(filePath, r.Header.Get("Accept-Encoding"))
}
// Always Vary when compression negotiation is in play for compressible types.
if negotiateCompress && isCompressiblePath(filePath) {
w.Header().Set("Vary", "Accept-Encoding")
}
openPath := filePath
if encoding != "" {
if vfi, verr := os.Stat(variantPath); verr == nil && !vfi.IsDir() {
openPath = variantPath
w.Header().Set("Content-Encoding", encoding)
if ct := contentTypeFor(filePath); ct != "" {
w.Header().Set("Content-Type", ct)
}
} else {
encoding = ""
openPath = filePath
}
}
f, err := os.Open(openPath)
if err != nil {
http.NotFound(w, r)
return
}
defer f.Close()
// Use original file modtime for conditional requests so identity/compressed stay consistent.
http.ServeContent(w, r, filepath.Base(filePath), fi.ModTime(), f)
}
func isCompressiblePath(path string) bool {
ext := strings.ToLower(filepath.Ext(path))
switch ext {
case ".js", ".css", ".html", ".svg", ".json", ".txt", ".xml", ".map":
return true
default:
return false
}
}
func contentTypeFor(path string) string {
ext := filepath.Ext(path)
if ext == "" {
return ""
}
if ct := mime.TypeByExtension(ext); ct != "" {
return ct
}
switch strings.ToLower(ext) {
case ".js":
return "text/javascript; charset=utf-8"
case ".css":
return "text/css; charset=utf-8"
case ".html":
return "text/html; charset=utf-8"
case ".svg":
return "image/svg+xml"
case ".json":
return "application/json"
case ".woff2":
return "font/woff2"
default:
return ""
}
}
type encodingOffer struct {
name string
q float64
}
// pickCompressedVariant selects br or gzip based on Accept-Encoding q-values.
// Returns ("", original) when identity should be used.
func pickCompressedVariant(filePath, acceptEncoding string) (encoding, variantPath string) {
if !isCompressiblePath(filePath) {
return "", filePath
}
offers := parseAcceptEncoding(acceptEncoding)
if len(offers) == 0 {
return "", filePath
}
identityQ := 1.0
identityExplicit := false
starQ := -1.0
preferred := make([]encodingOffer, 0, 2)
for _, o := range offers {
switch o.name {
case "identity":
identityQ = o.q
identityExplicit = true
case "*":
starQ = o.q
case "br", "gzip":
if o.q > 0 {
preferred = append(preferred, o)
}
}
}
sort.SliceStable(preferred, func(i, j int) bool {
if preferred[i].q != preferred[j].q {
return preferred[i].q > preferred[j].q
}
// Prefer br over gzip at equal q.
if preferred[i].name == "br" && preferred[j].name != "br" {
return true
}
return false
})
for _, o := range preferred {
var variant string
switch o.name {
case "br":
variant = filePath + ".br"
case "gzip":
variant = filePath + ".gz"
default:
continue
}
if fi, err := os.Stat(variant); err == nil && !fi.IsDir() {
if o.q >= identityQ || !identityExplicit {
return o.name, variant
}
}
}
// If identity is forbidden (q=0) but a wildcard allows compression, already handled.
// If identity q=0 and no compressed variant, fall through to identity anyway
// (cannot invent an encoding); callers still serve the file.
_ = starQ
return "", filePath
}
func parseAcceptEncoding(header string) []encodingOffer {
header = strings.TrimSpace(header)
if header == "" {
return nil
}
parts := strings.Split(header, ",")
out := make([]encodingOffer, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
name := part
q := 1.0
if semi := strings.IndexByte(part, ';'); semi >= 0 {
name = strings.TrimSpace(part[:semi])
params := strings.Split(part[semi+1:], ";")
for _, p := range params {
p = strings.TrimSpace(p)
if strings.HasPrefix(strings.ToLower(p), "q=") {
if v, err := strconv.ParseFloat(strings.TrimSpace(p[2:]), 64); err == nil {
q = v
}
}
}
}
name = strings.ToLower(strings.TrimSpace(name))
if name == "" {
continue
}
out = append(out, encodingOffer{name: name, q: q})
}
return out
}
+345
View File
@@ -0,0 +1,345 @@
package server
import (
"bytes"
"compress/gzip"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"chat-agent-system/internal/config"
)
func setupStaticFixture(t *testing.T) (handler http.Handler, distDir string) {
t.Helper()
dataDir := t.TempDir()
// Avoid picking up the repo's real web/dist via relative candidates.
t.Chdir(dataDir)
distDir = filepath.Join(dataDir, "web", "dist")
assetsDir := filepath.Join(distDir, "assets")
fontsDir := filepath.Join(distDir, "fonts")
if err := os.MkdirAll(assetsDir, 0o755); err != nil {
t.Fatalf("mkdir assets: %v", err)
}
if err := os.MkdirAll(fontsDir, 0o755); err != nil {
t.Fatalf("mkdir fonts: %v", err)
}
indexHTML := []byte("<!doctype html><html><body>spa-index</body></html>\n")
jsBody := bytes.Repeat([]byte("console.log('payload-for-compression-test');\n"), 80)
fontBody := []byte("woff2-fake-bytes-not-really-a-font")
mustWrite := func(path string, body []byte) {
t.Helper()
if err := os.WriteFile(path, body, 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
mustWrite(filepath.Join(distDir, "index.html"), indexHTML)
mustWrite(filepath.Join(assetsDir, "index-abc.js"), jsBody)
mustWrite(filepath.Join(fontsDir, "inter-v20-latin.woff2"), fontBody)
mustWrite(filepath.Join(distDir, "favicon.ico"), []byte{0, 0, 1, 0})
// Precompressed variants (content is gzip of jsBody; br uses same bytes for test
// identity — we only assert Content-Encoding / length / headers, not decode br).
var gzBuf bytes.Buffer
zw := gzip.NewWriter(&gzBuf)
if _, err := zw.Write(jsBody); err != nil {
t.Fatalf("gzip write: %v", err)
}
if err := zw.Close(); err != nil {
t.Fatalf("gzip close: %v", err)
}
mustWrite(filepath.Join(assetsDir, "index-abc.js.gz"), gzBuf.Bytes())
// Smaller fake br payload so Content-Length differs from identity/gzip.
brBody := append([]byte("BR"), jsBody[:40]...)
mustWrite(filepath.Join(assetsDir, "index-abc.js.br"), brBody)
cfg := &config.Config{
Env: "test",
AgentBackend: "stub",
DataDir: dataDir,
}
srv := newServerWithMemoryIAM(t, cfg)
srv.cfg.AllowInMemory = false
return srv.Routes(), distDir
}
func TestStaticAssets_CompressionNegotiation(t *testing.T) {
handler, _ := setupStaticFixture(t)
t.Run("prefers_br", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/assets/index-abc.js", nil)
req.Header.Set("Accept-Encoding", "gzip, deflate, br")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
if got := w.Header().Get("Content-Encoding"); got != "br" {
t.Fatalf("Content-Encoding=%q want br", got)
}
if !strings.Contains(w.Header().Get("Vary"), "Accept-Encoding") {
t.Fatalf("Vary missing Accept-Encoding: %q", w.Header().Get("Vary"))
}
if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, "javascript") {
t.Fatalf("Content-Type=%q want javascript", ct)
}
if w.Header().Get("Cache-Control") != "public, max-age=31536000, immutable" {
t.Fatalf("Cache-Control=%q", w.Header().Get("Cache-Control"))
}
if !bytes.HasPrefix(w.Body.Bytes(), []byte("BR")) {
t.Fatalf("body not br fixture prefix")
}
})
t.Run("gzip_only", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/assets/index-abc.js", nil)
req.Header.Set("Accept-Encoding", "gzip")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status=%d", w.Code)
}
if got := w.Header().Get("Content-Encoding"); got != "gzip" {
t.Fatalf("Content-Encoding=%q want gzip", got)
}
if !strings.Contains(w.Header().Get("Vary"), "Accept-Encoding") {
t.Fatalf("Vary=%q", w.Header().Get("Vary"))
}
zr, err := gzip.NewReader(bytes.NewReader(w.Body.Bytes()))
if err != nil {
t.Fatalf("gzip reader: %v", err)
}
defer zr.Close()
decoded, err := io.ReadAll(zr)
if err != nil {
t.Fatalf("gzip decode: %v", err)
}
if !bytes.Contains(decoded, []byte("payload-for-compression-test")) {
t.Fatalf("decoded body missing payload")
}
})
t.Run("gzip_q0_identity", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/assets/index-abc.js", nil)
req.Header.Set("Accept-Encoding", "gzip;q=0")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status=%d", w.Code)
}
if got := w.Header().Get("Content-Encoding"); got != "" {
t.Fatalf("Content-Encoding=%q want empty", got)
}
if !strings.Contains(w.Header().Get("Vary"), "Accept-Encoding") {
t.Fatalf("Vary=%q", w.Header().Get("Vary"))
}
if !bytes.Contains(w.Body.Bytes(), []byte("payload-for-compression-test")) {
t.Fatalf("identity body missing payload")
}
})
t.Run("no_accept_encoding_identity", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/assets/index-abc.js", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status=%d", w.Code)
}
if got := w.Header().Get("Content-Encoding"); got != "" {
t.Fatalf("Content-Encoding=%q want empty", got)
}
if !strings.Contains(w.Header().Get("Vary"), "Accept-Encoding") {
t.Fatalf("Vary=%q", w.Header().Get("Vary"))
}
})
}
func TestStaticAssets_HEADAndConditional(t *testing.T) {
handler, _ := setupStaticFixture(t)
req := httptest.NewRequest(http.MethodGet, "/assets/index-abc.js", nil)
req.Header.Set("Accept-Encoding", "gzip")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("GET status=%d", w.Code)
}
lastMod := w.Header().Get("Last-Modified")
if lastMod == "" {
t.Fatal("expected Last-Modified")
}
head := httptest.NewRequest(http.MethodHead, "/assets/index-abc.js", nil)
head.Header.Set("Accept-Encoding", "gzip")
hw := httptest.NewRecorder()
handler.ServeHTTP(hw, head)
if hw.Code != http.StatusOK {
t.Fatalf("HEAD status=%d", hw.Code)
}
if hw.Header().Get("Content-Encoding") != "gzip" {
t.Fatalf("HEAD Content-Encoding=%q", hw.Header().Get("Content-Encoding"))
}
if hw.Body.Len() != 0 {
t.Fatalf("HEAD body len=%d", hw.Body.Len())
}
modTime, err := http.ParseTime(lastMod)
if err != nil {
t.Fatalf("parse Last-Modified: %v", err)
}
cond := httptest.NewRequest(http.MethodGet, "/assets/index-abc.js", nil)
cond.Header.Set("Accept-Encoding", "gzip")
cond.Header.Set("If-Modified-Since", modTime.UTC().Format(http.TimeFormat))
cw := httptest.NewRecorder()
handler.ServeHTTP(cw, cond)
if cw.Code != http.StatusNotModified {
// ServeContent compares with second precision; bump if flaky.
if cw.Code != http.StatusOK {
t.Fatalf("conditional status=%d", cw.Code)
}
// Retry with far-future IMS
cond2 := httptest.NewRequest(http.MethodGet, "/assets/index-abc.js", nil)
cond2.Header.Set("Accept-Encoding", "gzip")
cond2.Header.Set("If-Modified-Since", time.Now().UTC().Add(24*time.Hour).Format(http.TimeFormat))
cw2 := httptest.NewRecorder()
handler.ServeHTTP(cw2, cond2)
if cw2.Code != http.StatusNotModified {
t.Fatalf("conditional with future IMS status=%d", cw2.Code)
}
}
}
func TestStaticAssets_RangeUsesIdentity(t *testing.T) {
handler, _ := setupStaticFixture(t)
req := httptest.NewRequest(http.MethodGet, "/assets/index-abc.js", nil)
req.Header.Set("Accept-Encoding", "gzip, br")
req.Header.Set("Range", "bytes=0-15")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusPartialContent {
t.Fatalf("status=%d want 206", w.Code)
}
if got := w.Header().Get("Content-Encoding"); got != "" {
t.Fatalf("Range must use identity, got Content-Encoding=%q", got)
}
if w.Body.Len() != 16 {
t.Fatalf("range body len=%d", w.Body.Len())
}
}
func TestStaticAssets_MissingAssetIs404NotHTML(t *testing.T) {
handler, _ := setupStaticFixture(t)
req := httptest.NewRequest(http.MethodGet, "/assets/missing-hash.js", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("status=%d want 404", w.Code)
}
body := w.Body.String()
if strings.Contains(body, "spa-index") || strings.Contains(strings.ToLower(body), "<!doctype html") {
t.Fatalf("missing asset returned HTML fallback: %q", body)
}
}
func TestStaticAssets_MissingFontIs404(t *testing.T) {
handler, _ := setupStaticFixture(t)
req := httptest.NewRequest(http.MethodGet, "/fonts/missing.woff2", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("status=%d want 404", w.Code)
}
}
func TestStaticAssets_SPAFallbackAndCache(t *testing.T) {
handler, _ := setupStaticFixture(t)
req := httptest.NewRequest(http.MethodGet, "/sessions/xxx", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status=%d", w.Code)
}
if !bytes.Contains(w.Body.Bytes(), []byte("spa-index")) {
t.Fatalf("expected index fallback, got %q", w.Body.String())
}
if w.Header().Get("Cache-Control") != "no-cache" {
t.Fatalf("Cache-Control=%q want no-cache", w.Header().Get("Cache-Control"))
}
root := httptest.NewRequest(http.MethodGet, "/", nil)
rw := httptest.NewRecorder()
handler.ServeHTTP(rw, root)
if rw.Header().Get("Cache-Control") != "no-cache" {
t.Fatalf("root Cache-Control=%q", rw.Header().Get("Cache-Control"))
}
fav := httptest.NewRequest(http.MethodGet, "/favicon.ico", nil)
fw := httptest.NewRecorder()
handler.ServeHTTP(fw, fav)
if fw.Code != http.StatusOK {
t.Fatalf("favicon status=%d", fw.Code)
}
if fw.Header().Get("Cache-Control") != "public, max-age=3600" {
t.Fatalf("favicon Cache-Control=%q", fw.Header().Get("Cache-Control"))
}
font := httptest.NewRequest(http.MethodGet, "/fonts/inter-v20-latin.woff2", nil)
ftw := httptest.NewRecorder()
handler.ServeHTTP(ftw, font)
if ftw.Code != http.StatusOK {
t.Fatalf("font status=%d", ftw.Code)
}
if ftw.Header().Get("Cache-Control") != "public, max-age=31536000, immutable" {
t.Fatalf("font Cache-Control=%q", ftw.Header().Get("Cache-Control"))
}
if ftw.Header().Get("Content-Encoding") != "" {
t.Fatalf("woff2 should not be compressed, got %q", ftw.Header().Get("Content-Encoding"))
}
}
func TestStaticAssets_PathTraversal(t *testing.T) {
handler, _ := setupStaticFixture(t)
req := httptest.NewRequest(http.MethodGet, "/assets/../../../etc/passwd", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
// Cleaned path may become SPA fallback or 404; must not leak /etc/passwd content.
body := w.Body.String()
if strings.Contains(body, "root:") || strings.Contains(body, "/bin/") {
t.Fatalf("possible path traversal leak: %q", body)
}
}
func TestStaticAssets_APIUnauthenticatedStill401(t *testing.T) {
handler, _ := setupStaticFixture(t)
req := httptest.NewRequest(http.MethodGet, "/api/v1/chat/sessions", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("status=%d want 401", w.Code)
}
if w.Header().Get("Cache-Control") == "public, max-age=31536000, immutable" {
t.Fatal("API must not receive immutable cache header")
}
}
func TestParseAcceptEncoding(t *testing.T) {
offers := parseAcceptEncoding("gzip;q=0.5, br, identity;q=0")
if len(offers) != 3 {
t.Fatalf("len=%d", len(offers))
}
byName := map[string]float64{}
for _, o := range offers {
byName[o.name] = o.q
}
if byName["br"] != 1.0 || byName["gzip"] != 0.5 || byName["identity"] != 0 {
t.Fatalf("offers=%v", offers)
}
}