| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501 |
- package middleware
- import (
- "bytes"
- "io"
- "mime/multipart"
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
- "github.com/Wei-Shaw/sub2api/internal/pkg/httputil"
- "github.com/Wei-Shaw/sub2api/internal/service"
- "github.com/gin-gonic/gin"
- )
- func newGroupModelAllowlistTestRouter(apiKey *service.APIKey, pathPrefix string) (*gin.Engine, *[]string) {
- gin.SetMode(gin.TestMode)
- router := gin.New()
- var calls []string
- router.Use(func(c *gin.Context) {
- if apiKey != nil {
- c.Set(string(ContextKeyAPIKey), apiKey)
- }
- c.Next()
- })
- router.Use(GroupModelAllowlist())
- register := func(method, path string) {
- router.Handle(method, path, func(c *gin.Context) {
- calls = append(calls, "handler:"+path)
- c.Status(http.StatusOK)
- })
- }
- register(http.MethodPost, pathPrefix+"/messages")
- register(http.MethodPost, pathPrefix+"/responses")
- register(http.MethodPost, pathPrefix+"/chat/completions")
- register(http.MethodPost, pathPrefix+"/embeddings")
- register(http.MethodGet, pathPrefix+"/models/:model")
- register(http.MethodPost, pathPrefix+"/models/*modelAction")
- register(http.MethodGet, pathPrefix+"/realtime")
- register(http.MethodPost, pathPrefix+"/images/edits")
- register(http.MethodPost, pathPrefix+"/live")
- return router, &calls
- }
- func allowlistAPIKey(enabled bool, models ...string) *service.APIKey {
- return &service.APIKey{
- Group: &service.Group{
- Platform: service.PlatformAnthropic,
- ModelAllowlist: service.GroupModelAllowlist{
- Enabled: enabled,
- Models: models,
- },
- },
- }
- }
- func doJSON(t *testing.T, router *gin.Engine, method, path, body string) *httptest.ResponseRecorder {
- t.Helper()
- req := httptest.NewRequest(method, path, strings.NewReader(body))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
- router.ServeHTTP(w, req)
- return w
- }
- // readTrackingBody 记录请求体是否被读取,用于断言快速路径零读取。
- type readTrackingBody struct {
- io.Reader
- read bool
- }
- func (b *readTrackingBody) Read(p []byte) (int, error) {
- b.read = true
- return b.Reader.Read(p)
- }
- func (b *readTrackingBody) Close() error { return nil }
- func TestGroupModelAllowlistDisabledDoesNotReadBody(t *testing.T) {
- router, calls := newGroupModelAllowlistTestRouter(allowlistAPIKey(false, "claude-sonnet-4.5"), "/v1")
- body := &readTrackingBody{Reader: strings.NewReader(`{"model":"claude-opus-4.6"}`)}
- req := httptest.NewRequest(http.MethodPost, "/v1/messages", body)
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
- router.ServeHTTP(w, req)
- if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
- }
- if len(*calls) != 1 {
- t.Fatalf("expected handler to run once, got %v", *calls)
- }
- if body.read {
- t.Fatal("allowlist disabled: middleware must not read the request body")
- }
- }
- // 白名单开启但请求不携带模型时同样不应读体之外产生副作用:读体是必要开销,
- // 这里验证读取后请求体被完整回填(handler 可零拷贝重读)。
- func TestGroupModelAllowlistEnabledModelFreeRequestRestoresBody(t *testing.T) {
- gin.SetMode(gin.TestMode)
- router := gin.New()
- router.Use(func(c *gin.Context) {
- c.Set(string(ContextKeyAPIKey), allowlistAPIKey(true, "claude-sonnet-4.5"))
- c.Next()
- })
- router.Use(GroupModelAllowlist())
- router.POST("/v1/messages", func(c *gin.Context) {
- body, err := httputil.ReadRequestBodyWithPrealloc(c.Request)
- if err != nil {
- t.Errorf("handler reread body: %v", err)
- c.Status(http.StatusInternalServerError)
- return
- }
- if string(body) != `{"input":"no model field"}` {
- t.Errorf("handler saw wrong body: %s", body)
- }
- c.Status(http.StatusOK)
- })
- w := doJSON(t, router, http.MethodPost, "/v1/messages", `{"input":"no model field"}`)
- if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
- }
- }
- // 413 传递:请求体超过 MaxBytesReader 上限时按现有合成中间件方式返回 413。
- func TestGroupModelAllowlistRequestBodyTooLargePassesThrough413(t *testing.T) {
- router, calls := newGroupModelAllowlistTestRouter(allowlistAPIKey(true, "claude-sonnet-4.5"), "/v1")
- bigBody := `{"model":"claude-opus-4.6","padding":"` + strings.Repeat("x", 512) + `"}`
- req := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(bigBody))
- req.Header.Set("Content-Type", "application/json")
- req.Body = http.MaxBytesReader(httptest.NewRecorder(), req.Body, 64)
- w := httptest.NewRecorder()
- router.ServeHTTP(w, req)
- if w.Code != http.StatusRequestEntityTooLarge {
- t.Fatalf("expected 413, got %d: %s", w.Code, w.Body.String())
- }
- if !strings.Contains(w.Body.String(), "Request body is too large") {
- t.Fatalf("expected too-large message, got %s", w.Body.String())
- }
- if len(*calls) != 0 {
- t.Fatalf("expected handler not to run, got %v", *calls)
- }
- }
- func TestGroupModelAllowlistJSONBodyAllowed(t *testing.T) {
- router, calls := newGroupModelAllowlistTestRouter(allowlistAPIKey(true, "claude-sonnet-4.5"), "/v1")
- w := doJSON(t, router, http.MethodPost, "/v1/messages", `{"model":"claude-sonnet-4.5"}`)
- if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
- }
- if len(*calls) != 1 {
- t.Fatalf("expected handler to run once, got %v", *calls)
- }
- }
- func TestGroupModelAllowlistJSONBodyDeniedOpenAIFormat(t *testing.T) {
- router, calls := newGroupModelAllowlistTestRouter(allowlistAPIKey(true, "claude-sonnet-4.5"), "/v1")
- w := doJSON(t, router, http.MethodPost, "/v1/responses", `{"model":"gpt-5.4"}`)
- if w.Code != http.StatusNotFound {
- t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String())
- }
- if !strings.Contains(w.Body.String(), `"model_not_found"`) {
- t.Fatalf("expected OpenAI error format, got %s", w.Body.String())
- }
- if !strings.Contains(w.Body.String(), `gpt-5.4\" is not available for this group`) {
- t.Fatalf("expected model in message, got %s", w.Body.String())
- }
- if len(*calls) != 0 {
- t.Fatalf("expected handler not to run, got %v", *calls)
- }
- }
- func TestGroupModelAllowlistMessagesDeniedAnthropicFormat(t *testing.T) {
- router, _ := newGroupModelAllowlistTestRouter(allowlistAPIKey(true, "claude-sonnet-4.5"), "/v1")
- w := doJSON(t, router, http.MethodPost, "/v1/messages", `{"model":"claude-opus-4.6"}`)
- if w.Code != http.StatusNotFound {
- t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String())
- }
- if !strings.Contains(w.Body.String(), `"not_found_error"`) {
- t.Fatalf("expected Anthropic not_found_error format, got %s", w.Body.String())
- }
- }
- func TestGroupModelAllowlistGeminiDeniedGoogleFormat(t *testing.T) {
- router, _ := newGroupModelAllowlistTestRouter(allowlistAPIKey(true, "gemini-2.5-pro"), "/v1beta")
- w := doJSON(t, router, http.MethodPost, "/v1beta/models/gemini-2.5-flash:generateContent", `{}`)
- if w.Code != http.StatusNotFound {
- t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String())
- }
- if !strings.Contains(w.Body.String(), `"NOT_FOUND"`) {
- t.Fatalf("expected Google NOT_FOUND format, got %s", w.Body.String())
- }
- }
- func TestGroupModelAllowlistGeminiWildcardAndPrefixForms(t *testing.T) {
- router, _ := newGroupModelAllowlistTestRouter(allowlistAPIKey(true, "gemini-2.5-*"), "/v1beta")
- w := doJSON(t, router, http.MethodPost, "/v1beta/models/models/gemini-2.5-flash:generateContent", `{}`)
- if w.Code != http.StatusOK {
- t.Fatalf("models/ prefixed model should match wildcard entry, got %d: %s", w.Code, w.Body.String())
- }
- }
- func TestGroupModelAllowlistGeminiGetModelRouteParam(t *testing.T) {
- router, _ := newGroupModelAllowlistTestRouter(allowlistAPIKey(true, "gemini-2.5-pro"), "/v1beta")
- req := httptest.NewRequest(http.MethodGet, "/v1beta/models/gemini-2.5-flash", nil)
- w := httptest.NewRecorder()
- router.ServeHTTP(w, req)
- if w.Code != http.StatusNotFound {
- t.Fatalf("expected 404 for unlisted GET model, got %d", w.Code)
- }
- req = httptest.NewRequest(http.MethodGet, "/v1beta/models/gemini-2.5-pro", nil)
- w = httptest.NewRecorder()
- router.ServeHTTP(w, req)
- if w.Code != http.StatusOK {
- t.Fatalf("expected 200 for listed GET model, got %d", w.Code)
- }
- }
- func TestGroupModelAllowlistRealtimeQueryParameter(t *testing.T) {
- router, _ := newGroupModelAllowlistTestRouter(allowlistAPIKey(true, "grok-4.6"), "/v1")
- req := httptest.NewRequest(http.MethodGet, "/v1/realtime?model=grok-4.6", nil)
- w := httptest.NewRecorder()
- router.ServeHTTP(w, req)
- if w.Code != http.StatusOK {
- t.Fatalf("expected 200 for listed realtime model, got %d: %s", w.Code, w.Body.String())
- }
- req = httptest.NewRequest(http.MethodGet, "/v1/realtime?model=grok-4.20", nil)
- w = httptest.NewRecorder()
- router.ServeHTTP(w, req)
- if w.Code != http.StatusNotFound {
- t.Fatalf("expected 404 for unlisted realtime model, got %d", w.Code)
- }
- }
- func TestGroupModelAllowlistMultipartModelField(t *testing.T) {
- router, _ := newGroupModelAllowlistTestRouter(allowlistAPIKey(true, "gpt-image-1"), "/v1")
- var body bytes.Buffer
- writer := multipart.NewWriter(&body)
- _ = writer.WriteField("model", "gpt-image-1")
- _ = writer.WriteField("prompt", "cat")
- _ = writer.Close()
- req := httptest.NewRequest(http.MethodPost, "/v1/images/edits", &body)
- req.Header.Set("Content-Type", writer.FormDataContentType())
- w := httptest.NewRecorder()
- router.ServeHTTP(w, req)
- if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
- }
- }
- func TestGroupModelAllowlistMultipartDenied(t *testing.T) {
- router, _ := newGroupModelAllowlistTestRouter(allowlistAPIKey(true, "gpt-image-1"), "/v1")
- var body bytes.Buffer
- writer := multipart.NewWriter(&body)
- _ = writer.WriteField("model", "dall-e-3")
- _ = writer.WriteField("prompt", "cat")
- _ = writer.Close()
- req := httptest.NewRequest(http.MethodPost, "/v1/images/edits", &body)
- req.Header.Set("Content-Type", writer.FormDataContentType())
- w := httptest.NewRecorder()
- router.ServeHTTP(w, req)
- if w.Code != http.StatusNotFound {
- t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String())
- }
- }
- func TestGroupModelAllowlistNoModelPassesThrough(t *testing.T) {
- router, _ := newGroupModelAllowlistTestRouter(allowlistAPIKey(true, "claude-sonnet-4.5"), "/v1")
- w := doJSON(t, router, http.MethodPost, "/v1/messages", `{"input":"no model field"}`)
- if w.Code != http.StatusOK {
- t.Fatalf("expected 200 when no model extractable, got %d: %s", w.Code, w.Body.String())
- }
- }
- func TestGroupModelAllowlistBodyRestoredForHandler(t *testing.T) {
- gin.SetMode(gin.TestMode)
- router := gin.New()
- router.Use(func(c *gin.Context) {
- c.Set(string(ContextKeyAPIKey), allowlistAPIKey(true, "claude-sonnet-4.5"))
- c.Next()
- })
- router.Use(GroupModelAllowlist())
- router.POST("/v1/messages", func(c *gin.Context) {
- body, err := io.ReadAll(c.Request.Body)
- if err != nil {
- t.Errorf("handler reread body: %v", err)
- c.Status(http.StatusInternalServerError)
- return
- }
- if !strings.Contains(string(body), `"claude-sonnet-4.5"`) {
- t.Errorf("handler saw wrong body: %s", body)
- }
- c.Status(http.StatusOK)
- })
- w := doJSON(t, router, http.MethodPost, "/v1/messages", `{"model":"claude-sonnet-4.5","input":"hi"}`)
- if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
- }
- }
- func TestGroupModelAllowlistWebSocketUpgradeSkipsBodyExtraction(t *testing.T) {
- gin.SetMode(gin.TestMode)
- router := gin.New()
- router.Use(func(c *gin.Context) {
- c.Set(string(ContextKeyAPIKey), allowlistAPIKey(true, "claude-sonnet-4.5"))
- c.Next()
- })
- router.Use(GroupModelAllowlist())
- router.GET("/v1/responses", func(c *gin.Context) {
- c.Status(http.StatusSwitchingProtocols)
- })
- req := httptest.NewRequest(http.MethodGet, "/v1/responses", nil)
- req.Header.Set("Upgrade", "websocket")
- req.Header.Set("Connection", "Upgrade")
- w := httptest.NewRecorder()
- router.ServeHTTP(w, req)
- if w.Code != http.StatusSwitchingProtocols {
- t.Fatalf("expected WS upgrade to reach handler, got %d: %s", w.Code, w.Body.String())
- }
- }
- // 伪造 WebSocket Upgrade 头的普通请求不得绕过白名单校验。
- func TestGroupModelAllowlistForgedUpgradeHeaderDoesNotBypass(t *testing.T) {
- router, calls := newGroupModelAllowlistTestRouter(allowlistAPIKey(true, "claude-sonnet-4.5"), "/v1")
- req := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(`{"model":"claude-opus-4.6"}`))
- req.Header.Set("Content-Type", "application/json")
- req.Header.Set("Upgrade", "websocket")
- req.Header.Set("Connection", "Upgrade")
- w := httptest.NewRecorder()
- router.ServeHTTP(w, req)
- if w.Code != http.StatusNotFound {
- t.Fatalf("expected 404 despite forged upgrade headers, got %d: %s", w.Code, w.Body.String())
- }
- if !strings.Contains(w.Body.String(), "claude-opus-4.6") {
- t.Fatalf("expected blocked model in message, got %s", w.Body.String())
- }
- if len(*calls) != 0 {
- t.Fatalf("expected handler not to run, got %v", *calls)
- }
- }
- // Grok Realtime 升级请求的模型在查询参数里,携带 Upgrade 头也必须校验。
- func TestGroupModelAllowlistRealtimeUpgradeStillChecksQueryModel(t *testing.T) {
- router, _ := newGroupModelAllowlistTestRouter(allowlistAPIKey(true, "grok-4.6"), "/v1")
- req := httptest.NewRequest(http.MethodGet, "/v1/realtime?model=grok-4.20", nil)
- req.Header.Set("Upgrade", "websocket")
- req.Header.Set("Connection", "Upgrade")
- w := httptest.NewRecorder()
- router.ServeHTTP(w, req)
- if w.Code != http.StatusNotFound {
- t.Fatalf("expected 404 for unlisted realtime model with upgrade headers, got %d: %s", w.Code, w.Body.String())
- }
- }
- // Live 入口的 handler 只读 session.model:准入必须以 session.model 为准,
- // 与顶层 model 不一致时按实际执行模型校验。
- func TestGroupModelAllowlistLiveUsesSessionModel(t *testing.T) {
- router, _ := newGroupModelAllowlistTestRouter(allowlistAPIKey(true, "gpt-realtime-model"), "/v1")
- // session.model 不在白名单:即使顶层 model 命中也必须拒绝。
- w := doJSON(t, router, http.MethodPost, "/v1/live", `{"model":"gpt-realtime-model","session":{"model":"blocked-model"},"sdp":"v=0"}`)
- if w.Code != http.StatusNotFound {
- t.Fatalf("expected 404 when session.model is blocked, got %d: %s", w.Code, w.Body.String())
- }
- // session.model 命中:顶层 model 不在白名单也不影响(handler 不读它)。
- w = doJSON(t, router, http.MethodPost, "/v1/live", `{"model":"blocked-model","session":{"model":"gpt-realtime-model"},"sdp":"v=0"}`)
- if w.Code != http.StatusOK {
- t.Fatalf("expected 200 when session.model is allowlisted, got %d: %s", w.Code, w.Body.String())
- }
- }
- func TestGroupModelAllowlistMarksRejectionReasons(t *testing.T) {
- gin.SetMode(gin.TestMode)
- router := gin.New()
- router.Use(func(c *gin.Context) {
- c.Set(string(ContextKeyAPIKey), allowlistAPIKey(true, "claude-sonnet-4.5"))
- c.Next()
- })
- router.Use(GroupModelAllowlist())
- router.POST("/v1/responses", func(c *gin.Context) { c.Status(http.StatusOK) })
- w := doJSON(t, router, http.MethodPost, "/v1/responses", `{"model":"gpt-5.4"}`)
- if w.Code != http.StatusNotFound {
- t.Fatalf("expected 404, got %d", w.Code)
- }
- // IngressRejectModelNotAllowed 与业务限流原因由 ops 中间件在真实链路落日志,
- // 此处至少验证拒绝原因可从 context 读取(MarkIngressRejected 生效)。
- }
- func TestGroupModelAllowlistNilGroupPasses(t *testing.T) {
- router, calls := newGroupModelAllowlistTestRouter(&service.APIKey{}, "/v1")
- w := doJSON(t, router, http.MethodPost, "/v1/messages", `{"model":"whatever"}`)
- if w.Code != http.StatusOK || len(*calls) != 1 {
- t.Fatalf("expected passthrough for key without group, got %d", w.Code)
- }
- }
- // encoding/json 绑定系 handler(如批量生图 ShouldBindJSON)对键名大小写不敏感:
- // {"Model":"blocked"} 会绑定为 blocked,准入必须拒绝。
- func TestGroupModelAllowlistCaseVariantModelKeyRejected(t *testing.T) {
- router, calls := newGroupModelAllowlistTestRouter(allowlistAPIKey(true, "claude-sonnet-4.5"), "/v1")
- w := doJSON(t, router, http.MethodPost, "/v1/responses", `{"Model":"claude-opus-4.6"}`)
- if w.Code != http.StatusNotFound {
- t.Fatalf("case-variant model key must be validated, got %d: %s", w.Code, w.Body.String())
- }
- if !strings.Contains(w.Body.String(), "claude-opus-4.6") {
- t.Fatalf("expected blocked model in message, got %s", w.Body.String())
- }
- if len(*calls) != 0 {
- t.Fatalf("expected handler not to run, got %v", *calls)
- }
- }
- // encoding/json 绑定对重复键取末值、gjson 取首个:两个值都必须通过校验。
- func TestGroupModelAllowlistDuplicateModelKeysRejected(t *testing.T) {
- router, _ := newGroupModelAllowlistTestRouter(allowlistAPIKey(true, "claude-sonnet-4.5"), "/v1")
- w := doJSON(t, router, http.MethodPost, "/v1/responses", `{"model":"claude-sonnet-4.5","model":"claude-opus-4.6"}`)
- if w.Code != http.StatusNotFound {
- t.Fatalf("duplicate model keys must all be validated, got %d: %s", w.Code, w.Body.String())
- }
- if !strings.Contains(w.Body.String(), "claude-opus-4.6") {
- t.Fatalf("expected the disallowed duplicate to be reported, got %s", w.Body.String())
- }
- }
- // 图片编辑的 multipart 解析器对重复 model 字段取末值:全部字段值都必须校验。
- func TestGroupModelAllowlistMultipartDuplicateModelFieldsRejected(t *testing.T) {
- router, _ := newGroupModelAllowlistTestRouter(allowlistAPIKey(true, "gpt-image-1"), "/v1")
- var body bytes.Buffer
- writer := multipart.NewWriter(&body)
- _ = writer.WriteField("model", "gpt-image-1")
- _ = writer.WriteField("model", "gpt-image-1.5")
- _ = writer.WriteField("prompt", "cat")
- _ = writer.Close()
- req := httptest.NewRequest(http.MethodPost, "/v1/images/edits", &body)
- req.Header.Set("Content-Type", writer.FormDataContentType())
- w := httptest.NewRecorder()
- router.ServeHTTP(w, req)
- if w.Code != http.StatusNotFound {
- t.Fatalf("duplicate multipart model fields must all be validated, got %d: %s", w.Code, w.Body.String())
- }
- if !strings.Contains(w.Body.String(), "gpt-image-1.5") {
- t.Fatalf("expected the disallowed field value to be reported, got %s", w.Body.String())
- }
- }
- // 全部候选都在白名单内时放行(重复但同值的字段不误伤)。
- func TestGroupModelAllowlistDuplicateIdenticalModelsAllowed(t *testing.T) {
- router, calls := newGroupModelAllowlistTestRouter(allowlistAPIKey(true, "gpt-image-1"), "/v1")
- var body bytes.Buffer
- writer := multipart.NewWriter(&body)
- _ = writer.WriteField("model", "gpt-image-1")
- _ = writer.WriteField("model", "gpt-image-1")
- _ = writer.Close()
- req := httptest.NewRequest(http.MethodPost, "/v1/images/edits", &body)
- req.Header.Set("Content-Type", writer.FormDataContentType())
- w := httptest.NewRecorder()
- router.ServeHTTP(w, req)
- if w.Code != http.StatusOK {
- t.Fatalf("identical duplicate fields should pass, got %d: %s", w.Code, w.Body.String())
- }
- if len(*calls) != 1 {
- t.Fatalf("expected handler to run once, got %v", *calls)
- }
- }
|