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("spa-index\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), "