Files
descrybe/apps/api/internal/httpapi/public_error_test.go
T

293 lines
11 KiB
Go
Raw Normal View History

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