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[]= 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 TestPublicGatewayTaskSanitizesCopyAndKeepsRawAuditFieldsInternal(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, RequestSnapshot: map[string]any{"image_base64": "private-input"}, ResponseSnapshot: map[string]any{"provider_payload": "private-output"}, }}, } 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 public.Attempts[0].RequestSnapshot != nil || public.Attempts[0].ResponseSnapshot != nil { t.Fatalf("public task leaked attempt snapshots: %+v", public.Attempts[0]) } 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) } if task.Attempts[0].RequestSnapshot == nil || task.Attempts[0].ResponseSnapshot == nil { t.Fatalf("public conversion mutated internal snapshots: %+v", task.Attempts[0]) } } func TestPublicGatewayTaskForwardsSafeUpstreamParameterMessage(t *testing.T) { raw := "Duplicate parameter: 'image'. Use image[]= 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) } }