package httpapi import ( "context" "net/http" "net/http/httptest" "testing" "github.com/descrybe/descrybe-v2/apps/api/internal/config" "github.com/go-chi/chi/v5" "github.com/google/uuid" ) func TestRateLimitAuthBlocksBurst(t *testing.T) { t.Parallel() s := &Server{Config: config.Config{}} h := s.RateLimitAuth(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })) var saw429 bool for i := 0; i < authLoginRPM+5; i++ { req := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil) req.RemoteAddr = "203.0.113.10:12345" rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code == http.StatusTooManyRequests { saw429 = true break } if rec.Code != http.StatusNoContent { t.Fatalf("unexpected status %d", rec.Code) } } if !saw429 { t.Fatal("expected 429 after auth burst") } } func TestRateLimitAuthRegisterStricterThanLogin(t *testing.T) { t.Parallel() s := &Server{Config: config.Config{}} h := s.RateLimitAuth(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })) var saw429 bool for i := 0; i < authRegisterRPM+3; i++ { req := httptest.NewRequest(http.MethodPost, "/api/auth/register", nil) req.RemoteAddr = "203.0.113.20:12345" rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code == http.StatusTooManyRequests { saw429 = true break } if rec.Code != http.StatusNoContent { t.Fatalf("unexpected status %d", rec.Code) } } if !saw429 { t.Fatal("expected 429 after register burst") } // Login budget is independent — register exhaust must not block login. login := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil) login.RemoteAddr = "203.0.113.20:12345" recLogin := httptest.NewRecorder() h.ServeHTTP(recLogin, login) if recLogin.Code != http.StatusNoContent { t.Fatalf("login should use separate bucket, got %d", recLogin.Code) } } func TestRateLimitAuthIncludesSalesContact(t *testing.T) { t.Parallel() s := &Server{Config: config.Config{}} h := s.RateLimitAuth(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })) var saw429 bool for i := 0; i < authLoginRPM+3; i++ { req := httptest.NewRequest(http.MethodPost, "/api/sales/contact", nil) req.RemoteAddr = "203.0.113.21:12345" rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code == http.StatusTooManyRequests { saw429 = true break } } if !saw429 { t.Fatal("expected 429 after sales contact burst") } } func TestRateLimitAuthSkipsSafeMethods(t *testing.T) { t.Parallel() s := &Server{Config: config.Config{}} h := s.RateLimitAuth(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })) for i := 0; i < 30; i++ { req := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil) req.RemoteAddr = "203.0.113.11:12345" rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusNoContent { t.Fatalf("GET should not be rate-limited, got %d", rec.Code) } } } func TestRateLimitAdminPlanFeaturesBlocksBurst(t *testing.T) { t.Parallel() s := &Server{Config: config.Config{}} uid := uuid.MustParse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb") h := s.RateLimitAdminPlanFeatures(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })) var saw429 bool for i := 0; i < adminPlanFeaturesGetRPM+5; i++ { req := httptest.NewRequest(http.MethodGet, "/api/admin/plans/1/features", nil) req = req.WithContext(context.WithValue(req.Context(), ctxUserID, uid)) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code == http.StatusTooManyRequests { saw429 = true break } if rec.Code != http.StatusNoContent { t.Fatalf("unexpected status %d", rec.Code) } } if !saw429 { t.Fatal("expected 429 after plan-features GET burst") } } func TestRateLimitAdminAnalyticsBlocksBurst(t *testing.T) { t.Parallel() s := &Server{Config: config.Config{}} uid := uuid.MustParse("cccccccc-cccc-cccc-cccc-cccccccccccc") h := s.RateLimitAdminAnalytics(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })) var saw429 bool for i := 0; i < adminAnalyticsGetRPM+5; i++ { req := httptest.NewRequest(http.MethodGet, "/api/admin/analytics", nil) req = req.WithContext(context.WithValue(req.Context(), ctxUserID, uid)) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code == http.StatusTooManyRequests { saw429 = true break } if rec.Code != http.StatusNoContent { t.Fatalf("unexpected status %d", rec.Code) } } if !saw429 { t.Fatal("expected 429 after analytics GET burst") } } func TestIsAdminAnalyticsGet(t *testing.T) { t.Parallel() cases := []struct { method string path string want bool }{ {http.MethodGet, "/api/admin/analytics", true}, {http.MethodGet, "/api/admin/analytics/", true}, {http.MethodGet, "/api/admin/diagnostics", true}, {http.MethodGet, "/api/admin/diagnostics/", true}, {http.MethodPost, "/api/admin/analytics", false}, {http.MethodGet, "/api/admin/readiness", false}, {http.MethodGet, "/api/admin/jobs", false}, } for _, tc := range cases { req := httptest.NewRequest(tc.method, tc.path, nil) if got := isAdminAnalyticsGet(req); got != tc.want { t.Fatalf("%s %s: got %v want %v", tc.method, tc.path, got, tc.want) } } } func TestIsAIOrMailProbePOST(t *testing.T) { t.Parallel() cases := []struct { method string path string want bool }{ {http.MethodPost, "/api/integrations/ai/test", true}, {http.MethodPost, "/api/admin/settings/mail/test", true}, {http.MethodPost, "/api/admin/settings/ai-roles/support/test", true}, {http.MethodGet, "/api/integrations/ai/test", false}, {http.MethodPost, "/api/admin/settings", false}, } for _, tc := range cases { req := httptest.NewRequest(tc.method, tc.path, nil) if got := isAIOrMailProbePOST(req); got != tc.want { t.Fatalf("%s %s: got %v want %v", tc.method, tc.path, got, tc.want) } } } func TestRateLimitAIProbesBlocksBurst(t *testing.T) { t.Parallel() s := &Server{Config: config.Config{}} uid := uuid.MustParse("dddddddd-dddd-dddd-dddd-dddddddddddd") h := s.RateLimitAIProbes(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })) var saw429 bool for i := 0; i < aiProbeRPM+5; i++ { req := httptest.NewRequest(http.MethodPost, "/api/integrations/ai/test", nil) req = req.WithContext(context.WithValue(req.Context(), ctxUserID, uid)) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code == http.StatusTooManyRequests { saw429 = true break } if rec.Code != http.StatusNoContent { t.Fatalf("unexpected status %d", rec.Code) } } if !saw429 { t.Fatal("expected 429 after AI probe burst") } } func TestIsAdminPlanFeaturesGet(t *testing.T) { t.Parallel() cases := []struct { method string path string want bool }{ {http.MethodGet, "/api/admin/plans/1/features", true}, {http.MethodGet, "/api/admin/plans/99/features/", true}, {http.MethodPut, "/api/admin/plans/1/features", false}, {http.MethodGet, "/api/admin/plans", false}, {http.MethodGet, "/api/admin/feature-gates", false}, {http.MethodPost, "/api/admin/plans/1/features/enable-all", false}, } for _, tc := range cases { req := httptest.NewRequest(tc.method, tc.path, nil) if got := isAdminPlanFeaturesGet(req); got != tc.want { t.Fatalf("%s %s: got %v want %v", tc.method, tc.path, got, tc.want) } } } func TestIsHeavyFeedOrProcessMutation(t *testing.T) { t.Parallel() cases := []struct { method string path string want bool }{ {http.MethodPost, "/api/v1/feeds/abc/sync", true}, {http.MethodPost, "/api/feeds/abc/sync", true}, {http.MethodPost, "/api/v1/feeds/abc/extract-schema", true}, {http.MethodPost, "/api/feeds/abc/extract-schema", true}, {http.MethodPost, "/api/v1/feeds/abc/sync-process-sample", true}, {http.MethodPost, "/api/v1/process", true}, {http.MethodPost, "/api/processing/jobs", true}, {http.MethodPost, "/api/processing/jobs/abc/retry", true}, {http.MethodPost, "/api/v1/process/abc/retry", true}, {http.MethodPost, "/api/export-feeds/abc/generate", true}, {http.MethodPost, "/api/v1/export-feeds/abc/generate", true}, {http.MethodPost, "/api/export-feeds/abc/export-products", true}, {http.MethodPost, "/api/v1/export-feeds/abc/export-products", true}, {http.MethodGet, "/api/v1/feeds/abc/sync", false}, {http.MethodPost, "/api/v1/feeds/abc/mappings", false}, {http.MethodPost, "/api/integrations/shopify/sync", false}, {http.MethodPost, "/api/woocommerce/sync", false}, {http.MethodPost, "/api/processing/jobs/abc/cancel", false}, {http.MethodPost, "/api/campaigns/abc/generate", false}, } for _, tc := range cases { req := httptest.NewRequest(tc.method, tc.path, nil) if got := isHeavyFeedOrProcessMutation(req); got != tc.want { t.Fatalf("%s %s: got %v want %v", tc.method, tc.path, got, tc.want) } } } func TestRateLimitV1ProcessBlocksBurst(t *testing.T) { t.Parallel() s := &Server{Config: config.Config{}} h := s.RateLimitV1Process(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })) var saw429 bool for i := 0; i < heavyMutationRPM+5; i++ { req := httptest.NewRequest(http.MethodPost, "/api/export-feeds/abc/generate", nil) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code == http.StatusTooManyRequests { saw429 = true if rec.Header().Get("Retry-After") == "" { t.Fatal("expected Retry-After on 429") } if rec.Header().Get("RateLimit") == "" { t.Fatal("expected RateLimit on 429") } if rec.Header().Get("RateLimit-Policy") == "" { t.Fatal("expected RateLimit-Policy on 429") } break } if rec.Code != http.StatusNoContent { t.Fatalf("unexpected status %d", rec.Code) } } if !saw429 { t.Fatal("expected 429 after heavy mutation burst") } } func TestRateLimitEffectiveCap(t *testing.T) { t.Parallel() if got := rateLimitEffectiveCap(30, 1); got != 30 { t.Fatalf("replicas=1 want 30 got %d", got) } if got := rateLimitEffectiveCap(30, 3); got != 10 { t.Fatalf("replicas=3 want 10 got %d", got) } if got := rateLimitEffectiveCap(30, 7); got != 5 { t.Fatalf("replicas=7 want ceil(30/7)=5 got %d", got) } if got := rateLimitEffectiveCap(0, 2); got != 1 { t.Fatalf("base<=0 want 1 got %d", got) } if got := rateLimitEffectiveCap(10, 0); got != 10 { t.Fatalf("replicas<=1 want base got %d", got) } } func TestRateLimitAuthRespectsReplicas(t *testing.T) { t.Parallel() // ceil(authRegisterRPM/5)=1 → second register must 429 s := &Server{Config: config.Config{RateLimitReplicas: authRegisterRPM}} h := s.RateLimitAuth(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })) req1 := httptest.NewRequest(http.MethodPost, "/api/auth/register", nil) req1.RemoteAddr = "203.0.113.50:40000" rec1 := httptest.NewRecorder() h.ServeHTTP(rec1, req1) if rec1.Code != http.StatusNoContent { t.Fatalf("first status=%d", rec1.Code) } req2 := httptest.NewRequest(http.MethodPost, "/api/auth/register", nil) req2.RemoteAddr = "203.0.113.50:40000" rec2 := httptest.NewRecorder() h.ServeHTTP(rec2, req2) if rec2.Code != http.StatusTooManyRequests { t.Fatalf("second status=%d want 429", rec2.Code) } } func TestRateLimitV1ProcessRespectsReplicas(t *testing.T) { t.Parallel() s := &Server{Config: config.Config{RateLimitReplicas: heavyMutationRPM}} h := s.RateLimitV1Process(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })) // ceil(30/30)=1 → second request must 429 req1 := httptest.NewRequest(http.MethodPost, "/api/v1/process", nil) rec1 := httptest.NewRecorder() h.ServeHTTP(rec1, req1) if rec1.Code != http.StatusNoContent { t.Fatalf("first status=%d", rec1.Code) } req2 := httptest.NewRequest(http.MethodPost, "/api/v1/process", nil) rec2 := httptest.NewRecorder() h.ServeHTTP(rec2, req2) if rec2.Code != http.StatusTooManyRequests { t.Fatalf("second status=%d want 429", rec2.Code) } } func TestRateLimitV1ProcessSeparateCompanies(t *testing.T) { t.Parallel() s := &Server{Config: config.Config{}} h := s.RateLimitV1Process(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })) cidA := uuid.MustParse("11111111-1111-1111-1111-111111111111") cidB := uuid.MustParse("22222222-2222-2222-2222-222222222222") for i := 0; i < heavyMutationRPM; i++ { req := httptest.NewRequest(http.MethodPost, "/api/v1/process", nil) req = req.WithContext(context.WithValue(req.Context(), ctxCompanyID, cidA)) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusNoContent { t.Fatalf("company A request %d: status %d", i, rec.Code) } } blocked := httptest.NewRequest(http.MethodPost, "/api/v1/process", nil) blocked = blocked.WithContext(context.WithValue(blocked.Context(), ctxCompanyID, cidA)) recBlocked := httptest.NewRecorder() h.ServeHTTP(recBlocked, blocked) if recBlocked.Code != http.StatusTooManyRequests { t.Fatalf("company A should be limited, got %d", recBlocked.Code) } okB := httptest.NewRequest(http.MethodPost, "/api/export-feeds/abc/generate", nil) okB = okB.WithContext(context.WithValue(okB.Context(), ctxCompanyID, cidB)) recB := httptest.NewRecorder() h.ServeHTTP(recB, okB) if recB.Code != http.StatusNoContent { t.Fatalf("company B should not share A budget, got %d", recB.Code) } } func TestRateLimitPublicBlocksBurst(t *testing.T) { t.Parallel() s := &Server{Config: config.Config{}} h := s.RateLimitPublic(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })) var saw429 bool for i := 0; i < 40; i++ { req := httptest.NewRequest(http.MethodGet, "/api/public/unsubscribe", nil) req.RemoteAddr = "203.0.113.50:12345" rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code == http.StatusTooManyRequests { saw429 = true break } if rec.Code != http.StatusNoContent { t.Fatalf("unexpected status %d", rec.Code) } } if !saw429 { t.Fatal("expected 429 after public burst") } } func TestRateLimitPublicExportRejectsBadTokenShape(t *testing.T) { t.Parallel() s := &Server{Config: config.Config{}} r := chi.NewRouter() r.With(s.RateLimitPublicExport).Get("/export-feeds/{token}.xml", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }) req := httptest.NewRequest(http.MethodGet, "/export-feeds/short.xml", nil) req.RemoteAddr = "203.0.113.60:1" rec := httptest.NewRecorder() r.ServeHTTP(rec, req) if rec.Code != http.StatusNotFound { t.Fatalf("expected 404 for bad token shape, got %d", rec.Code) } } func TestRateLimitPublicExportBlocksIPBurst(t *testing.T) { t.Parallel() s := &Server{Config: config.Config{}} r := chi.NewRouter() r.With(s.RateLimitPublicExport).Get("/export-feeds/{token}.xml", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }) token := "0123456789abcdef0123456789abcdef" var saw429 bool for i := 0; i < 40; i++ { req := httptest.NewRequest(http.MethodGet, "/export-feeds/"+token+".xml", nil) req.RemoteAddr = "203.0.113.61:1" rec := httptest.NewRecorder() r.ServeHTTP(rec, req) if rec.Code == http.StatusTooManyRequests { saw429 = true break } if rec.Code != http.StatusNoContent { t.Fatalf("unexpected status %d", rec.Code) } } if !saw429 { t.Fatal("expected 429 after public export burst") } } func TestRateLimitAPIKeyAttemptsBlocksBurst(t *testing.T) { t.Parallel() s := &Server{Config: config.Config{}} h := s.RateLimitAPIKeyAttempts(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })) var saw429 bool for i := 0; i < apiKeyAttemptRPM+5; i++ { req := httptest.NewRequest(http.MethodGet, "/api/v1/products", nil) req.RemoteAddr = "203.0.113.70:12345" req.Header.Set("X-API-Key", "dk_test_key") rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code == http.StatusTooManyRequests { saw429 = true break } if rec.Code != http.StatusNoContent { t.Fatalf("unexpected status %d", rec.Code) } } if !saw429 { t.Fatal("expected 429 after API key attempt burst") } } func TestRateLimitAPIKeyCompanyBudget(t *testing.T) { t.Parallel() s := &Server{Config: config.Config{}} h := s.RateLimitAPIKey(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })) cid := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") var saw429 bool for i := 0; i < apiKeyCompanyRPM+5; i++ { req := httptest.NewRequest(http.MethodGet, "/api/v1/products", nil) req = req.WithContext(context.WithValue(req.Context(), ctxCompanyID, cid)) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code == http.StatusTooManyRequests { saw429 = true break } if rec.Code != http.StatusNoContent { t.Fatalf("unexpected status %d", rec.Code) } } if !saw429 { t.Fatal("expected 429 after API key company burst") } }