Files
fire-safety-ymd/internal/handler/chat_page_test.go
2026-09-06 00:25:25 +08:00

94 lines
3.8 KiB
Go

package handler
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestNewChatPageHandlerValidatesPublicAppID(t *testing.T) {
for _, appID := range []string{"", "app/other", "<script>", " app"} {
if _, err := NewChatPageHandler(ChatPageOptions{AppID: appID}); err == nil {
t.Fatalf("NewChatPageHandler(%q) error = nil", appID)
}
}
if _, err := NewChatPageHandler(ChatPageOptions{AppID: "fire-safety-public-app"}); err != nil {
t.Fatalf("NewChatPageHandler(valid) error = %v", err)
}
}
func TestChatPageServesSafeHTMLAndAssets(t *testing.T) {
handler, err := NewChatPageHandler(ChatPageOptions{AppID: "fire-safety-public-app"})
if err != nil {
t.Fatal(err)
}
tests := []struct {
path string
contentType string
contains []string
}{
{path: "/chat/", contentType: "text/html", contains: []string{`data-app-id="fire-safety-public-app"`, `src="/chat/app.js"`, `aria-live="polite"`, `method="post"`, `maxlength="4096"`, `<noscript>`}},
{path: "/chat/app.css", contentType: "text/css", contains: []string{"@media (max-width: 680px)", ":focus-visible"}},
{path: "/chat/app.js", contentType: "text/javascript", contains: []string{`"xtoken": authToken`, `"Accept": "text/event-stream"`, `credentials: "omit"`, "output.session_id", "paragraph.textContent = text", "committedSessionID = \"\"", "payload.error"}},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
request := httptest.NewRequest(http.MethodGet, tt.path, nil)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
result := response.Result()
body, readErr := io.ReadAll(result.Body)
if readErr != nil {
t.Fatal(readErr)
}
if result.StatusCode != http.StatusOK || !strings.HasPrefix(result.Header.Get("Content-Type"), tt.contentType) {
t.Fatalf("status=%d content-type=%q body=%s", result.StatusCode, result.Header.Get("Content-Type"), body)
}
for _, expected := range tt.contains {
if !strings.Contains(string(body), expected) {
t.Fatalf("body missing %q", expected)
}
}
for _, header := range []string{"Content-Security-Policy", "Cross-Origin-Resource-Policy", "Referrer-Policy", "Permissions-Policy", "X-Content-Type-Options", "X-Frame-Options"} {
if result.Header.Get(header) == "" {
t.Fatalf("security header %s is empty", header)
}
}
if csp := result.Header.Get("Content-Security-Policy"); !strings.Contains(csp, "form-action 'none'") || !strings.Contains(csp, "object-src 'none'") {
t.Fatalf("CSP does not prohibit native form/object submission: %q", csp)
}
if strings.Contains(string(body), "xtoken=") || strings.Contains(string(body), "localStorage") || strings.Contains(string(body), "sessionStorage") {
t.Fatalf("asset contains forbidden credential persistence: %s", body)
}
})
}
}
func TestChatPageRestrictsMethodsAndPaths(t *testing.T) {
handler, err := NewChatPageHandler(ChatPageOptions{AppID: "fire-safety-public-app"})
if err != nil {
t.Fatal(err)
}
post := httptest.NewRecorder()
handler.ServeHTTP(post, httptest.NewRequest(http.MethodPost, "/chat/", nil))
if post.Code != http.StatusMethodNotAllowed || post.Header().Get("Allow") != "GET, HEAD" {
t.Fatalf("POST status=%d allow=%q", post.Code, post.Header().Get("Allow"))
}
missing := httptest.NewRecorder()
handler.ServeHTTP(missing, httptest.NewRequest(http.MethodGet, "/chat/unknown", nil))
if missing.Code != http.StatusNotFound {
t.Fatalf("unknown path status=%d", missing.Code)
}
head := httptest.NewRecorder()
handler.ServeHTTP(head, httptest.NewRequest(http.MethodHead, "/chat/app.js", nil))
if head.Code != http.StatusOK || head.Body.Len() != 0 || head.Header().Get("Content-Length") == "" {
t.Fatalf("HEAD status=%d len=%d content-length=%q", head.Code, head.Body.Len(), head.Header().Get("Content-Length"))
}
}