Files
easyai-ai-gateway/apps/api/internal/publicerror/public_error_test.go
T
wangbo ebdb96e7d7 fix(provider): 修正媒体请求转换与上游错误透传
按上游协议能力延迟处理媒体资源:OpenAI 兼容平台默认使用 multipart,显式配置后才发送 JSON URL;Gemini 官方协议使用 Files API,兼容协议使用内嵌 Base64,并同步覆盖相关媒体客户端。\n\n安全的上游 400/422 原始错误会作为下游 message 返回,同时保留结构化诊断信息和历史任务兼容。\n\n验证:API 全量无缓存测试、go vet、pnpm lint、pnpm test、pnpm build、pnpm openapi、git diff --check。
2026-08-05 00:40:42 +08:00

132 lines
6.4 KiB
Go

package publicerror
import (
"net/http"
"strings"
"testing"
)
func TestTransportErrorsNeverExposeSocketDetails(t *testing.T) {
raw := "read tcp 10.42.0.72:54960->47.77.191.126:443: read: connection reset by peer"
got := FromFields("response_read_error", raw, http.StatusOK, true)
if got.Code != "upstream_connection_interrupted" || got.HTTPStatus != http.StatusBadGateway || !got.Retryable {
t.Fatalf("unexpected public error: %+v", got)
}
if strings.Contains(got.Message, "10.42.0.72") || strings.Contains(got.Message, "47.77.191.126") {
t.Fatalf("public error leaked socket details: %+v", got)
}
}
func TestStorageAndExpiredErrorsHaveStableActions(t *testing.T) {
storage := FromFields("storage_write_failed", "secret endpoint", 0, true)
if storage.Category != "storage" || storage.Action != "retry" || storage.HTTPStatus != http.StatusServiceUnavailable {
t.Fatalf("unexpected storage error: %+v", storage)
}
expired := FromFields("binary_result_expired", "local binary result has expired", 0, false)
if expired.Code != "result_expired" || expired.Action != "resubmit" || expired.HTTPStatus != http.StatusGone {
t.Fatalf("unexpected expired error: %+v", expired)
}
}
func TestUnknownServerErrorDoesNotExposeProviderBody(t *testing.T) {
raw := `provider rejected request: {"account":"secret-project","detail":"internal"}`
got := FromFields("vendor_opaque_error", raw, http.StatusBadGateway, true)
if got.Code != "gateway_error" || got.Message == raw || strings.Contains(got.Message, "secret-project") {
t.Fatalf("unknown provider error was exposed: %+v", got)
}
}
func TestProviderHTTPErrorDoesNotExposeProviderBody(t *testing.T) {
raw := `{"error":{"message":"bucket private-a rejected secret-project"}}`
for _, test := range []struct {
code string
status int
wantCode string
wantStatus int
}{
{code: "http_400", status: http.StatusBadRequest, wantCode: "upstream_invalid_request", wantStatus: http.StatusBadRequest},
{code: "http_404", status: http.StatusNotFound, wantCode: "upstream_not_found", wantStatus: http.StatusNotFound},
{code: "http_405", status: http.StatusMethodNotAllowed, wantCode: "upstream_method_not_allowed", wantStatus: http.StatusMethodNotAllowed},
{code: "http_413", status: http.StatusRequestEntityTooLarge, wantCode: "upstream_payload_too_large", wantStatus: http.StatusRequestEntityTooLarge},
{code: "http_422", status: http.StatusUnprocessableEntity, wantCode: "upstream_unprocessable_request", wantStatus: http.StatusUnprocessableEntity},
{code: "auth_failed", status: http.StatusUnauthorized, wantCode: "upstream_auth_failed", wantStatus: http.StatusBadGateway},
{code: "provider_failed", status: http.StatusForbidden, wantCode: "upstream_auth_failed", wantStatus: http.StatusBadGateway},
{code: "provider_failed", status: http.StatusTooManyRequests, wantCode: "upstream_rate_limited", wantStatus: http.StatusTooManyRequests},
{code: "server_error", status: http.StatusBadGateway, wantCode: "upstream_unavailable", wantStatus: http.StatusServiceUnavailable},
{code: "invalid_response", status: http.StatusOK, wantCode: "upstream_invalid_response", wantStatus: http.StatusBadGateway},
} {
got := FromFields(test.code, raw, test.status, true)
if got.Code != test.wantCode || got.HTTPStatus != test.wantStatus {
t.Fatalf("%s: unexpected public error: %+v", test.code, got)
}
if got.Source != "upstream" {
t.Fatalf("%s: source = %q, want upstream", test.code, got.Source)
}
if strings.Contains(got.Message, "private-a") || strings.Contains(got.Message, "secret-project") {
t.Fatalf("%s: provider body leaked: %+v", test.code, got)
}
}
}
func TestUpstreamParameterErrorPreservesSafeOriginalMessage(t *testing.T) {
raw := "Duplicate parameter: 'image'. Use image[]=<value> for multiple values."
for _, test := range []struct {
code string
status int
}{
{code: "http_400", status: http.StatusBadRequest},
{code: "http_422", status: http.StatusUnprocessableEntity},
} {
got := FromFields(test.code, raw, test.status, false)
upstream, _ := got.Details["upstreamError"].(map[string]any)
if got.Message != raw || upstream["message"] != raw || upstream["code"] != test.code || upstream["statusCode"] != test.status {
t.Fatalf("%s: safe upstream message was not forwarded: %+v", test.code, got)
}
}
}
func TestUpstreamParameterErrorRejectsOpaqueOrSensitiveDetails(t *testing.T) {
for _, raw := range []string{
`{"error":{"message":"bucket private-a rejected secret-project"}}`,
"invalid api_key=sk-private-value",
"read tcp 10.42.0.1:1234: connection reset by peer",
} {
got := FromFields("http_400", raw, http.StatusBadRequest, false)
if len(got.Details) != 0 || got.Message == raw {
t.Fatalf("unsafe upstream message was exposed for %q: %+v", raw, got)
}
}
}
func TestGatewayRateLimitIsDistinctFromUpstreamRateLimit(t *testing.T) {
gateway := FromFields("gateway_rate_limited", "concurrency limit is saturated and queueing is disabled", http.StatusTooManyRequests, true)
if gateway.Code != "gateway_rate_limited" || gateway.Source != "gateway" || gateway.HTTPStatus != http.StatusTooManyRequests {
t.Fatalf("unexpected gateway rate limit: %+v", gateway)
}
if strings.Contains(gateway.Message, "concurrency") {
t.Fatalf("gateway rate limit leaked internal details: %+v", gateway)
}
upstream := FromFields("rate_limit", "provider quota exhausted for private account", http.StatusTooManyRequests, true)
if upstream.Code != "upstream_rate_limited" || upstream.Source != "upstream" || upstream.HTTPStatus != http.StatusTooManyRequests {
t.Fatalf("unexpected upstream rate limit: %+v", upstream)
}
if upstream.Message == "provider quota exhausted for private account" {
t.Fatalf("upstream rate limit leaked provider details: %+v", upstream)
}
}
func TestHTTPCodeDerivesOriginalUpstreamStatusWhenStatusIsMissing(t *testing.T) {
got := FromFields("http_404", "404 page not found", 0, false)
if got.Code != "upstream_not_found" || got.Source != "upstream" || got.HTTPStatus != http.StatusNotFound {
t.Fatalf("unexpected derived upstream error: %+v", got)
}
}
func TestValidationGateKeepsStablePublicCode(t *testing.T) {
got := FromFields("validation_in_progress", "new production tasks are paused while validation is running", http.StatusServiceUnavailable, true)
if got.Code != "validation_in_progress" || got.HTTPStatus != http.StatusServiceUnavailable || !got.Retryable {
t.Fatalf("unexpected validation gate error: %+v", got)
}
}