Initial commit of Descrybe v2 without local scratch artifacts.

Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
2026-08-09 22:47:43 +02:00
commit 8580c996c3
1285 changed files with 325780 additions and 0 deletions
+538
View File
@@ -0,0 +1,538 @@
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")
}
}