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