package httpapi import ( "encoding/json" "errors" "net/http" "net/http/httptest" "strings" "testing" "github.com/descrybe/descrybe-v2/apps/api/internal/aiprovider" "github.com/descrybe/descrybe-v2/apps/api/internal/auth" "github.com/descrybe/descrybe-v2/apps/api/internal/billing" "github.com/descrybe/descrybe-v2/apps/api/internal/campaigns" "github.com/descrybe/descrybe-v2/apps/api/internal/catalog" "github.com/descrybe/descrybe-v2/apps/api/internal/company" emailpkg "github.com/descrybe/descrybe-v2/apps/api/internal/email" "github.com/descrybe/descrybe-v2/apps/api/internal/feeds" "github.com/descrybe/descrybe-v2/apps/api/internal/marketing" "github.com/descrybe/descrybe-v2/apps/api/internal/processing" "github.com/descrybe/descrybe-v2/apps/api/internal/seo" "github.com/descrybe/descrybe-v2/apps/api/internal/shopify" "github.com/descrybe/descrybe-v2/apps/api/internal/woocommerce" "github.com/jackc/pgx/v5" ) func TestLogAndErrorHidesInternalDetail(t *testing.T) { rec := httptest.NewRecorder() LogAndError(rec, http.StatusInternalServerError, "could not resolve upload", errors.New("open /secret/path: permission denied")) if rec.Code != http.StatusInternalServerError { t.Fatalf("status=%d", rec.Code) } var body map[string]string if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { t.Fatal(err) } if body["error"] != "could not resolve upload" { t.Fatalf("error=%q", body["error"]) } if strings.Contains(rec.Body.String(), "secret") { t.Fatal("leaked internal path detail") } } func TestClientOrLogPreservesAuthValidation(t *testing.T) { rec := httptest.NewRecorder() ClientOrLog(rec, http.StatusBadRequest, "registration failed", auth.ErrPasswordTooShort, auth.ClientError) if rec.Code != http.StatusBadRequest { t.Fatalf("status=%d", rec.Code) } if !strings.Contains(rec.Body.String(), "password must be at least 8 characters") { t.Fatalf("body=%s", rec.Body.String()) } } func TestClientOrLogHidesOpaqueAuthDBError(t *testing.T) { rec := httptest.NewRecorder() ClientOrLog(rec, http.StatusBadRequest, "registration failed", errors.New("ERROR: duplicate key value violates unique constraint \"users_email_key\" (SQLSTATE 23505)"), auth.ClientError) if rec.Code != http.StatusBadRequest { t.Fatalf("status=%d", rec.Code) } var body map[string]string if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { t.Fatal(err) } if body["error"] != "registration failed" { t.Fatalf("error=%q", body["error"]) } if strings.Contains(rec.Body.String(), "SQLSTATE") || strings.Contains(rec.Body.String(), "users_email") { t.Fatal("leaked DB detail") } } func TestClientOrLogPreservesBillingSentinel(t *testing.T) { rec := httptest.NewRecorder() ClientOrLog(rec, http.StatusBadRequest, "checkout failed", billing.ErrStripePlanUnsupported, billing.ClientError) if !strings.Contains(rec.Body.String(), "not available for self-serve") { t.Fatalf("body=%s", rec.Body.String()) } } func TestClientOrLogHidesStripeProviderError(t *testing.T) { rec := httptest.NewRecorder() ClientOrLog(rec, http.StatusBadRequest, "checkout failed", errors.New("stripe api 400: {\"error\":{\"message\":\"No such price: price_secret_abc\"}}"), billing.ClientError) var body map[string]string if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { t.Fatal(err) } if body["error"] != "checkout failed" { t.Fatalf("error=%q", body["error"]) } if strings.Contains(rec.Body.String(), "price_secret") || strings.Contains(rec.Body.String(), "No such price") { t.Fatal("leaked Stripe provider detail") } } func TestCatalogClientErrorPreservesValidation(t *testing.T) { msg, ok := catalog.ClientError(catalog.ClientMsg("name and unique_id required")) if !ok || msg != "name and unique_id required" { t.Fatalf("msg=%q ok=%v", msg, ok) } if _, ok := catalog.ClientError(errors.New("pq: relation \"categories\" does not exist")); ok { t.Fatal("opaque DB error must not be client-facing") } } func TestShopifyWooClientErrorSentinels(t *testing.T) { if msg, ok := shopify.ClientError(shopify.ErrMissingCreds); !ok || msg == "" { t.Fatal("shopify missing creds") } if _, ok := shopify.ClientError(errors.New("dial tcp 10.0.0.1:443: i/o timeout")); ok { t.Fatal("shopify opaque must not be client-facing") } if msg, ok := woocommerce.ClientError(woocommerce.ErrInvalidStoreURL); !ok || !strings.Contains(msg, "store url") { t.Fatalf("woo invalid url msg=%q ok=%v", msg, ok) } } func TestWritePublicExportErrorUsesFormatMismatchSentinel(t *testing.T) { rec := httptest.NewRecorder() writePublicExportError(rec, feeds.ErrFormatMismatch) // Must match unknown-token responses so format probes cannot confirm a token. if rec.Code != http.StatusNotFound { t.Fatalf("status=%d", rec.Code) } if !strings.Contains(rec.Body.String(), "export feed not found") { t.Fatalf("body=%s", rec.Body.String()) } if strings.Contains(rec.Body.String(), "format mismatch") { t.Fatalf("must not leak format mismatch: body=%s", rec.Body.String()) } rec = httptest.NewRecorder() writePublicExportError(rec, pgx.ErrNoRows) if rec.Code != http.StatusNotFound { t.Fatalf("status=%d", rec.Code) } } func TestFeedsClientErrorPreservesValidation(t *testing.T) { msg, ok := feeds.ClientError(feeds.ClientMsg("name required")) if !ok || msg != "name required" { t.Fatalf("msg=%q ok=%v", msg, ok) } if _, ok := feeds.ClientError(errors.New("pq: relation \"input_feeds\" does not exist")); ok { t.Fatal("opaque DB error must not be client-facing") } ClientOrLog(httptest.NewRecorder(), http.StatusBadRequest, "could not create feed", errors.New("dial tcp timeout"), feeds.ClientError) } func TestCampaignsClientErrorPreservesSentinel(t *testing.T) { rec := httptest.NewRecorder() ClientOrLog(rec, http.StatusBadRequest, "could not create campaign", campaigns.ErrNameRequired, campaigns.ClientError) if !strings.Contains(rec.Body.String(), "name required") { t.Fatalf("body=%s", rec.Body.String()) } rec = httptest.NewRecorder() ClientOrLog(rec, http.StatusBadRequest, "could not create campaign", errors.New("ERROR: duplicate key"), campaigns.ClientError) var body map[string]string if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { t.Fatal(err) } if body["error"] != "could not create campaign" { t.Fatalf("error=%q", body["error"]) } } func TestEmailClientErrorHidesProviderDetail(t *testing.T) { rec := httptest.NewRecorder() ClientOrLog(rec, http.StatusBadRequest, "could not update email settings", emailpkg.ClientMsg("provider must be resend or smtp"), emailpkg.ClientError) if !strings.Contains(rec.Body.String(), "provider must be resend or smtp") { t.Fatalf("body=%s", rec.Body.String()) } rec = httptest.NewRecorder() ClientOrLog(rec, http.StatusBadRequest, "email verification failed", errors.New("resend api 500: internal secret"), emailpkg.ClientError) var body map[string]string if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { t.Fatal(err) } if body["error"] != "email verification failed" { t.Fatalf("error=%q", body["error"]) } if strings.Contains(rec.Body.String(), "secret") { t.Fatal("leaked provider detail") } } func TestAIProviderClientErrorHidesBaseURLDetail(t *testing.T) { rec := httptest.NewRecorder() ClientOrLog(rec, http.StatusBadRequest, "could not update ai settings", aiprovider.ErrInvalidMode, aiprovider.ClientError) if !strings.Contains(rec.Body.String(), "mode must be") { t.Fatalf("body=%s", rec.Body.String()) } rec = httptest.NewRecorder() ClientOrLog(rec, http.StatusBadRequest, "could not update ai settings", errors.New("encrypt: cipher: message authentication failed"), aiprovider.ClientError) var body map[string]string if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { t.Fatal(err) } if body["error"] != "could not update ai settings" { t.Fatalf("error=%q", body["error"]) } } func TestMarketingClientErrorPreservesPresetValidation(t *testing.T) { rec := httptest.NewRecorder() ClientOrLog(rec, http.StatusBadRequest, "could not prepare campaign", marketing.ClientMsg("preset_id must be black_friday or christmas"), marketing.ClientError) if !strings.Contains(rec.Body.String(), "preset_id must be") { t.Fatalf("body=%s", rec.Body.String()) } } func TestAuthInviteEmailRequired(t *testing.T) { rec := httptest.NewRecorder() ClientOrLog(rec, http.StatusBadRequest, "could not create invite", auth.ErrEmailRequired, auth.ClientError) if !strings.Contains(rec.Body.String(), "email is required") { t.Fatalf("body=%s", rec.Body.String()) } } func TestProcessingRateLimitStatusViaSentinel(t *testing.T) { // Mirror handler status selection without spinning up Server deps. err := processing.ErrRateLimited status := http.StatusBadRequest if errors.Is(err, processing.ErrRateLimited) { status = http.StatusTooManyRequests } if status != http.StatusTooManyRequests { t.Fatalf("status=%d", status) } if strings.Contains(err.Error(), "rate limit") && !errors.Is(err, processing.ErrRateLimited) { t.Fatal("regression: string matching alone is insufficient") } } func TestSEONotFoundUsesSentinel(t *testing.T) { if !errors.Is(seo.ErrNotFound, seo.ErrNotFound) { t.Fatal("seo.ErrNotFound identity broken") } opaque := errors.New("product row not found in warehouse") if errors.Is(opaque, seo.ErrNotFound) { t.Fatal("opaque message must not match ErrNotFound") } } func TestSEOClientErrorPreservesInvalidMode(t *testing.T) { rec := httptest.NewRecorder() ClientOrLog(rec, http.StatusBadRequest, "seo apply failed", seo.ErrInvalidMode, seo.ClientError) if rec.Code != http.StatusBadRequest { t.Fatalf("status=%d", rec.Code) } if !strings.Contains(rec.Body.String(), "mode must be template or ai") { t.Fatalf("body=%s", rec.Body.String()) } rec = httptest.NewRecorder() ClientOrLog(rec, http.StatusBadRequest, "seo apply failed", errors.New("openai: api key sk-secret leaked"), seo.ClientError) var body map[string]string if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { t.Fatal(err) } if body["error"] != "seo apply failed" { t.Fatalf("error=%q", body["error"]) } if strings.Contains(rec.Body.String(), "sk-secret") { t.Fatal("leaked opaque SEO apply detail") } } func TestBrandLogoClientErrorPreservesValidation(t *testing.T) { rec := httptest.NewRecorder() ClientOrLog(rec, http.StatusBadRequest, "could not upload logo", company.ErrLogoInvalidType, company.ClientError) if !strings.Contains(rec.Body.String(), "logo must be PNG, JPEG, or WebP") { t.Fatalf("body=%s", rec.Body.String()) } rec = httptest.NewRecorder() ClientOrLog(rec, http.StatusBadRequest, "could not upload logo", company.ErrLogoTooLarge, company.ClientError) if !strings.Contains(rec.Body.String(), "logo exceeds 2 MiB limit") { t.Fatalf("body=%s", rec.Body.String()) } rec = httptest.NewRecorder() ClientOrLog(rec, http.StatusBadRequest, "could not upload logo", errors.New("open /secret/uploads: permission denied"), company.ClientError) var body map[string]string if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { t.Fatal(err) } if body["error"] != "could not upload logo" { t.Fatalf("error=%q", body["error"]) } if strings.Contains(rec.Body.String(), "secret") || strings.Contains(rec.Body.String(), "permission denied") { t.Fatal("leaked filesystem detail") } }