Files
warmbly/internal/api/middleware/query_validation_test.go
T
2026-10-06 12:52:39 +00:00

117 lines
4.2 KiB
Go

package middleware
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
)
func TestQueryValidationRejectsUnsupportedText(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, query := range []string{"q=%00", "q=valid&q=%00", "%00=value", "cursor=%00", "folder=%00", "tag=%00", "q=%FF", "%FF=value", "q=%ZZ", "q=unescaped;semicolon"} {
t.Run(query, func(t *testing.T) {
called := false
r := gin.New()
r.Use(SecurityHeaders(), RequestIDMiddleware(), cors.New(cors.Config{AllowOrigins: []string{"https://dashboard.example.com"}, AllowMethods: []string{http.MethodGet}}), QueryValidation())
r.GET("/v1/campaigns", func(c *gin.Context) {
called = true
c.Status(http.StatusOK)
})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/v1/campaigns?"+query, nil)
req.Header.Set("Origin", "https://dashboard.example.com")
r.ServeHTTP(w, req)
if called || w.Code != http.StatusBadRequest {
t.Fatalf("called = %t, status = %d; want false, 400", called, w.Code)
}
if !strings.Contains(w.Body.String(), `"code":"bad_request"`) || !strings.Contains(w.Body.String(), `"request_id":"`) {
t.Errorf("missing structured error or request ID: %s", w.Body.String())
}
if w.Header().Get("Cache-Control") != "no-store" {
t.Error("invalid queries must retain cache protection")
}
if w.Header().Get("Access-Control-Allow-Origin") != "https://dashboard.example.com" {
t.Error("validation errors must be readable by the configured dashboard origin")
}
})
}
}
func TestQueryValidationPreservesValidQuery(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, query := range []string{"", "q=security&limit=5", "q=%27%22%3Cscript%3E", "q=hello%3Bworld", "q=%E4%BD%A0%E5%A5%BD", "q=one&q=two"} {
t.Run(query, func(t *testing.T) {
r := gin.New()
r.Use(QueryValidation())
r.GET("/v1/emails", func(c *gin.Context) {
if c.Request.URL.RawQuery != query {
t.Errorf("query was changed to %q", c.Request.URL.RawQuery)
}
c.Status(http.StatusOK)
})
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/v1/emails?"+query, nil))
if w.Code != http.StatusOK {
t.Errorf("valid query returned %d", w.Code)
}
})
}
}
func TestQueryValidationProtectsLegacyInvitationLookup(t *testing.T) {
gin.SetMode(gin.TestMode)
token := strings.Repeat("a", 64)
for _, tc := range []struct {
query string
valid bool
}{
{"token=" + token, true},
{"token=" + token + "&ref=%E4%BD%A0%E5%A5%BD", true},
{"token=%00", false},
{"token=%FF", false},
{"token=%ZZ", false},
{"%00=value", false},
{"token=" + token + "&unused=%00", false},
} {
t.Run(tc.query, func(t *testing.T) {
rateLimiterCalled, handlerCalled := false, false
r := gin.New()
r.Use(SecurityHeaders(), RequestIDMiddleware())
r.GET("/invitations/lookup", QueryValidation(), func(c *gin.Context) {
rateLimiterCalled = true
c.Next()
}, func(c *gin.Context) {
handlerCalled = true
if c.Query("token") != token || c.Request.URL.RawQuery != tc.query {
t.Error("valid invitation query was changed")
}
c.Status(http.StatusOK)
})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/invitations/lookup?"+tc.query, nil)
req.Header.Set("Origin", "https://dashboard.example.com")
r.ServeHTTP(w, req)
wantStatus := http.StatusBadRequest
if tc.valid {
wantStatus = http.StatusOK
}
if w.Code != wantStatus || rateLimiterCalled != tc.valid || handlerCalled != tc.valid {
t.Fatalf("status = %d, limiter = %t, handler = %t; want %d, %t, %t", w.Code, rateLimiterCalled, handlerCalled, wantStatus, tc.valid, tc.valid)
}
if !tc.valid && (!strings.Contains(w.Body.String(), `"code":"bad_request"`) || !strings.Contains(w.Body.String(), `"request_id":"`)) {
t.Errorf("missing structured validation error: %s", w.Body.String())
}
if w.Header().Get("Cache-Control") != "no-store" || w.Header().Get("X-Request-Id") == "" {
t.Error("invitation responses must retain cache protection and request IDs")
}
if w.Header().Get("Access-Control-Allow-Origin") != "" {
t.Error("legacy invitation validation must not change CORS behavior")
}
})
}
}