181 lines
6.3 KiB
Go
181 lines
6.3 KiB
Go
package webui
|
|
|
|
import (
|
|
"mime"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"testing/fstest"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
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", "/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 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 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", "/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
|
|
server.RegisterRoutes(engine)
|
|
return engine
|
|
}
|