Files
easyai-ai-gateway/apps/api/internal/httpapi/public_errors_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

150 lines
5.9 KiB
Go

package httpapi
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/clients"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/publicerror"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
func TestPublicHTTPErrorReportsSourceAndPreservesSafeUpstreamStatus(t *testing.T) {
upstream := httptest.NewRecorder()
writeProtocolError(upstream, clients.ProtocolOpenAIImages, http.StatusNotFound, "404 page not found at private upstream route", nil, "http_404")
if upstream.Code != http.StatusNotFound {
t.Fatalf("upstream status = %d, want 404; body=%s", upstream.Code, upstream.Body.String())
}
var upstreamBody map[string]any
if err := json.Unmarshal(upstream.Body.Bytes(), &upstreamBody); err != nil {
t.Fatal(err)
}
upstreamError := requireObject(t, upstreamBody["error"])
upstreamDetails := requireObject(t, upstreamError["details"])
upstreamPublic := requireObject(t, upstreamDetails["publicError"])
if upstreamError["code"] != "upstream_not_found" || upstreamPublic["source"] != "upstream" {
t.Fatalf("unexpected upstream response: %+v", upstreamBody)
}
if strings.Contains(upstream.Body.String(), "private upstream route") {
t.Fatalf("upstream response leaked raw message: %s", upstream.Body.String())
}
gateway := httptest.NewRecorder()
writeError(gateway, http.StatusTooManyRequests, "concurrency limit is saturated and queueing is disabled", "gateway_rate_limited")
if gateway.Code != http.StatusTooManyRequests {
t.Fatalf("gateway status = %d, want 429; body=%s", gateway.Code, gateway.Body.String())
}
var gatewayBody map[string]any
if err := json.Unmarshal(gateway.Body.Bytes(), &gatewayBody); err != nil {
t.Fatal(err)
}
gatewayError := requireObject(t, gatewayBody["error"])
if gatewayError["code"] != "gateway_rate_limited" || gatewayError["source"] != "gateway" {
t.Fatalf("unexpected gateway response: %+v", gatewayBody)
}
}
func TestPublicHTTPErrorIncludesSafeUpstreamParameterMessage(t *testing.T) {
raw := "Duplicate parameter: 'image'. Use image[]=<value> for multiple values."
recorder := httptest.NewRecorder()
writeProtocolError(recorder, clients.ProtocolOpenAIImages, http.StatusBadRequest, raw, nil, "http_400")
if recorder.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body=%s", recorder.Code, recorder.Body.String())
}
var body map[string]any
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
errorPayload := requireObject(t, body["error"])
details := requireObject(t, errorPayload["details"])
upstream := requireObject(t, details["upstreamError"])
if errorPayload["message"] != raw || upstream["message"] != raw || upstream["code"] != "http_400" || upstream["statusCode"] != float64(http.StatusBadRequest) {
t.Fatalf("unexpected upstream error details: %+v", body)
}
}
func TestPublicGatewayTaskSanitizesCopyAndKeepsRawAuditFields(t *testing.T) {
rawMessage := `404 page not found: {"privateProject":"secret"}`
task := store.GatewayTask{
ID: "task-1",
Status: "failed",
ErrorCode: "http_404",
ErrorMessage: rawMessage,
Attempts: []store.TaskAttempt{{
AttemptNo: 1,
Status: "failed",
StatusCode: http.StatusNotFound,
ErrorCode: "http_404",
ErrorMessage: rawMessage,
}},
}
public := publicGatewayTask(task)
if public.ErrorCode != "upstream_not_found" || public.ErrorMessage == rawMessage || public.PublicError == nil || public.PublicError.Source != "upstream" {
t.Fatalf("unexpected public task error: %+v", public)
}
if strings.Contains(public.ErrorMessage, "secret") || public.Attempts[0].ErrorMessage == rawMessage {
t.Fatalf("public task leaked upstream details: %+v", public)
}
if task.ErrorCode != "http_404" || task.ErrorMessage != rawMessage || task.Attempts[0].ErrorCode != "http_404" || task.Attempts[0].ErrorMessage != rawMessage {
t.Fatalf("public conversion mutated raw audit fields: %+v", task)
}
}
func TestPublicGatewayTaskForwardsSafeUpstreamParameterMessage(t *testing.T) {
raw := "Duplicate parameter: 'image'. Use image[]=<value> for multiple values."
legacy := publicerror.Error{
Code: "upstream_invalid_request",
Message: "The upstream service rejected the request parameters.",
Category: "upstream",
Source: "upstream",
HTTPStatus: http.StatusBadRequest,
Version: "v1",
}
task := store.GatewayTask{
ID: "task-parameter-error",
Status: "failed",
ErrorCode: "http_400",
ErrorMessage: raw,
PublicError: &legacy,
Attempts: []store.TaskAttempt{{
AttemptNo: 1,
Status: "failed",
StatusCode: http.StatusBadRequest,
ErrorCode: "http_400",
ErrorMessage: raw,
PublicError: &legacy,
}},
}
public := publicGatewayTask(task)
upstream := requireObject(t, public.PublicError.Details["upstreamError"])
attemptUpstream := requireObject(t, public.Attempts[0].PublicError.Details["upstreamError"])
if public.ErrorMessage != raw || public.Attempts[0].ErrorMessage != raw || upstream["message"] != raw || attemptUpstream["message"] != raw {
t.Fatalf("task response did not forward safe upstream diagnostics: %+v", public)
}
}
func TestTaskErrorHTTPStatusRebuildsLegacySnapshotFromAttempt(t *testing.T) {
legacy := publicerror.Error{
Code: "upstream_request_rejected",
HTTPStatus: http.StatusBadRequest,
Version: "v1",
}
task := store.GatewayTask{
ErrorCode: "http_422",
PublicError: &legacy,
Attempts: []store.TaskAttempt{{StatusCode: http.StatusUnprocessableEntity}},
}
if got := taskErrorHTTPStatus(task); got != http.StatusUnprocessableEntity {
t.Fatalf("taskErrorHTTPStatus = %d, want %d", got, http.StatusUnprocessableEntity)
}
public := publicTaskError(task)
if public.Code != "upstream_unprocessable_request" || public.HTTPStatus != http.StatusUnprocessableEntity || public.Source != "upstream" {
t.Fatalf("legacy public snapshot was not rebuilt: %+v", public)
}
}