Files
Agent-System/internal/server/static_assets_test.go
T
weichenandCursor 9335c4c90c 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>
2026-09-17 08:18:11 +00:00

346 lines
11 KiB
Go

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)
}
}