Files
3x-ui/internal/web/assets_test.go
T
MHSanaei 3526a65f5d fix(web): bound the panel's open connections and share asset gzip
The engine-wide gzip middleware gave every in-flight request its own
compressor of about 1 MiB, and the public /assets bundles let an
anonymous client keep a handler blocked in Write until WriteTimeout, so
a thousand stalled downloads held a gigabyte. The listener itself took
any number of connections, each holding buffers and a goroutine before
any login.

The bundles are now served from a gzip copy made once per file, with
the assets prefix left out of the per-request middleware, and the panel
listener closes a new connection once 2048 are open or its source (an
IPv4 address or IPv6 /64) holds 256; a loopback reverse proxy counts
only toward the total.
2026-11-01 01:01:38 +01:00

90 lines
2.7 KiB
Go

package web
import (
"bytes"
"compress/gzip"
"io"
"io/fs"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
)
// Any per-request compressor here is pinned for as long as an anonymous client
// stalls its download, so the bundles must come out as one shared gzip copy.
func TestAssetsServedFromSharedGzipCopy(t *testing.T) {
entries, err := fs.ReadDir(distFS, "dist/assets")
if err != nil || len(entries) == 0 {
t.Skip("no built frontend bundles embedded (dist stub)")
}
name := ""
for _, e := range entries {
if strings.HasSuffix(e.Name(), ".js") {
name = e.Name()
break
}
}
if name == "" {
t.Skip("no .js bundle embedded")
}
raw, err := fs.ReadFile(distFS, "dist/assets/"+name)
if err != nil {
t.Fatal(err)
}
engine := newTestEngine(t)
get := func(acceptEncoding string) *http.Response {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/assets/"+name, nil)
req.Header.Set("Accept-Encoding", acceptEncoding)
w := httptest.NewRecorder()
engine.ServeHTTP(w, req)
return w.Result()
}
for range 2 {
res := get("gzip")
body, _ := io.ReadAll(res.Body)
if res.StatusCode != http.StatusOK || res.Header.Get("Content-Encoding") != "gzip" {
t.Fatalf("gzip download: got %d, Content-Encoding %q", res.StatusCode, res.Header.Get("Content-Encoding"))
}
// A precompressed copy is sent with its length; a streamed compressor can't know it.
if got := res.Header.Get("Content-Length"); got != strconv.Itoa(len(body)) {
t.Errorf("Content-Length = %q, want %d: bundle was compressed per request", got, len(body))
}
if res.Header.Get("Last-Modified") == "" {
t.Error("Last-Modified missing: assets FS no longer stamps startTime")
}
zr, err := gzip.NewReader(bytes.NewReader(body))
if err != nil {
t.Fatal(err)
}
if plain, _ := io.ReadAll(zr); !bytes.Equal(plain, raw) {
t.Fatal("gzip download decompresses to something other than the bundle")
}
}
res := get("")
body, _ := io.ReadAll(res.Body)
if res.StatusCode != http.StatusOK || res.Header.Get("Content-Encoding") != "" || !bytes.Equal(body, raw) {
t.Fatalf("plain download: got %d, Content-Encoding %q, %d bytes", res.StatusCode, res.Header.Get("Content-Encoding"), len(body))
}
}
// The assets route never listed its directory; serving the bundles from a shared copy
// must not turn that 404 into a 500.
func TestAssetsDirectoryIsNotFound(t *testing.T) {
engine := newTestEngine(t)
for _, encoding := range []string{"", "gzip"} {
req := httptest.NewRequest(http.MethodGet, "/assets/", nil)
req.Header.Set("Accept-Encoding", encoding)
w := httptest.NewRecorder()
engine.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("GET /assets/ with Accept-Encoding %q = %d, want 404", encoding, w.Code)
}
}
}