Files
CLIProxyAPI/internal/api/home_models_empty_test.go
hkfires 4d34502f07 fix(api): reject home models error envelopes
Reject any home models payload containing an error key before
parsing provider sections. Array-shaped error values were
previously treated as model sections, masking upstream errors
as valid or empty catalogs instead of returning an error.
2026-10-19 12:11:57 +08:00

131 lines
5.7 KiB
Go

package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v8/internal/home"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
)
func TestDecodeHomeModelsEmptyCatalog(t *testing.T) {
for _, raw := range []string{"{}", " { } ", `{"claude":[],"codex-pro":[]}`} {
entries, errDecode := decodeHomeModels([]byte(raw))
if errDecode != nil || entries == nil || len(entries) != 0 {
t.Errorf("decodeHomeModels(%q) = %v, %v, want empty list", raw, entries, errDecode)
}
}
for _, raw := range []string{
"", "null", "[]", "{bad}", `{"claude":[{}]}`, `{"claude":null}`, `{"error":null}`,
`{"error":{"type":"internal_error","message":"unavailable"}}`,
`{"error":[]}`, `{"error":[],"claude":[]}`,
`{"error":[{"id":"not-a-model"}]}`, `{"error":[],"claude":[{"id":"model-a"}]}`,
} {
if _, errDecode := decodeHomeModels([]byte(raw)); errDecode == nil {
t.Errorf("decodeHomeModels(%q) accepted malformed or error payload", raw)
}
}
}
func TestHomeEmptyModelCatalogResponses(t *testing.T) {
server := newTestServer(t)
server.cfg.Home.Enabled = true
engine := gin.New()
engine.GET("/v1/models", server.unifiedModelsHandler(nil, nil))
engine.GET("/v1beta/models", server.geminiModelsHandler(nil))
engine.GET("/v1beta/models/*action", server.geminiGetHandler(nil))
client := newHomeCatalogClient(t, "{}")
previousHome := home.Current()
home.SetCurrent(client)
t.Cleanup(func() { home.SetCurrent(previousHome) })
registry.GetGlobalRegistry().RegisterClient("empty-catalog-local", "codex", []*registry.ModelInfo{{ID: "local-only-model"}})
t.Cleanup(func() { registry.GetGlobalRegistry().UnregisterClient("empty-catalog-local") })
tests := []struct {
name string
path string
headers map[string]string
field string
}{
{name: "openai", path: "/v1/models", field: "data"},
{name: "claude", path: "/v1/models", headers: map[string]string{"Anthropic-Version": "2023-06-01"}, field: "data"},
{name: "codex", path: "/v1/models?client_version=0.153.4", field: "models"},
{name: "cpa", path: "/v1/models?client_version=cpa", field: "models"},
{name: "gemini", path: "/v1beta/models", field: "models"},
{name: "grok", path: "/v1/models", headers: map[string]string{"User-Agent": "grok-shell"}, field: "data"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
request := httptest.NewRequest(http.MethodGet, test.path, nil)
request.Header.Set("Authorization", "Bearer scoped-key")
for key, value := range test.headers {
request.Header.Set(key, value)
}
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d; body=%s", recorder.Code, recorder.Body.String())
}
var payload map[string]json.RawMessage
if errUnmarshal := json.Unmarshal(recorder.Body.Bytes(), &payload); errUnmarshal != nil {
t.Fatal(errUnmarshal)
}
if string(payload[test.field]) != "[]" {
t.Fatalf("%s = %s, want []; body=%s", test.field, payload[test.field], recorder.Body.String())
}
})
}
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/v1beta/models/inaccessible-model", nil))
if recorder.Code != http.StatusNotFound {
t.Fatalf("inaccessible model status = %d, want 404; body=%s", recorder.Code, recorder.Body.String())
}
}
func TestHomeModelCatalogErrorsRemainErrors(t *testing.T) {
server := newTestServer(t)
server.cfg.Home.Enabled = true
engine := gin.New()
engine.GET("/v1/models", server.unifiedModelsHandler(nil, nil))
for _, test := range []struct {
name string
payload string
status int
}{
{name: "missing credentials", payload: `{"error":{"type":"no_credentials","message":"Missing API key"}}`, status: http.StatusUnauthorized},
{name: "invalid credentials", payload: `{"error":{"type":"invalid_credential","message":"Invalid API key"}}`, status: http.StatusUnauthorized},
{name: "policy failure", payload: `{"error":{"type":"internal_error","message":"Model access service error"}}`, status: http.StatusBadGateway},
{name: "empty array error", payload: `{"error":[]}`, status: http.StatusBadGateway},
{name: "error alongside empty section", payload: `{"error":[],"claude":[]}`, status: http.StatusBadGateway},
{name: "error containing model-shaped records", payload: `{"error":[{"id":"not-a-model"}]}`, status: http.StatusBadGateway},
{name: "error alongside models", payload: `{"error":[],"claude":[{"id":"model-a"}]}`, status: http.StatusBadGateway},
{name: "empty response", payload: "", status: http.StatusBadGateway},
{name: "malformed JSON", payload: "{bad}", status: http.StatusBadGateway},
{name: "null catalog", payload: "null", status: http.StatusBadGateway},
{name: "null section", payload: `{"claude":null}`, status: http.StatusBadGateway},
{name: "missing model IDs", payload: `{"claude":[{}]}`, status: http.StatusBadGateway},
} {
t.Run(test.name, func(t *testing.T) {
client := newHomeCatalogClient(t, test.payload)
previousHome := home.Current()
home.SetCurrent(client)
t.Cleanup(func() { home.SetCurrent(previousHome) })
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/v1/models", nil))
if recorder.Code != test.status {
t.Fatalf("status = %d, want %d; body=%s", recorder.Code, test.status, recorder.Body.String())
}
var payload map[string]json.RawMessage
if errUnmarshal := json.Unmarshal(recorder.Body.Bytes(), &payload); errUnmarshal != nil {
t.Fatal(errUnmarshal)
}
if _, exists := payload["error"]; !exists {
t.Fatalf("expected error response; body=%s", recorder.Body.String())
}
})
}
}