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","time_zone":"` + confirmEmailCodeTestTimeZone + `"}` 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","time_zone":"` + confirmEmailCodeTestTimeZone + `"}`) second := confirmEmailCodeRequest(`{"challenge_id":"challenge-123","code":"123456","client_public_key":"public-key-material","time_zone":"` + confirmEmailCodeTestTimeZone + `"}`) third := confirmEmailCodeRequest(`{"challenge_id":"challenge-456","code":"123456","client_public_key":"public-key-material","time_zone":"` + confirmEmailCodeTestTimeZone + `"}`) 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...) }