Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
293 lines
11 KiB
Go
293 lines
11 KiB
Go
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")
|
|
}
|
|
}
|