181 lines
7.2 KiB
Go
181 lines
7.2 KiB
Go
package externaladmin
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"hyapp-admin-server/internal/cache"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type allowFixedWindowLimiter struct{}
|
|
|
|
func (allowFixedWindowLimiter) ConsumeFixedWindow(context.Context, time.Duration, []cache.FixedWindowRule) (cache.FixedWindowDecision, error) {
|
|
return cache.FixedWindowDecision{Allowed: true}, nil
|
|
}
|
|
|
|
type recordingFixedWindowLimiter struct {
|
|
decision cache.FixedWindowDecision
|
|
err error
|
|
calls int
|
|
window time.Duration
|
|
rules []cache.FixedWindowRule
|
|
batches [][]cache.FixedWindowRule
|
|
deadlineSet bool
|
|
deadlineIn time.Duration
|
|
}
|
|
|
|
func (limiter *recordingFixedWindowLimiter) ConsumeFixedWindow(ctx context.Context, window time.Duration, rules []cache.FixedWindowRule) (cache.FixedWindowDecision, error) {
|
|
limiter.calls++
|
|
limiter.window = window
|
|
limiter.rules = append([]cache.FixedWindowRule(nil), rules...)
|
|
limiter.batches = append(limiter.batches, append([]cache.FixedWindowRule(nil), rules...))
|
|
deadline, ok := ctx.Deadline()
|
|
limiter.deadlineSet = ok
|
|
if ok {
|
|
limiter.deadlineIn = time.Until(deadline)
|
|
}
|
|
return limiter.decision, limiter.err
|
|
}
|
|
|
|
func TestLoginRateLimiterUsesTrustedHashedLayersBeforeAccountResolution(t *testing.T) {
|
|
limiter := &recordingFixedWindowLimiter{decision: cache.FixedWindowDecision{Allowed: true}}
|
|
service := NewService(&gorm.DB{}, nil, Config{LoginRateLimit: LoginRateLimitConfig{
|
|
Window: time.Minute, SystemLimit: 300, AppLimit: 120, IPLimit: 30, IdentityLimit: 10,
|
|
}})
|
|
service.limiter = limiter
|
|
|
|
// Empty password is invalid, but this syntactically valid attempt must consume
|
|
// distributed counters before dummy bcrypt/input rejection.
|
|
_, err := service.Login(t.Context(), LoginInput{Username: "Operator", IP: "203.0.113.44"})
|
|
if !errors.Is(err, ErrInvalidCredentials) {
|
|
t.Fatalf("login error = %v", err)
|
|
}
|
|
if limiter.calls != 1 || limiter.window != time.Minute || len(limiter.rules) != 3 {
|
|
t.Fatalf("limiter call=%d window=%s rules=%+v", limiter.calls, limiter.window, limiter.rules)
|
|
}
|
|
if !limiter.deadlineSet || limiter.deadlineIn <= 0 || limiter.deadlineIn > loginLimiterTimeout+25*time.Millisecond {
|
|
t.Fatalf("independent limiter deadline = %s set=%t", limiter.deadlineIn, limiter.deadlineSet)
|
|
}
|
|
wantLimits := []uint64{300, 30, 10}
|
|
for index, rule := range limiter.rules {
|
|
if rule.Limit != wantLimits[index] {
|
|
t.Fatalf("rule %d limit=%d", index, rule.Limit)
|
|
}
|
|
if !strings.Contains(rule.Key, loginLimiterClusterTag) {
|
|
t.Fatalf("rule %d is not Redis Cluster-safe: %s", index, rule.Key)
|
|
}
|
|
for _, secret := range []string{"operator", "203.0.113.44"} {
|
|
if strings.Contains(rule.Key, secret) {
|
|
t.Fatalf("rule %d leaks %q: %s", index, secret, rule.Key)
|
|
}
|
|
}
|
|
}
|
|
if limiter.rules[0].Key != "rate:{external-login}:system" {
|
|
t.Fatalf("system-global key must not vary by App: %s", limiter.rules[0].Key)
|
|
}
|
|
if len(loginRateLimitDigest("fami")) != 32 || loginRateLimitDigest("fami") == loginRateLimitDigest("lalu") {
|
|
t.Fatal("rate-limit identifiers must use a truncated SHA-256 digest")
|
|
}
|
|
}
|
|
|
|
func TestLoginRateLimitDenialAndRedisFailurePrecedeAllDatabaseWork(t *testing.T) {
|
|
t.Run("limited", func(t *testing.T) {
|
|
limiter := &recordingFixedWindowLimiter{decision: cache.FixedWindowDecision{Allowed: false, RetryAfter: 1500 * time.Millisecond}}
|
|
service := NewService(nil, nil, Config{})
|
|
service.limiter = limiter
|
|
_, err := service.Login(t.Context(), LoginInput{Username: "operator", Password: "password", IP: "127.0.0.1"})
|
|
var rateErr *LoginRateLimitError
|
|
if !errors.As(err, &rateErr) || rateErr.RetryAfter != 1500*time.Millisecond {
|
|
t.Fatalf("rate error = %#v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("redis unavailable", func(t *testing.T) {
|
|
limiter := &recordingFixedWindowLimiter{err: errors.New("redis down")}
|
|
service := NewService(nil, nil, Config{})
|
|
service.limiter = limiter
|
|
_, err := service.Login(t.Context(), LoginInput{Username: "operator", Password: "password", IP: "127.0.0.1"})
|
|
if !errors.Is(err, ErrLoginLimiterFailed) {
|
|
t.Fatalf("login error = %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("missing redis", func(t *testing.T) {
|
|
service := NewService(nil, nil, Config{})
|
|
_, err := service.Login(t.Context(), LoginInput{Username: "operator", Password: "password", IP: "127.0.0.1"})
|
|
if !errors.Is(err, ErrLoginLimiterFailed) {
|
|
t.Fatalf("login error = %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestLoginHandlerReturns429RetryAfterAnd503OnLimiterFailure(t *testing.T) {
|
|
for _, testCase := range []struct {
|
|
name string
|
|
limiter *recordingFixedWindowLimiter
|
|
wantStatus int
|
|
wantRetry string
|
|
body string
|
|
}{
|
|
{
|
|
name: "limited", limiter: &recordingFixedWindowLimiter{decision: cache.FixedWindowDecision{Allowed: false, RetryAfter: 1500 * time.Millisecond}},
|
|
wantStatus: http.StatusTooManyRequests, wantRetry: "2", body: `{"account":"operator","password":"password"}`,
|
|
},
|
|
{
|
|
name: "valid empty json still counted", limiter: &recordingFixedWindowLimiter{decision: cache.FixedWindowDecision{Allowed: false, RetryAfter: time.Second}},
|
|
wantStatus: http.StatusTooManyRequests, wantRetry: "1", body: `{}`,
|
|
},
|
|
{
|
|
name: "redis unavailable", limiter: &recordingFixedWindowLimiter{err: errors.New("redis down")},
|
|
wantStatus: http.StatusServiceUnavailable, body: `{"account":"operator","password":"password"}`,
|
|
},
|
|
} {
|
|
t.Run(testCase.name, func(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
engine := gin.New()
|
|
handler := New(nil, nil, Config{}, nil, WithLoginRateLimiter(testCase.limiter))
|
|
RegisterExternalRoutes(engine.Group("/api/v1"), handler, BusinessHandlers{})
|
|
request := httptest.NewRequest(http.MethodPost, "/api/v1/external/auth/login", strings.NewReader(testCase.body))
|
|
request.Header.Set("Content-Type", "application/json")
|
|
responseRecorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(responseRecorder, request)
|
|
if responseRecorder.Code != testCase.wantStatus {
|
|
t.Fatalf("status=%d body=%s", responseRecorder.Code, responseRecorder.Body.String())
|
|
}
|
|
if got := responseRecorder.Header().Get("Retry-After"); got != testCase.wantRetry {
|
|
t.Fatalf("Retry-After=%q want=%q", got, testCase.wantRetry)
|
|
}
|
|
if responseRecorder.Header().Get("Cache-Control") != "no-store" {
|
|
t.Fatal("rate-limit auth responses must remain non-cacheable")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLoginHandlerCapsBodyBeforeDistributedLimiter(t *testing.T) {
|
|
limiter := &recordingFixedWindowLimiter{decision: cache.FixedWindowDecision{Allowed: true}}
|
|
gin.SetMode(gin.TestMode)
|
|
engine := gin.New()
|
|
handler := New(nil, nil, Config{}, nil, WithLoginRateLimiter(limiter))
|
|
RegisterExternalRoutes(engine.Group("/api/v1"), handler, BusinessHandlers{})
|
|
body := `{"account":"operator","password":"` + strings.Repeat("x", int(maxLoginBodyBytes)) + `"}`
|
|
request := httptest.NewRequest(http.MethodPost, "/api/v1/external/auth/login", strings.NewReader(body))
|
|
request.Header.Set("Content-Type", "application/json")
|
|
responseRecorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(responseRecorder, request)
|
|
if responseRecorder.Code != http.StatusRequestEntityTooLarge {
|
|
t.Fatalf("status=%d body=%s", responseRecorder.Code, responseRecorder.Body.String())
|
|
}
|
|
if limiter.calls != 0 {
|
|
t.Fatalf("oversized, unparseable body reached limiter %d times", limiter.calls)
|
|
}
|
|
}
|