Files
galaxy-game/gateway/internal/restapi/public_anti_abuse_test.go
T
2026-04-02 19:18:42 +02:00

456 lines
13 KiB
Go

package restapi
import (
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"galaxy/gateway/internal/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPublicAntiAbuseRejectsOversizedBodies(t *testing.T) {
t.Parallel()
oversizedJSONBody := `{"email":"` + strings.Repeat("a", 8200) + `@example.com"}`
oversizedConfirmJSONBody := `{"challenge_id":"` + strings.Repeat("c", 8300) + `","code":"123456","client_public_key":"key"}`
tests := []struct {
name string
method string
target string
body string
wantClass PublicRouteClass
}{
{
name: "send email",
method: http.MethodPost,
target: "/api/v1/public/auth/send-email-code",
body: oversizedJSONBody,
wantClass: PublicRouteClassPublicAuth,
},
{
name: "confirm email",
method: http.MethodPost,
target: "/api/v1/public/auth/confirm-email-code",
body: oversizedConfirmJSONBody,
wantClass: PublicRouteClassPublicAuth,
},
{
name: "healthz body",
method: http.MethodGet,
target: "/healthz",
body: `x`,
wantClass: PublicRouteClassPublicMisc,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
observer := &recordingPublicRequestObserver{}
authService := &recordingAuthServiceClient{
sendEmailCodeResult: SendEmailCodeResult{ChallengeID: "challenge-123"},
confirmEmailCodeResult: ConfirmEmailCodeResult{
DeviceSessionID: "device-session-123",
},
}
handler := newPublicHandlerWithConfig(config.DefaultPublicHTTPConfig(), ServerDependencies{
AuthService: authService,
Observer: observer,
})
req := httptest.NewRequest(tt.method, tt.target, strings.NewReader(tt.body))
req.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusRequestEntityTooLarge, recorder.Code)
assert.Equal(t, jsonContentType, recorder.Header().Get("Content-Type"))
assert.Equal(t, `{"error":{"code":"request_too_large","message":"request body exceeds the configured limit"}}`, recorder.Body.String())
assert.Equal(t, 0, authService.sendEmailCodeCalls)
assert.Equal(t, 0, authService.confirmEmailCodeCalls)
assert.Equal(t, []malformedObservation{{
class: tt.wantClass,
reason: PublicMalformedRequestReasonOversizedBody,
}}, observer.snapshot())
})
}
}
func TestPublicAntiAbuseRejectsInvalidMethodsForBrowserShapes(t *testing.T) {
t.Parallel()
handler := newPublicHandler(ServerDependencies{})
tests := []struct {
name string
method string
target string
accept string
wantAllow string
}{
{
name: "asset path",
method: http.MethodPost,
target: "/assets/app.js",
wantAllow: "GET, HEAD",
},
{
name: "bootstrap request",
method: http.MethodPost,
target: "/",
accept: "text/html",
wantAllow: "GET, HEAD",
},
{
name: "head probe rejected",
method: http.MethodHead,
target: "/healthz",
wantAllow: http.MethodGet,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(tt.method, tt.target, nil)
if tt.accept != "" {
req.Header.Set("Accept", tt.accept)
}
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusMethodNotAllowed, recorder.Code)
assert.Equal(t, jsonContentType, recorder.Header().Get("Content-Type"))
assert.Equal(t, tt.wantAllow, recorder.Header().Get("Allow"))
assert.Equal(t, `{"error":{"code":"method_not_allowed","message":"request method is not allowed for this route"}}`, recorder.Body.String())
})
}
}
func TestPublicAntiAbuseBrowserClassBucketsStayIsolatedFromPublicAuth(t *testing.T) {
t.Parallel()
tests := []struct {
name string
burstRequest *http.Request
}{
{
name: "browser asset",
burstRequest: httptest.NewRequest(http.MethodGet, "/assets/app.js", nil),
},
{
name: "browser bootstrap",
burstRequest: func() *http.Request {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Accept", "text/html")
return req
}(),
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
cfg := config.DefaultPublicHTTPConfig()
cfg.AntiAbuse.BrowserAsset.RateLimit = config.PublicRateLimitConfig{
Requests: 1,
Window: time.Hour,
Burst: 1,
}
cfg.AntiAbuse.BrowserBootstrap.RateLimit = config.PublicRateLimitConfig{
Requests: 1,
Window: time.Hour,
Burst: 1,
}
cfg.AntiAbuse.PublicAuth.RateLimit = config.PublicRateLimitConfig{
Requests: 100,
Window: time.Hour,
Burst: 100,
}
authService := &recordingAuthServiceClient{
sendEmailCodeResult: SendEmailCodeResult{
ChallengeID: "challenge-123",
},
}
handler := newPublicHandlerWithConfig(cfg, ServerDependencies{AuthService: authService})
tt.burstRequest.RemoteAddr = "192.0.2.10:1234"
firstBurst := httptest.NewRecorder()
handler.ServeHTTP(firstBurst, tt.burstRequest.Clone(tt.burstRequest.Context()))
secondBurst := httptest.NewRecorder()
handler.ServeHTTP(secondBurst, tt.burstRequest.Clone(tt.burstRequest.Context()))
authReq := httptest.NewRequest(http.MethodPost, "/api/v1/public/auth/send-email-code", strings.NewReader(`{"email":"pilot@example.com"}`))
authReq.Header.Set("Content-Type", "application/json")
authReq.RemoteAddr = "192.0.2.10:1234"
authResp := httptest.NewRecorder()
handler.ServeHTTP(authResp, authReq)
assert.Equal(t, http.StatusNotFound, firstBurst.Code)
assert.Equal(t, http.StatusTooManyRequests, secondBurst.Code)
assert.Equal(t, http.StatusOK, authResp.Code)
assert.Equal(t, `{"challenge_id":"challenge-123"}`, authResp.Body.String())
assert.Equal(t, 1, authService.sendEmailCodeCalls)
})
}
}
func TestPublicAntiAbuseSendEmailIdentityThrottle(t *testing.T) {
t.Parallel()
cfg := config.DefaultPublicHTTPConfig()
cfg.AntiAbuse.PublicAuth.RateLimit = config.PublicRateLimitConfig{
Requests: 100,
Window: time.Hour,
Burst: 100,
}
cfg.AntiAbuse.SendEmailCodeIdentity.RateLimit = config.PublicRateLimitConfig{
Requests: 1,
Window: time.Hour,
Burst: 1,
}
authService := &recordingAuthServiceClient{
sendEmailCodeResult: SendEmailCodeResult{
ChallengeID: "challenge-123",
},
}
handler := newPublicHandlerWithConfig(cfg, ServerDependencies{AuthService: authService})
first := sendEmailCodeRequest(`{"email":"pilot@example.com"}`)
second := sendEmailCodeRequest(`{"email":"pilot@example.com"}`)
third := sendEmailCodeRequest(`{"email":"other@example.com"}`)
firstResp := httptest.NewRecorder()
handler.ServeHTTP(firstResp, first)
secondResp := httptest.NewRecorder()
handler.ServeHTTP(secondResp, second)
thirdResp := httptest.NewRecorder()
handler.ServeHTTP(thirdResp, third)
assert.Equal(t, http.StatusOK, firstResp.Code)
assert.Equal(t, http.StatusTooManyRequests, secondResp.Code)
assert.Equal(t, "3600", secondResp.Header().Get("Retry-After"))
assert.Equal(t, http.StatusOK, thirdResp.Code)
assert.Equal(t, 2, authService.sendEmailCodeCalls)
thirdInput := authService.sendEmailCodeInput
assert.Equal(t, "other@example.com", thirdInput.Email)
}
func TestPublicAntiAbuseConfirmEmailIdentityThrottle(t *testing.T) {
t.Parallel()
cfg := config.DefaultPublicHTTPConfig()
cfg.AntiAbuse.PublicAuth.RateLimit = config.PublicRateLimitConfig{
Requests: 100,
Window: time.Hour,
Burst: 100,
}
cfg.AntiAbuse.ConfirmEmailCodeIdentity.RateLimit = config.PublicRateLimitConfig{
Requests: 1,
Window: time.Hour,
Burst: 1,
}
authService := &recordingAuthServiceClient{
confirmEmailCodeResult: ConfirmEmailCodeResult{
DeviceSessionID: "device-session-123",
},
}
handler := newPublicHandlerWithConfig(cfg, ServerDependencies{AuthService: authService})
first := confirmEmailCodeRequest(`{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material"}`)
second := confirmEmailCodeRequest(`{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material"}`)
third := confirmEmailCodeRequest(`{"challenge_id":"challenge-456","code":"123456","client_public_key":"public-key-material"}`)
firstResp := httptest.NewRecorder()
handler.ServeHTTP(firstResp, first)
secondResp := httptest.NewRecorder()
handler.ServeHTTP(secondResp, second)
thirdResp := httptest.NewRecorder()
handler.ServeHTTP(thirdResp, third)
assert.Equal(t, http.StatusOK, firstResp.Code)
assert.Equal(t, http.StatusTooManyRequests, secondResp.Code)
assert.Equal(t, "3600", secondResp.Header().Get("Retry-After"))
assert.Equal(t, http.StatusOK, thirdResp.Code)
assert.Equal(t, 2, authService.confirmEmailCodeCalls)
assert.Equal(t, "challenge-456", authService.confirmEmailCodeInput.ChallengeID)
}
func TestPublicAntiAbuseMalformedTelemetry(t *testing.T) {
t.Parallel()
tests := []struct {
name string
body string
wantReason PublicMalformedRequestReason
wantRecords int
}{
{
name: "empty body",
body: ``,
wantReason: PublicMalformedRequestReasonEmptyBody,
wantRecords: 1,
},
{
name: "malformed json",
body: `{"email":`,
wantReason: PublicMalformedRequestReasonMalformedJSON,
wantRecords: 1,
},
{
name: "invalid json value",
body: `{"email":123}`,
wantReason: PublicMalformedRequestReasonInvalidJSONValue,
wantRecords: 1,
},
{
name: "unknown field",
body: `{"email":"pilot@example.com","extra":"x"}`,
wantReason: PublicMalformedRequestReasonUnknownField,
wantRecords: 1,
},
{
name: "multiple objects",
body: `{"email":"pilot@example.com"}{"email":"pilot@example.com"}`,
wantReason: PublicMalformedRequestReasonMultipleJSONObjects,
wantRecords: 1,
},
{
name: "validation error does not count as malformed",
body: `{"email":"not-an-email"}`,
wantRecords: 0,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
observer := &recordingPublicRequestObserver{}
authService := &recordingAuthServiceClient{}
handler := newPublicHandlerWithConfig(config.DefaultPublicHTTPConfig(), ServerDependencies{
AuthService: authService,
Observer: observer,
})
req := sendEmailCodeRequest(tt.body)
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusBadRequest, recorder.Code)
assert.Equal(t, tt.wantRecords, len(observer.snapshot()))
assert.Equal(t, 0, authService.sendEmailCodeCalls)
if tt.wantRecords == 1 {
assert.Equal(t, malformedObservation{
class: PublicRouteClassPublicAuth,
reason: tt.wantReason,
}, observer.snapshot()[0])
}
})
}
}
func TestInMemoryPublicRequestLimiterCleansExpiredBuckets(t *testing.T) {
t.Parallel()
now := time.Unix(1000, 0)
limiter := newInMemoryPublicRequestLimiter()
limiter.now = func() time.Time {
return now
}
limiter.cleanupInterval = time.Second
policy := config.PublicRateLimitConfig{
Requests: 1,
Window: time.Minute,
Burst: 1,
}
firstDecision := limiter.Reserve("bucket-1", policy)
secondDecision := limiter.Reserve("bucket-2", policy)
require.True(t, firstDecision.Allowed)
require.True(t, secondDecision.Allowed)
require.Len(t, limiter.entries, 2)
now = now.Add(3 * time.Minute)
thirdDecision := limiter.Reserve("bucket-3", policy)
require.True(t, thirdDecision.Allowed)
assert.Len(t, limiter.entries, 1)
_, exists := limiter.entries["bucket-3"]
assert.True(t, exists)
}
func sendEmailCodeRequest(body string) *http.Request {
req := httptest.NewRequest(http.MethodPost, "/api/v1/public/auth/send-email-code", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = "192.0.2.10:1234"
return req
}
func confirmEmailCodeRequest(body string) *http.Request {
req := httptest.NewRequest(http.MethodPost, "/api/v1/public/auth/confirm-email-code", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = "192.0.2.10:1234"
return req
}
type malformedObservation struct {
class PublicRouteClass
reason PublicMalformedRequestReason
}
type recordingPublicRequestObserver struct {
mu sync.Mutex
observations []malformedObservation
}
func (o *recordingPublicRequestObserver) RecordMalformedRequest(class PublicRouteClass, reason PublicMalformedRequestReason) {
o.mu.Lock()
defer o.mu.Unlock()
o.observations = append(o.observations, malformedObservation{
class: class,
reason: reason,
})
}
func (o *recordingPublicRequestObserver) snapshot() []malformedObservation {
o.mu.Lock()
defer o.mu.Unlock()
return append([]malformedObservation(nil), o.observations...)
}