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)
}
}