Files
kandev/apps/backend/internal/system/frontenderrors/handler_test.go
T
2026-09-06 11:35:51 +01:00

212 lines
7.4 KiB
Go

package frontenderrors
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/kandev/kandev/internal/auth/authn"
"github.com/kandev/kandev/internal/common/logger"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"go.uber.org/zap/zaptest/observer"
)
// @covers AC-PLATFORM-EXPECTED-RUNTIME-LOG-SEVERITY-001.18
func TestHandlerLogsBoundedStructuredFrontendError(t *testing.T) {
router, observed := newHandlerTestRouter(t, "user-1")
body := `{
"client_timestamp":"2026-07-30T12:34:56.789Z",
"source":"sonner",
"task_id":"task-123",
"title":"Failed to save",
"description":"The backend rejected the update",
"url":"http://localhost/settings/system/logs?token=secret#private",
"user_agent":"test-agent",
"language":"en-US",
"platform":"test-platform",
"viewport":{"width":1440,"height":900},
"stack":"toast stack",
"error":{"name":"TypeError","message":"Failed to fetch","stack":"error stack"}
}`
response := performFrontendErrorRequest(router, body)
if response.Code != http.StatusNoContent {
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
}
entries := observed.All()
if len(entries) != 1 {
t.Fatalf("logged entries = %d, want 1", len(entries))
}
entry := entries[0]
if entry.Level != zapcore.ErrorLevel || entry.Message != "frontend error toast" {
t.Fatalf("entry = %s %q", entry.Level, entry.Message)
}
fields := entry.ContextMap()
if fields["task_id"] != "task-123" || fields["source"] != "sonner" ||
fields["title"] != "Failed to save" || fields["error_name"] != "TypeError" {
t.Fatalf("structured fields = %#v", fields)
}
if fields["url"] != "http://localhost/settings/system/logs" {
t.Fatalf("logged url = %#v", fields["url"])
}
}
// @covers AC-PLATFORM-EXPECTED-RUNTIME-LOG-SEVERITY-001.17
func TestHandlerLogsBackendReloadDiagnosticAtInfo(t *testing.T) {
for _, signal := range []string{"boot_id_changed", "settings_interlock_rejected"} {
t.Run(signal, func(t *testing.T) {
router, observed := newHandlerTestRouter(t, "user-1")
body := `{
"client_timestamp":"2026-09-05T15:52:11.255Z",
"source":"backend-reload",
"task_id":"task-123",
"title":"` + signal + `",
"url":"http://localhost/t/task-123"
}`
response := performFrontendErrorRequest(router, body)
if response.Code != http.StatusNoContent {
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
}
entries := observed.All()
if len(entries) != 1 {
t.Fatalf("logged entries = %d, want 1", len(entries))
}
entry := entries[0]
if entry.Level != zapcore.InfoLevel || entry.Message != "frontend backend reload required" {
t.Fatalf("entry = %s %q", entry.Level, entry.Message)
}
fields := entry.ContextMap()
if fields["source"] != "backend-reload" || fields["title"] != signal {
t.Fatalf("structured fields = %#v", fields)
}
})
}
}
func TestHandlerRejectsInvalidBackendReloadReports(t *testing.T) {
tests := []struct {
name string
body string
}{
{name: "unknown signal", body: `{"source":"backend-reload","title":"other"}`},
{name: "error data", body: `{"source":"backend-reload","title":"boot_id_changed","stack":"toast stack"}`},
{name: "description data", body: `{"source":"backend-reload","title":"boot_id_changed","description":"should be rejected"}`},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
router, observed := newHandlerTestRouter(t, "user-1")
response := performFrontendErrorRequest(router, test.body)
if response.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d, body = %s",
response.Code, http.StatusBadRequest, response.Body.String())
}
if observed.Len() != 0 {
t.Fatalf("invalid request produced %d log entries", observed.Len())
}
})
}
}
func TestHandlerRejectsInvalidAndOversizedRequests(t *testing.T) {
router, observed := newHandlerTestRouter(t, "user-1")
tests := []struct {
name string
body string
want int
}{
{name: "malformed", body: `{`, want: http.StatusBadRequest},
{name: "unsupported source", body: `{"source":"other","title":"visible"}`, want: http.StatusBadRequest},
{name: "empty text", body: `{"source":"sonner","title":" ","description":""}`, want: http.StatusBadRequest},
{name: "bad timestamp", body: `{"source":"sonner","title":"visible","client_timestamp":"yesterday"}`, want: http.StatusBadRequest},
{name: "oversized", body: `{"source":"sonner","title":"` + strings.Repeat("x", maxRequestBodyBytes) + `"}`, want: http.StatusRequestEntityTooLarge},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
response := performFrontendErrorRequest(router, test.body)
if response.Code != test.want {
t.Fatalf("status = %d, want %d, body = %s", response.Code, test.want, response.Body.String())
}
})
}
if observed.Len() != 0 {
t.Fatalf("invalid requests produced %d log entries", observed.Len())
}
}
func TestHandlerTruncatesUTF8FieldsAndMarksEntry(t *testing.T) {
router, observed := newHandlerTestRouter(t, "user-1")
body, err := json.Marshal(Request{
Source: "toast-provider", Title: strings.Repeat("🙂", titleByteLimit/2),
TaskID: strings.Repeat("t", taskIDByteLimit+20),
})
if err != nil {
t.Fatal(err)
}
response := performFrontendErrorRequest(router, string(body))
if response.Code != http.StatusNoContent {
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
}
fields := observed.All()[0].ContextMap()
title, _ := fields["title"].(string)
taskID, _ := fields["task_id"].(string)
if len(title) > titleByteLimit || !strings.HasSuffix(title, "🙂") {
t.Fatalf("title truncation split UTF-8 or exceeded limit: %d bytes", len(title))
}
if len(taskID) != taskIDByteLimit || fields["truncated"] != true {
t.Fatalf("task/truncated fields = %d/%#v", len(taskID), fields["truncated"])
}
}
func TestHandlerLimitsAcceptedRequestBytes(t *testing.T) {
router, observed := newHandlerTestRouter(t, "user-1")
body := `{"source":"sonner","title":"visible"}` + strings.Repeat(" ", 40*1024)
if response := performFrontendErrorRequest(router, body); response.Code != http.StatusNoContent {
t.Fatalf("first status = %d, body = %s", response.Code, response.Body.String())
}
response := performFrontendErrorRequest(router, body)
if response.Code != http.StatusTooManyRequests {
t.Fatalf("second status = %d, want %d, body = %s",
response.Code, http.StatusTooManyRequests, response.Body.String())
}
if response.Header().Get("Retry-After") == "" {
t.Fatal("missing Retry-After")
}
if observed.Len() != 1 {
t.Fatalf("logged entries = %d, want 1", observed.Len())
}
}
func newHandlerTestRouter(t *testing.T, userID string) (*gin.Engine, *observer.ObservedLogs) {
t.Helper()
gin.SetMode(gin.TestMode)
core, observed := observer.New(zapcore.DebugLevel)
log, err := logger.NewFromZap(zap.New(core))
if err != nil {
t.Fatal(err)
}
router := gin.New()
router.Use(func(c *gin.Context) {
authn.SetOnGin(c, authn.Identity{UserID: userID, Role: authn.RoleMember})
c.Next()
})
router.POST("/api/v1/system/logs/frontend-errors", Handle(New(log, time.Now)))
return router, observed
}
func performFrontendErrorRequest(router *gin.Engine, body string) *httptest.ResponseRecorder {
request := httptest.NewRequest(http.MethodPost, "/api/v1/system/logs/frontend-errors", bytes.NewBufferString(body))
request.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
return response
}