feat(安全): 实现 CSP nonce 支持解决内联脚本安全问题
- 添加 GenerateNonce() 生成加密安全的随机 nonce - SecurityHeaders 中间件为每个请求生成唯一 nonce - CSP 策略支持 __CSP_NONCE__ 占位符动态替换 - embed_on.go 注入的内联脚本添加 nonce 属性 - 添加 Cloudflare Insights 域名到 CSP 允许列表 - 添加完整单元测试,覆盖率达到 89.8% 解决的问题: - 内联脚本违反 CSP script-src 指令 - Cloudflare Insights beacon.min.js 加载被阻止 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,9 +13,15 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
// NonceHTMLPlaceholder is the placeholder for nonce in HTML script tags
|
||||
NonceHTMLPlaceholder = "__CSP_NONCE_VALUE__"
|
||||
)
|
||||
|
||||
//go:embed all:dist
|
||||
var frontendFS embed.FS
|
||||
|
||||
@@ -115,6 +121,9 @@ func (s *FrontendServer) fileExists(path string) bool {
|
||||
}
|
||||
|
||||
func (s *FrontendServer) serveIndexHTML(c *gin.Context) {
|
||||
// Get nonce from context (generated by SecurityHeaders middleware)
|
||||
nonce := middleware.GetNonceFromContext(c)
|
||||
|
||||
// Check cache first
|
||||
cached := s.cache.Get()
|
||||
if cached != nil {
|
||||
@@ -125,9 +134,12 @@ func (s *FrontendServer) serveIndexHTML(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Replace nonce placeholder with actual nonce before serving
|
||||
content := replaceNoncePlaceholder(cached.Content, nonce)
|
||||
|
||||
c.Header("ETag", cached.ETag)
|
||||
c.Header("Cache-Control", "no-cache") // Must revalidate
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", cached.Content)
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", content)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
@@ -155,24 +167,33 @@ func (s *FrontendServer) serveIndexHTML(c *gin.Context) {
|
||||
rendered := s.injectSettings(settingsJSON)
|
||||
s.cache.Set(rendered, settingsJSON)
|
||||
|
||||
// Replace nonce placeholder with actual nonce before serving
|
||||
content := replaceNoncePlaceholder(rendered, nonce)
|
||||
|
||||
cached = s.cache.Get()
|
||||
if cached != nil {
|
||||
c.Header("ETag", cached.ETag)
|
||||
}
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", rendered)
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", content)
|
||||
c.Abort()
|
||||
}
|
||||
|
||||
func (s *FrontendServer) injectSettings(settingsJSON []byte) []byte {
|
||||
// Create the script tag to inject
|
||||
script := []byte(`<script>window.__APP_CONFIG__=` + string(settingsJSON) + `;</script>`)
|
||||
// Create the script tag to inject with nonce placeholder
|
||||
// The placeholder will be replaced with actual nonce at request time
|
||||
script := []byte(`<script nonce="` + NonceHTMLPlaceholder + `">window.__APP_CONFIG__=` + string(settingsJSON) + `;</script>`)
|
||||
|
||||
// Inject before </head>
|
||||
headClose := []byte("</head>")
|
||||
return bytes.Replace(s.baseHTML, headClose, append(script, headClose...), 1)
|
||||
}
|
||||
|
||||
// replaceNoncePlaceholder replaces the nonce placeholder with actual nonce value
|
||||
func replaceNoncePlaceholder(html []byte, nonce string) []byte {
|
||||
return bytes.ReplaceAll(html, []byte(NonceHTMLPlaceholder), []byte(nonce))
|
||||
}
|
||||
|
||||
// ServeEmbeddedFrontend returns a middleware for serving embedded frontend
|
||||
// This is the legacy function for backward compatibility when no settings provider is available
|
||||
func ServeEmbeddedFrontend() gin.HandlerFunc {
|
||||
|
||||
660
backend/internal/web/embed_test.go
Normal file
660
backend/internal/web/embed_test.go
Normal file
@@ -0,0 +1,660 @@
|
||||
//go:build embed
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func init() {
|
||||
gin.SetMode(gin.TestMode)
|
||||
}
|
||||
|
||||
func TestReplaceNoncePlaceholder(t *testing.T) {
|
||||
t.Run("replaces_single_placeholder", func(t *testing.T) {
|
||||
html := []byte(`<script nonce="__CSP_NONCE_VALUE__">console.log('test');</script>`)
|
||||
nonce := "abc123xyz"
|
||||
|
||||
result := replaceNoncePlaceholder(html, nonce)
|
||||
|
||||
expected := `<script nonce="abc123xyz">console.log('test');</script>`
|
||||
assert.Equal(t, expected, string(result))
|
||||
})
|
||||
|
||||
t.Run("replaces_multiple_placeholders", func(t *testing.T) {
|
||||
html := []byte(`<script nonce="__CSP_NONCE_VALUE__">a</script><script nonce="__CSP_NONCE_VALUE__">b</script>`)
|
||||
nonce := "nonce123"
|
||||
|
||||
result := replaceNoncePlaceholder(html, nonce)
|
||||
|
||||
assert.Equal(t, 2, strings.Count(string(result), `nonce="nonce123"`))
|
||||
assert.NotContains(t, string(result), NonceHTMLPlaceholder)
|
||||
})
|
||||
|
||||
t.Run("handles_empty_nonce", func(t *testing.T) {
|
||||
html := []byte(`<script nonce="__CSP_NONCE_VALUE__">test</script>`)
|
||||
nonce := ""
|
||||
|
||||
result := replaceNoncePlaceholder(html, nonce)
|
||||
|
||||
assert.Equal(t, `<script nonce="">test</script>`, string(result))
|
||||
})
|
||||
|
||||
t.Run("no_placeholder_returns_unchanged", func(t *testing.T) {
|
||||
html := []byte(`<script>console.log('test');</script>`)
|
||||
nonce := "abc123"
|
||||
|
||||
result := replaceNoncePlaceholder(html, nonce)
|
||||
|
||||
assert.Equal(t, string(html), string(result))
|
||||
})
|
||||
|
||||
t.Run("handles_empty_html", func(t *testing.T) {
|
||||
html := []byte(``)
|
||||
nonce := "abc123"
|
||||
|
||||
result := replaceNoncePlaceholder(html, nonce)
|
||||
|
||||
assert.Empty(t, result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestNonceHTMLPlaceholder(t *testing.T) {
|
||||
t.Run("constant_value", func(t *testing.T) {
|
||||
assert.Equal(t, "__CSP_NONCE_VALUE__", NonceHTMLPlaceholder)
|
||||
})
|
||||
}
|
||||
|
||||
// mockSettingsProvider implements PublicSettingsProvider for testing
|
||||
type mockSettingsProvider struct {
|
||||
settings any
|
||||
err error
|
||||
called int
|
||||
}
|
||||
|
||||
func (m *mockSettingsProvider) GetPublicSettingsForInjection(ctx context.Context) (any, error) {
|
||||
m.called++
|
||||
return m.settings, m.err
|
||||
}
|
||||
|
||||
func TestFrontendServer_InjectSettings(t *testing.T) {
|
||||
t.Run("injects_settings_with_nonce_placeholder", func(t *testing.T) {
|
||||
provider := &mockSettingsProvider{
|
||||
settings: map[string]string{"key": "value"},
|
||||
}
|
||||
|
||||
server, err := NewFrontendServer(provider)
|
||||
require.NoError(t, err)
|
||||
|
||||
settingsJSON := []byte(`{"test":"data"}`)
|
||||
result := server.injectSettings(settingsJSON)
|
||||
|
||||
// Should contain the script with nonce placeholder
|
||||
assert.Contains(t, string(result), `<script nonce="__CSP_NONCE_VALUE__">`)
|
||||
assert.Contains(t, string(result), `window.__APP_CONFIG__={"test":"data"};`)
|
||||
assert.Contains(t, string(result), `</script></head>`)
|
||||
})
|
||||
|
||||
t.Run("injects_before_head_close", func(t *testing.T) {
|
||||
provider := &mockSettingsProvider{
|
||||
settings: map[string]string{"key": "value"},
|
||||
}
|
||||
|
||||
server, err := NewFrontendServer(provider)
|
||||
require.NoError(t, err)
|
||||
|
||||
settingsJSON := []byte(`{}`)
|
||||
result := server.injectSettings(settingsJSON)
|
||||
|
||||
// Script should be injected before </head>
|
||||
headCloseIndex := bytes.Index(result, []byte("</head>"))
|
||||
scriptIndex := bytes.Index(result, []byte(`<script nonce="`))
|
||||
|
||||
assert.True(t, scriptIndex < headCloseIndex, "script should be before </head>")
|
||||
})
|
||||
|
||||
t.Run("handles_complex_settings", func(t *testing.T) {
|
||||
provider := &mockSettingsProvider{
|
||||
settings: map[string]any{
|
||||
"nested": map[string]any{
|
||||
"array": []int{1, 2, 3},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
server, err := NewFrontendServer(provider)
|
||||
require.NoError(t, err)
|
||||
|
||||
settingsJSON := []byte(`{"nested":{"array":[1,2,3]},"special":"<>&"}`)
|
||||
result := server.injectSettings(settingsJSON)
|
||||
|
||||
assert.Contains(t, string(result), `window.__APP_CONFIG__={"nested":{"array":[1,2,3]},"special":"<>&"};`)
|
||||
})
|
||||
}
|
||||
|
||||
func TestFrontendServer_ServeIndexHTML(t *testing.T) {
|
||||
t.Run("serves_html_with_nonce", func(t *testing.T) {
|
||||
provider := &mockSettingsProvider{
|
||||
settings: map[string]string{"test": "value"},
|
||||
}
|
||||
|
||||
server, err := NewFrontendServer(provider)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a gin context with nonce
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
|
||||
// Set nonce in context (simulating SecurityHeaders middleware)
|
||||
testNonce := "test-nonce-12345"
|
||||
c.Set(middleware.CSPNonceKey, testNonce)
|
||||
|
||||
server.serveIndexHTML(c)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Contains(t, w.Header().Get("Content-Type"), "text/html")
|
||||
|
||||
body := w.Body.String()
|
||||
// Nonce placeholder should be replaced
|
||||
assert.NotContains(t, body, NonceHTMLPlaceholder)
|
||||
assert.Contains(t, body, `nonce="`+testNonce+`"`)
|
||||
})
|
||||
|
||||
t.Run("caches_html_content", func(t *testing.T) {
|
||||
provider := &mockSettingsProvider{
|
||||
settings: map[string]string{"test": "value"},
|
||||
}
|
||||
|
||||
server, err := NewFrontendServer(provider)
|
||||
require.NoError(t, err)
|
||||
|
||||
// First request
|
||||
w1 := httptest.NewRecorder()
|
||||
c1, _ := gin.CreateTestContext(w1)
|
||||
c1.Request = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
c1.Set(middleware.CSPNonceKey, "nonce1")
|
||||
|
||||
server.serveIndexHTML(c1)
|
||||
assert.Equal(t, 1, provider.called)
|
||||
|
||||
// Second request - should use cache
|
||||
w2 := httptest.NewRecorder()
|
||||
c2, _ := gin.CreateTestContext(w2)
|
||||
c2.Request = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
c2.Set(middleware.CSPNonceKey, "nonce2")
|
||||
|
||||
server.serveIndexHTML(c2)
|
||||
// Settings provider should not be called again
|
||||
assert.Equal(t, 1, provider.called)
|
||||
|
||||
// But nonce should be different
|
||||
assert.Contains(t, w2.Body.String(), `nonce="nonce2"`)
|
||||
})
|
||||
|
||||
t.Run("sets_etag_header", func(t *testing.T) {
|
||||
provider := &mockSettingsProvider{
|
||||
settings: map[string]string{"test": "value"},
|
||||
}
|
||||
|
||||
server, err := NewFrontendServer(provider)
|
||||
require.NoError(t, err)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
c.Set(middleware.CSPNonceKey, "nonce123")
|
||||
|
||||
server.serveIndexHTML(c)
|
||||
|
||||
etag := w.Header().Get("ETag")
|
||||
assert.NotEmpty(t, etag)
|
||||
assert.True(t, strings.HasPrefix(etag, `"`))
|
||||
assert.True(t, strings.HasSuffix(etag, `"`))
|
||||
})
|
||||
|
||||
t.Run("returns_304_for_matching_etag", func(t *testing.T) {
|
||||
provider := &mockSettingsProvider{
|
||||
settings: map[string]string{"test": "value"},
|
||||
}
|
||||
|
||||
server, err := NewFrontendServer(provider)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Use a real router for proper 304 handling
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set(middleware.CSPNonceKey, "test-nonce")
|
||||
c.Next()
|
||||
})
|
||||
router.Use(server.Middleware())
|
||||
|
||||
// First request to populate cache and get ETag
|
||||
w1 := httptest.NewRecorder()
|
||||
req1 := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
router.ServeHTTP(w1, req1)
|
||||
etag := w1.Header().Get("ETag")
|
||||
require.NotEmpty(t, etag)
|
||||
|
||||
// Second request with If-None-Match
|
||||
w2 := httptest.NewRecorder()
|
||||
req2 := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req2.Header.Set("If-None-Match", etag)
|
||||
router.ServeHTTP(w2, req2)
|
||||
|
||||
assert.Equal(t, http.StatusNotModified, w2.Code)
|
||||
assert.Empty(t, w2.Body.String())
|
||||
})
|
||||
|
||||
t.Run("sets_cache_control_header", func(t *testing.T) {
|
||||
provider := &mockSettingsProvider{
|
||||
settings: map[string]string{"test": "value"},
|
||||
}
|
||||
|
||||
server, err := NewFrontendServer(provider)
|
||||
require.NoError(t, err)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
c.Set(middleware.CSPNonceKey, "nonce123")
|
||||
|
||||
server.serveIndexHTML(c)
|
||||
|
||||
assert.Equal(t, "no-cache", w.Header().Get("Cache-Control"))
|
||||
})
|
||||
|
||||
t.Run("fallback_on_settings_error", func(t *testing.T) {
|
||||
provider := &mockSettingsProvider{
|
||||
err: context.DeadlineExceeded,
|
||||
}
|
||||
|
||||
server, err := NewFrontendServer(provider)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Invalidate cache to force settings fetch
|
||||
server.InvalidateCache()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
c.Set(middleware.CSPNonceKey, "nonce123")
|
||||
|
||||
server.serveIndexHTML(c)
|
||||
|
||||
// Should still return 200 with base HTML
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Contains(t, w.Header().Get("Content-Type"), "text/html")
|
||||
})
|
||||
}
|
||||
|
||||
func TestFrontendServer_InvalidateCache(t *testing.T) {
|
||||
t.Run("invalidates_cache", func(t *testing.T) {
|
||||
provider := &mockSettingsProvider{
|
||||
settings: map[string]string{"test": "value"},
|
||||
}
|
||||
|
||||
server, err := NewFrontendServer(provider)
|
||||
require.NoError(t, err)
|
||||
|
||||
// First request to populate cache
|
||||
w1 := httptest.NewRecorder()
|
||||
c1, _ := gin.CreateTestContext(w1)
|
||||
c1.Request = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
c1.Set(middleware.CSPNonceKey, "nonce1")
|
||||
|
||||
server.serveIndexHTML(c1)
|
||||
assert.Equal(t, 1, provider.called)
|
||||
|
||||
// Invalidate cache
|
||||
server.InvalidateCache()
|
||||
|
||||
// Update settings
|
||||
provider.settings = map[string]string{"test": "new_value"}
|
||||
|
||||
// Second request should fetch new settings
|
||||
w2 := httptest.NewRecorder()
|
||||
c2, _ := gin.CreateTestContext(w2)
|
||||
c2.Request = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
c2.Set(middleware.CSPNonceKey, "nonce2")
|
||||
|
||||
server.serveIndexHTML(c2)
|
||||
assert.Equal(t, 2, provider.called)
|
||||
})
|
||||
|
||||
t.Run("handles_nil_server", func(t *testing.T) {
|
||||
var server *FrontendServer
|
||||
// Should not panic
|
||||
assert.NotPanics(t, func() {
|
||||
server.InvalidateCache()
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("handles_nil_cache", func(t *testing.T) {
|
||||
server := &FrontendServer{}
|
||||
// Should not panic
|
||||
assert.NotPanics(t, func() {
|
||||
server.InvalidateCache()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestFrontendServer_Middleware(t *testing.T) {
|
||||
t.Run("skips_api_routes", func(t *testing.T) {
|
||||
provider := &mockSettingsProvider{
|
||||
settings: map[string]string{"test": "value"},
|
||||
}
|
||||
|
||||
server, err := NewFrontendServer(provider)
|
||||
require.NoError(t, err)
|
||||
|
||||
apiPaths := []string{
|
||||
"/api/v1/users",
|
||||
"/v1/models",
|
||||
"/v1beta/chat",
|
||||
"/antigravity/test",
|
||||
"/setup/init",
|
||||
"/health",
|
||||
"/responses",
|
||||
}
|
||||
|
||||
for _, path := range apiPaths {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
router := gin.New()
|
||||
router.Use(server.Middleware())
|
||||
nextCalled := false
|
||||
router.GET(path, func(c *gin.Context) {
|
||||
nextCalled = true
|
||||
c.String(http.StatusOK, "ok")
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.True(t, nextCalled, "next handler should be called for API route")
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("serves_index_for_spa_routes", func(t *testing.T) {
|
||||
provider := &mockSettingsProvider{
|
||||
settings: map[string]string{"test": "value"},
|
||||
}
|
||||
|
||||
server, err := NewFrontendServer(provider)
|
||||
require.NoError(t, err)
|
||||
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set(middleware.CSPNonceKey, "test-nonce")
|
||||
c.Next()
|
||||
})
|
||||
router.Use(server.Middleware())
|
||||
|
||||
spaPaths := []string{
|
||||
"/",
|
||||
"/dashboard",
|
||||
"/users/123",
|
||||
"/settings/profile",
|
||||
}
|
||||
|
||||
for _, path := range spaPaths {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Contains(t, w.Header().Get("Content-Type"), "text/html")
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("serves_static_files", func(t *testing.T) {
|
||||
provider := &mockSettingsProvider{
|
||||
settings: map[string]string{"test": "value"},
|
||||
}
|
||||
|
||||
server, err := NewFrontendServer(provider)
|
||||
require.NoError(t, err)
|
||||
|
||||
router := gin.New()
|
||||
router.Use(server.Middleware())
|
||||
|
||||
// Request for existing static file
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/logo.png", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Contains(t, w.Header().Get("Content-Type"), "image/png")
|
||||
})
|
||||
}
|
||||
|
||||
func TestNewFrontendServer(t *testing.T) {
|
||||
t.Run("creates_server_successfully", func(t *testing.T) {
|
||||
provider := &mockSettingsProvider{
|
||||
settings: map[string]string{"test": "value"},
|
||||
}
|
||||
|
||||
server, err := NewFrontendServer(provider)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, server)
|
||||
assert.NotNil(t, server.distFS)
|
||||
assert.NotNil(t, server.fileServer)
|
||||
assert.NotNil(t, server.baseHTML)
|
||||
assert.NotNil(t, server.cache)
|
||||
assert.Equal(t, provider, server.settings)
|
||||
})
|
||||
|
||||
t.Run("reads_base_html", func(t *testing.T) {
|
||||
provider := &mockSettingsProvider{
|
||||
settings: map[string]string{"test": "value"},
|
||||
}
|
||||
|
||||
server, err := NewFrontendServer(provider)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotEmpty(t, server.baseHTML)
|
||||
assert.Contains(t, string(server.baseHTML), "<!doctype html>")
|
||||
})
|
||||
}
|
||||
|
||||
func TestHasEmbeddedFrontend(t *testing.T) {
|
||||
t.Run("returns_true_when_frontend_embedded", func(t *testing.T) {
|
||||
result := HasEmbeddedFrontend()
|
||||
assert.True(t, result)
|
||||
})
|
||||
}
|
||||
|
||||
// Tests for legacy ServeEmbeddedFrontend function
|
||||
func TestServeEmbeddedFrontend(t *testing.T) {
|
||||
t.Run("serves_static_files", func(t *testing.T) {
|
||||
middleware := ServeEmbeddedFrontend()
|
||||
|
||||
router := gin.New()
|
||||
router.Use(middleware)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/logo.png", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Contains(t, w.Header().Get("Content-Type"), "image/png")
|
||||
})
|
||||
|
||||
t.Run("serves_index_html_for_root", func(t *testing.T) {
|
||||
middleware := ServeEmbeddedFrontend()
|
||||
|
||||
router := gin.New()
|
||||
router.Use(middleware)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Contains(t, w.Header().Get("Content-Type"), "text/html")
|
||||
assert.Contains(t, w.Body.String(), "<!doctype html>")
|
||||
})
|
||||
|
||||
t.Run("serves_index_html_for_spa_routes", func(t *testing.T) {
|
||||
middleware := ServeEmbeddedFrontend()
|
||||
|
||||
router := gin.New()
|
||||
router.Use(middleware)
|
||||
|
||||
spaPaths := []string{"/dashboard", "/users/123", "/settings"}
|
||||
|
||||
for _, path := range spaPaths {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Contains(t, w.Header().Get("Content-Type"), "text/html")
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("skips_api_routes", func(t *testing.T) {
|
||||
middleware := ServeEmbeddedFrontend()
|
||||
|
||||
apiPaths := []string{
|
||||
"/api/users",
|
||||
"/v1/models",
|
||||
"/v1beta/chat",
|
||||
"/antigravity/test",
|
||||
"/setup/init",
|
||||
"/health",
|
||||
"/responses",
|
||||
}
|
||||
|
||||
for _, path := range apiPaths {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
nextCalled := false
|
||||
router := gin.New()
|
||||
router.Use(middleware)
|
||||
router.GET(path, func(c *gin.Context) {
|
||||
nextCalled = true
|
||||
c.String(http.StatusOK, "ok")
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.True(t, nextCalled, "next handler should be called for API route")
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Tests for HTMLCache
|
||||
func TestHTMLCache(t *testing.T) {
|
||||
t.Run("new_cache_returns_nil", func(t *testing.T) {
|
||||
cache := NewHTMLCache()
|
||||
assert.Nil(t, cache.Get())
|
||||
})
|
||||
|
||||
t.Run("set_and_get", func(t *testing.T) {
|
||||
cache := NewHTMLCache()
|
||||
cache.SetBaseHTML([]byte("<html></html>"))
|
||||
|
||||
html := []byte("<html><body>test</body></html>")
|
||||
settings := []byte(`{"key":"value"}`)
|
||||
cache.Set(html, settings)
|
||||
|
||||
result := cache.Get()
|
||||
require.NotNil(t, result)
|
||||
assert.Equal(t, html, result.Content)
|
||||
assert.NotEmpty(t, result.ETag)
|
||||
})
|
||||
|
||||
t.Run("invalidate_clears_cache", func(t *testing.T) {
|
||||
cache := NewHTMLCache()
|
||||
cache.SetBaseHTML([]byte("<html></html>"))
|
||||
|
||||
html := []byte("<html><body>test</body></html>")
|
||||
settings := []byte(`{"key":"value"}`)
|
||||
cache.Set(html, settings)
|
||||
|
||||
require.NotNil(t, cache.Get())
|
||||
|
||||
cache.Invalidate()
|
||||
|
||||
assert.Nil(t, cache.Get())
|
||||
})
|
||||
|
||||
t.Run("etag_changes_with_settings", func(t *testing.T) {
|
||||
cache := NewHTMLCache()
|
||||
cache.SetBaseHTML([]byte("<html></html>"))
|
||||
|
||||
html := []byte("<html><body>test</body></html>")
|
||||
|
||||
cache.Set(html, []byte(`{"v":1}`))
|
||||
etag1 := cache.Get().ETag
|
||||
|
||||
cache.Invalidate()
|
||||
cache.Set(html, []byte(`{"v":2}`))
|
||||
etag2 := cache.Get().ETag
|
||||
|
||||
assert.NotEqual(t, etag1, etag2)
|
||||
})
|
||||
|
||||
t.Run("etag_format", func(t *testing.T) {
|
||||
cache := NewHTMLCache()
|
||||
cache.SetBaseHTML([]byte("<html></html>"))
|
||||
|
||||
cache.Set([]byte("<html></html>"), []byte(`{}`))
|
||||
result := cache.Get()
|
||||
|
||||
// ETag should be quoted
|
||||
assert.True(t, strings.HasPrefix(result.ETag, `"`))
|
||||
assert.True(t, strings.HasSuffix(result.ETag, `"`))
|
||||
// Should contain dash separator
|
||||
assert.Contains(t, result.ETag[1:len(result.ETag)-1], "-")
|
||||
})
|
||||
}
|
||||
|
||||
// Benchmark tests
|
||||
func BenchmarkReplaceNoncePlaceholder(b *testing.B) {
|
||||
html := []byte(`<!DOCTYPE html><html><head><script nonce="__CSP_NONCE_VALUE__">window.__APP_CONFIG__={"test":"data"};</script></head><body></body></html>`)
|
||||
nonce := "abcdefghijklmnop123456=="
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
replaceNoncePlaceholder(html, nonce)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkFrontendServerServeIndexHTML(b *testing.B) {
|
||||
provider := &mockSettingsProvider{
|
||||
settings: map[string]string{"test": "value"},
|
||||
}
|
||||
|
||||
server, _ := NewFrontendServer(provider)
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
c.Set(middleware.CSPNonceKey, "test-nonce")
|
||||
|
||||
server.serveIndexHTML(c)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user