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>
346 lines
11 KiB
Go
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)
|
|
}
|
|
}
|