381 lines
13 KiB
Go
381 lines
13 KiB
Go
package webui
|
|
|
|
import (
|
|
"bytes"
|
|
"mime"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"testing/fstest"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"gpt-load/internal/platform/httproute"
|
|
)
|
|
|
|
func TestServerServesSameIndexForExplicitPageRoutes(t *testing.T) {
|
|
const wantCSP = "default-src 'self'; script-src 'self'; style-src 'self'; " +
|
|
"style-src-elem 'self'; style-src-attr 'unsafe-inline'; " +
|
|
"img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; " +
|
|
"base-uri 'self'; frame-ancestors 'none'; form-action 'self'"
|
|
|
|
server := newServer(fstest.MapFS{
|
|
"dist/index.html": &fstest.MapFile{Data: []byte("<!doctype html><title>test index</title>")},
|
|
}, "dist")
|
|
engine := testEngine(server)
|
|
|
|
var firstBody string
|
|
for _, target := range []string{
|
|
"/", "/login", "/import", "/groups/42", "/access-keys", "/monitor?tab=logs", "/models", "/settings",
|
|
} {
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, target, nil))
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("GET %s status = %d, want 200", target, recorder.Code)
|
|
}
|
|
if firstBody == "" {
|
|
firstBody = recorder.Body.String()
|
|
}
|
|
if recorder.Body.String() != firstBody {
|
|
t.Fatalf("GET %s body differs from index", target)
|
|
}
|
|
if got := recorder.Header().Get("Cache-Control"); got != "no-cache" {
|
|
t.Fatalf("GET %s Cache-Control = %q, want no-cache", target, got)
|
|
}
|
|
if got := recorder.Header().Get("Content-Type"); !strings.HasPrefix(got, "text/html") {
|
|
t.Fatalf("GET %s Content-Type = %q, want HTML", target, got)
|
|
}
|
|
if got := recorder.Header().Get("X-Content-Type-Options"); got != "nosniff" {
|
|
t.Fatalf("GET %s X-Content-Type-Options = %q", target, got)
|
|
}
|
|
if got := recorder.Header().Get("X-Frame-Options"); got != "DENY" {
|
|
t.Fatalf("GET %s X-Frame-Options = %q", target, got)
|
|
}
|
|
got := recorder.Header().Get("Content-Security-Policy")
|
|
if strings.Contains(got, "style-src 'self' 'unsafe-inline'") {
|
|
t.Fatalf("GET %s CSP allows broad inline styles: %q", target, got)
|
|
}
|
|
if got != wantCSP {
|
|
t.Fatalf("GET %s CSP = %q, want %q", target, got, wantCSP)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestServerServesModelsDeepLinkWithoutCatchingUnknownNestedPaths(t *testing.T) {
|
|
engine := testEngine(newServer(fstest.MapFS{
|
|
"dist/index.html": &fstest.MapFile{Data: []byte("<!doctype html><title>models</title>")},
|
|
}, "dist"))
|
|
|
|
models := httptest.NewRecorder()
|
|
engine.ServeHTTP(models, httptest.NewRequest(http.MethodGet, "/models", nil))
|
|
if models.Code != http.StatusOK || !strings.Contains(models.Body.String(), "<title>models</title>") {
|
|
t.Fatalf("GET /models = %d %q, want embedded index", models.Code, models.Body.String())
|
|
}
|
|
|
|
unknown := httptest.NewRecorder()
|
|
engine.ServeHTTP(
|
|
unknown,
|
|
httptest.NewRequest(http.MethodGet, "/models/unknown", nil),
|
|
)
|
|
if unknown.Code != http.StatusNotFound {
|
|
t.Fatalf("GET unknown nested models path = %d, want 404", unknown.Code)
|
|
}
|
|
}
|
|
|
|
func TestServerServesGroupsCollectionWithoutCatchingUnknownNestedPaths(t *testing.T) {
|
|
engine := testEngine(newServer(fstest.MapFS{
|
|
"dist/index.html": &fstest.MapFile{Data: []byte("<!doctype html><title>groups</title>")},
|
|
}, "dist"))
|
|
|
|
groups := httptest.NewRecorder()
|
|
engine.ServeHTTP(groups, httptest.NewRequest(http.MethodGet, "/groups", nil))
|
|
if groups.Code != http.StatusOK || !strings.Contains(groups.Body.String(), "<title>groups</title>") {
|
|
t.Fatalf("GET /groups = %d %q, want embedded index", groups.Code, groups.Body.String())
|
|
}
|
|
|
|
unknown := httptest.NewRecorder()
|
|
engine.ServeHTTP(
|
|
unknown,
|
|
httptest.NewRequest(http.MethodGet, "/groups/unknown/path", nil),
|
|
)
|
|
if unknown.Code != http.StatusNotFound {
|
|
t.Fatalf("GET unknown nested groups path = %d, want 404", unknown.Code)
|
|
}
|
|
}
|
|
|
|
func TestServerFallbackReturnsNotFoundForUnknownRequests(t *testing.T) {
|
|
server := newServer(fstest.MapFS{
|
|
"dist/index.html": &fstest.MapFile{Data: []byte("<!doctype html><title>fallback</title>")},
|
|
}, "dist")
|
|
engine := testEngine(server)
|
|
|
|
browserPage := httptest.NewRecorder()
|
|
browserPageRequest := httptest.NewRequest(http.MethodGet, "/phase-2-unknown-route", nil)
|
|
browserPageRequest.Header.Set("Accept", "text/html,application/xhtml+xml;q=0.9")
|
|
engine.ServeHTTP(browserPage, browserPageRequest)
|
|
if browserPage.Code != http.StatusNotFound ||
|
|
!strings.Contains(browserPage.Body.String(), "<title>fallback</title>") {
|
|
t.Fatalf(
|
|
"unknown browser page = %d %q, want 404 SPA index",
|
|
browserPage.Code,
|
|
browserPage.Body.String(),
|
|
)
|
|
}
|
|
|
|
for _, testCase := range []struct {
|
|
name string
|
|
method string
|
|
target string
|
|
accept string
|
|
}{
|
|
{name: "non HTML client", method: http.MethodGet, target: "/unknown"},
|
|
{
|
|
name: "HTML explicitly unacceptable",
|
|
method: http.MethodGet,
|
|
target: "/unknown",
|
|
accept: "text/html;q=0, application/json",
|
|
},
|
|
{name: "non GET browser request", method: http.MethodPost, target: "/unknown", accept: "text/html"},
|
|
{name: "control namespace", method: http.MethodGet, target: "/api/unknown", accept: "text/html"},
|
|
{name: "OpenAI namespace", method: http.MethodGet, target: "/v1/unknown", accept: "text/html"},
|
|
{name: "Gemini namespace", method: http.MethodGet, target: "/v1beta/unknown", accept: "text/html"},
|
|
{name: "health namespace", method: http.MethodGet, target: "/health/unknown", accept: "text/html"},
|
|
{
|
|
name: "Chrome DevTools workspace probe",
|
|
method: http.MethodGet,
|
|
target: "/.well-known/appspecific/com.chrome.devtools.json",
|
|
},
|
|
} {
|
|
t.Run(testCase.name, func(t *testing.T) {
|
|
recorder := httptest.NewRecorder()
|
|
request := httptest.NewRequest(testCase.method, testCase.target, nil)
|
|
if testCase.accept != "" {
|
|
request.Header.Set("Accept", testCase.accept)
|
|
}
|
|
engine.ServeHTTP(recorder, request)
|
|
if recorder.Code != http.StatusNotFound {
|
|
t.Fatalf(
|
|
"%s %s = %d %q, want 404",
|
|
testCase.method,
|
|
testCase.target,
|
|
recorder.Code,
|
|
recorder.Body.String(),
|
|
)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestServerUsesCompileFallbackWhenIndexIsMissing(t *testing.T) {
|
|
server := newServer(fstest.MapFS{
|
|
"dist/assets/embed-placeholder.txt": &fstest.MapFile{Data: []byte("marker")},
|
|
}, "dist")
|
|
recorder := httptest.NewRecorder()
|
|
|
|
testEngine(server).ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/", nil))
|
|
|
|
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), "前端资源尚未构建") {
|
|
t.Fatalf("fallback response = %d %q", recorder.Code, recorder.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestServerServesAssetsWithImmutableCaching(t *testing.T) {
|
|
server := newServer(fstest.MapFS{
|
|
"dist/assets/app.js": &fstest.MapFile{Data: []byte("export default true")},
|
|
"dist/assets/app.css": &fstest.MapFile{Data: []byte("body { color: black; }")},
|
|
"dist/assets/app": &fstest.MapFile{Data: []byte{0x00, 0x01}},
|
|
}, "dist")
|
|
engine := testEngine(server)
|
|
|
|
for _, testCase := range []struct {
|
|
name string
|
|
target string
|
|
wantMediaTypes []string
|
|
}{
|
|
{name: "javascript", target: "/assets/app.js", wantMediaTypes: []string{"text/javascript", "application/javascript"}},
|
|
{name: "stylesheet", target: "/assets/app.css", wantMediaTypes: []string{"text/css"}},
|
|
{name: "no extension", target: "/assets/app", wantMediaTypes: []string{"application/octet-stream"}},
|
|
} {
|
|
t.Run(testCase.name, func(t *testing.T) {
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, testCase.target, nil))
|
|
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("asset status = %d, want 200", recorder.Code)
|
|
}
|
|
contentType := recorder.Header().Get("Content-Type")
|
|
mediaType, _, err := mime.ParseMediaType(contentType)
|
|
if err != nil {
|
|
t.Fatalf("parse Content-Type %q: %v", contentType, err)
|
|
}
|
|
matches := false
|
|
for _, want := range testCase.wantMediaTypes {
|
|
if mediaType == want {
|
|
matches = true
|
|
break
|
|
}
|
|
}
|
|
if !matches {
|
|
t.Fatalf("Content-Type media type = %q, want one of %v", mediaType, testCase.wantMediaTypes)
|
|
}
|
|
if got := recorder.Header().Get("Cache-Control"); got != "public, max-age=31536000, immutable" {
|
|
t.Fatalf("Cache-Control = %q", got)
|
|
}
|
|
if got := recorder.Header().Get("X-Content-Type-Options"); got != "nosniff" {
|
|
t.Fatalf("X-Content-Type-Options = %q", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestServerServesThemeBootstrapAsExplicitRootAsset(t *testing.T) {
|
|
server := newServer(fstest.MapFS{
|
|
"dist/index.html": &fstest.MapFile{Data: []byte("<!doctype html>")},
|
|
"dist/theme-bootstrap.js": &fstest.MapFile{Data: []byte("document.documentElement.dataset.theme = 'dark'")},
|
|
}, "dist")
|
|
recorder := httptest.NewRecorder()
|
|
|
|
testEngine(server).ServeHTTP(
|
|
recorder,
|
|
httptest.NewRequest(http.MethodGet, "/theme-bootstrap.js", nil),
|
|
)
|
|
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("theme bootstrap status = %d, want 200", recorder.Code)
|
|
}
|
|
mediaType, _, err := mime.ParseMediaType(recorder.Header().Get("Content-Type"))
|
|
if err != nil {
|
|
t.Fatalf("parse theme bootstrap Content-Type: %v", err)
|
|
}
|
|
if mediaType != "text/javascript" && mediaType != "application/javascript" {
|
|
t.Fatalf("theme bootstrap Content-Type = %q, want JavaScript", mediaType)
|
|
}
|
|
if got := recorder.Header().Get("Cache-Control"); got != "no-cache" {
|
|
t.Fatalf("theme bootstrap Cache-Control = %q, want no-cache", got)
|
|
}
|
|
if got := recorder.Header().Get("X-Content-Type-Options"); got != "nosniff" {
|
|
t.Fatalf("theme bootstrap X-Content-Type-Options = %q, want nosniff", got)
|
|
}
|
|
if !strings.Contains(recorder.Body.String(), "dataset.theme") {
|
|
t.Fatalf("theme bootstrap body = %q", recorder.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestServerServesFaviconAsExplicitRootAsset(t *testing.T) {
|
|
want := []byte(`<svg xmlns="http://www.w3.org/2000/svg"><path d="M0 0h1v1z"/></svg>`)
|
|
server := newServer(fstest.MapFS{
|
|
"dist/index.html": &fstest.MapFile{Data: []byte("<!doctype html>")},
|
|
"dist/favicon.svg": &fstest.MapFile{Data: want},
|
|
}, "dist")
|
|
recorder := httptest.NewRecorder()
|
|
|
|
testEngine(server).ServeHTTP(
|
|
recorder,
|
|
httptest.NewRequest(http.MethodGet, "/favicon.svg", nil),
|
|
)
|
|
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("favicon status = %d, want 200", recorder.Code)
|
|
}
|
|
if got := recorder.Header().Get("Content-Type"); got != "image/svg+xml" {
|
|
t.Fatalf("favicon Content-Type = %q, want image/svg+xml", got)
|
|
}
|
|
if got := recorder.Header().Get("Cache-Control"); got != "no-cache" {
|
|
t.Fatalf("favicon Cache-Control = %q, want no-cache", got)
|
|
}
|
|
if got := recorder.Header().Get("X-Content-Type-Options"); got != "nosniff" {
|
|
t.Fatalf("favicon X-Content-Type-Options = %q, want nosniff", got)
|
|
}
|
|
if !bytes.Equal(recorder.Body.Bytes(), want) {
|
|
t.Fatalf("favicon body = %q, want %q", recorder.Body.Bytes(), want)
|
|
}
|
|
}
|
|
|
|
func TestServerDoesNotServeLegacyFaviconAlias(t *testing.T) {
|
|
legacy := []byte{0x00, 0x00, 0x01, 0x00}
|
|
server := newServer(fstest.MapFS{
|
|
"dist/index.html": &fstest.MapFile{Data: []byte("<!doctype html>")},
|
|
"dist/favicon.ico": &fstest.MapFile{Data: legacy},
|
|
}, "dist")
|
|
recorder := httptest.NewRecorder()
|
|
|
|
testEngine(server).ServeHTTP(
|
|
recorder,
|
|
httptest.NewRequest(http.MethodGet, "/favicon.ico", nil),
|
|
)
|
|
|
|
if recorder.Code != http.StatusNotFound {
|
|
t.Fatalf("legacy favicon status = %d, want 404", recorder.Code)
|
|
}
|
|
if bytes.Equal(recorder.Body.Bytes(), legacy) {
|
|
t.Fatalf("legacy favicon body = %v, want no ICO alias", recorder.Body.Bytes())
|
|
}
|
|
}
|
|
|
|
func TestServerDoesNotExposeFilesOutsideAssets(t *testing.T) {
|
|
const (
|
|
indexSecret = "outer-index-secret"
|
|
fileSecret = "outer-file-secret"
|
|
)
|
|
engine := testEngine(newServer(fstest.MapFS{
|
|
"dist/index.html": &fstest.MapFile{Data: []byte("<!doctype html>" + indexSecret)},
|
|
"dist/secret": &fstest.MapFile{Data: []byte(fileSecret)},
|
|
}, "dist"))
|
|
|
|
for _, testCase := range []struct {
|
|
name string
|
|
target string
|
|
}{
|
|
{name: "parent traversal", target: "/assets/../index.html"},
|
|
{name: "encoded parent traversal", target: "/assets/%2e%2e/secret"},
|
|
{name: "double slash absolute shape", target: "/assets//secret"},
|
|
{name: "encoded absolute shape", target: "/assets/%2Fsecret"},
|
|
} {
|
|
t.Run(testCase.name, func(t *testing.T) {
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, testCase.target, nil))
|
|
|
|
if recorder.Code == http.StatusOK {
|
|
t.Fatalf("GET %s status = 200, want rejection or redirect", testCase.target)
|
|
}
|
|
if body := recorder.Body.String(); strings.Contains(body, indexSecret) || strings.Contains(body, fileSecret) {
|
|
t.Fatalf("GET %s leaked content outside assets: %q", testCase.target, body)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestServerDoesNotHandleBackendOrUnknownRoutes(t *testing.T) {
|
|
engine := testEngine(newServer(fstest.MapFS{
|
|
"dist/index.html": &fstest.MapFile{Data: []byte("<!doctype html>")},
|
|
}, "dist"))
|
|
|
|
for _, target := range []string{
|
|
"/api/unknown", "/v1/models", "/unknown", "/settings/unknown", "/assets/missing.js",
|
|
} {
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, target, nil))
|
|
if recorder.Code != http.StatusNotFound {
|
|
t.Fatalf("GET %s status = %d, want 404", target, recorder.Code)
|
|
}
|
|
if strings.Contains(strings.ToLower(recorder.Body.String()), "<!doctype html") {
|
|
t.Fatalf("GET %s unexpectedly returned the UI index", target)
|
|
}
|
|
}
|
|
}
|
|
|
|
func testEngine(server *Server) *gin.Engine {
|
|
gin.SetMode(gin.TestMode)
|
|
engine := gin.New()
|
|
engine.RedirectTrailingSlash = false
|
|
registry, err := httproute.NewRegistry(server.HTTPModule())
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
if err := registry.Bind(engine); err != nil {
|
|
panic(err)
|
|
}
|
|
return engine
|
|
}
|