feat: edge gateway service
This commit is contained in:
@@ -0,0 +1,455 @@
|
||||
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...)
|
||||
}
|
||||
Reference in New Issue
Block a user