group_model_allowlist_test.go 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501
  1. package middleware
  2. import (
  3. "bytes"
  4. "io"
  5. "mime/multipart"
  6. "net/http"
  7. "net/http/httptest"
  8. "strings"
  9. "testing"
  10. "github.com/Wei-Shaw/sub2api/internal/pkg/httputil"
  11. "github.com/Wei-Shaw/sub2api/internal/service"
  12. "github.com/gin-gonic/gin"
  13. )
  14. func newGroupModelAllowlistTestRouter(apiKey *service.APIKey, pathPrefix string) (*gin.Engine, *[]string) {
  15. gin.SetMode(gin.TestMode)
  16. router := gin.New()
  17. var calls []string
  18. router.Use(func(c *gin.Context) {
  19. if apiKey != nil {
  20. c.Set(string(ContextKeyAPIKey), apiKey)
  21. }
  22. c.Next()
  23. })
  24. router.Use(GroupModelAllowlist())
  25. register := func(method, path string) {
  26. router.Handle(method, path, func(c *gin.Context) {
  27. calls = append(calls, "handler:"+path)
  28. c.Status(http.StatusOK)
  29. })
  30. }
  31. register(http.MethodPost, pathPrefix+"/messages")
  32. register(http.MethodPost, pathPrefix+"/responses")
  33. register(http.MethodPost, pathPrefix+"/chat/completions")
  34. register(http.MethodPost, pathPrefix+"/embeddings")
  35. register(http.MethodGet, pathPrefix+"/models/:model")
  36. register(http.MethodPost, pathPrefix+"/models/*modelAction")
  37. register(http.MethodGet, pathPrefix+"/realtime")
  38. register(http.MethodPost, pathPrefix+"/images/edits")
  39. register(http.MethodPost, pathPrefix+"/live")
  40. return router, &calls
  41. }
  42. func allowlistAPIKey(enabled bool, models ...string) *service.APIKey {
  43. return &service.APIKey{
  44. Group: &service.Group{
  45. Platform: service.PlatformAnthropic,
  46. ModelAllowlist: service.GroupModelAllowlist{
  47. Enabled: enabled,
  48. Models: models,
  49. },
  50. },
  51. }
  52. }
  53. func doJSON(t *testing.T, router *gin.Engine, method, path, body string) *httptest.ResponseRecorder {
  54. t.Helper()
  55. req := httptest.NewRequest(method, path, strings.NewReader(body))
  56. req.Header.Set("Content-Type", "application/json")
  57. w := httptest.NewRecorder()
  58. router.ServeHTTP(w, req)
  59. return w
  60. }
  61. // readTrackingBody 记录请求体是否被读取,用于断言快速路径零读取。
  62. type readTrackingBody struct {
  63. io.Reader
  64. read bool
  65. }
  66. func (b *readTrackingBody) Read(p []byte) (int, error) {
  67. b.read = true
  68. return b.Reader.Read(p)
  69. }
  70. func (b *readTrackingBody) Close() error { return nil }
  71. func TestGroupModelAllowlistDisabledDoesNotReadBody(t *testing.T) {
  72. router, calls := newGroupModelAllowlistTestRouter(allowlistAPIKey(false, "claude-sonnet-4.5"), "/v1")
  73. body := &readTrackingBody{Reader: strings.NewReader(`{"model":"claude-opus-4.6"}`)}
  74. req := httptest.NewRequest(http.MethodPost, "/v1/messages", body)
  75. req.Header.Set("Content-Type", "application/json")
  76. w := httptest.NewRecorder()
  77. router.ServeHTTP(w, req)
  78. if w.Code != http.StatusOK {
  79. t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
  80. }
  81. if len(*calls) != 1 {
  82. t.Fatalf("expected handler to run once, got %v", *calls)
  83. }
  84. if body.read {
  85. t.Fatal("allowlist disabled: middleware must not read the request body")
  86. }
  87. }
  88. // 白名单开启但请求不携带模型时同样不应读体之外产生副作用:读体是必要开销,
  89. // 这里验证读取后请求体被完整回填(handler 可零拷贝重读)。
  90. func TestGroupModelAllowlistEnabledModelFreeRequestRestoresBody(t *testing.T) {
  91. gin.SetMode(gin.TestMode)
  92. router := gin.New()
  93. router.Use(func(c *gin.Context) {
  94. c.Set(string(ContextKeyAPIKey), allowlistAPIKey(true, "claude-sonnet-4.5"))
  95. c.Next()
  96. })
  97. router.Use(GroupModelAllowlist())
  98. router.POST("/v1/messages", func(c *gin.Context) {
  99. body, err := httputil.ReadRequestBodyWithPrealloc(c.Request)
  100. if err != nil {
  101. t.Errorf("handler reread body: %v", err)
  102. c.Status(http.StatusInternalServerError)
  103. return
  104. }
  105. if string(body) != `{"input":"no model field"}` {
  106. t.Errorf("handler saw wrong body: %s", body)
  107. }
  108. c.Status(http.StatusOK)
  109. })
  110. w := doJSON(t, router, http.MethodPost, "/v1/messages", `{"input":"no model field"}`)
  111. if w.Code != http.StatusOK {
  112. t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
  113. }
  114. }
  115. // 413 传递:请求体超过 MaxBytesReader 上限时按现有合成中间件方式返回 413。
  116. func TestGroupModelAllowlistRequestBodyTooLargePassesThrough413(t *testing.T) {
  117. router, calls := newGroupModelAllowlistTestRouter(allowlistAPIKey(true, "claude-sonnet-4.5"), "/v1")
  118. bigBody := `{"model":"claude-opus-4.6","padding":"` + strings.Repeat("x", 512) + `"}`
  119. req := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(bigBody))
  120. req.Header.Set("Content-Type", "application/json")
  121. req.Body = http.MaxBytesReader(httptest.NewRecorder(), req.Body, 64)
  122. w := httptest.NewRecorder()
  123. router.ServeHTTP(w, req)
  124. if w.Code != http.StatusRequestEntityTooLarge {
  125. t.Fatalf("expected 413, got %d: %s", w.Code, w.Body.String())
  126. }
  127. if !strings.Contains(w.Body.String(), "Request body is too large") {
  128. t.Fatalf("expected too-large message, got %s", w.Body.String())
  129. }
  130. if len(*calls) != 0 {
  131. t.Fatalf("expected handler not to run, got %v", *calls)
  132. }
  133. }
  134. func TestGroupModelAllowlistJSONBodyAllowed(t *testing.T) {
  135. router, calls := newGroupModelAllowlistTestRouter(allowlistAPIKey(true, "claude-sonnet-4.5"), "/v1")
  136. w := doJSON(t, router, http.MethodPost, "/v1/messages", `{"model":"claude-sonnet-4.5"}`)
  137. if w.Code != http.StatusOK {
  138. t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
  139. }
  140. if len(*calls) != 1 {
  141. t.Fatalf("expected handler to run once, got %v", *calls)
  142. }
  143. }
  144. func TestGroupModelAllowlistJSONBodyDeniedOpenAIFormat(t *testing.T) {
  145. router, calls := newGroupModelAllowlistTestRouter(allowlistAPIKey(true, "claude-sonnet-4.5"), "/v1")
  146. w := doJSON(t, router, http.MethodPost, "/v1/responses", `{"model":"gpt-5.4"}`)
  147. if w.Code != http.StatusNotFound {
  148. t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String())
  149. }
  150. if !strings.Contains(w.Body.String(), `"model_not_found"`) {
  151. t.Fatalf("expected OpenAI error format, got %s", w.Body.String())
  152. }
  153. if !strings.Contains(w.Body.String(), `gpt-5.4\" is not available for this group`) {
  154. t.Fatalf("expected model in message, got %s", w.Body.String())
  155. }
  156. if len(*calls) != 0 {
  157. t.Fatalf("expected handler not to run, got %v", *calls)
  158. }
  159. }
  160. func TestGroupModelAllowlistMessagesDeniedAnthropicFormat(t *testing.T) {
  161. router, _ := newGroupModelAllowlistTestRouter(allowlistAPIKey(true, "claude-sonnet-4.5"), "/v1")
  162. w := doJSON(t, router, http.MethodPost, "/v1/messages", `{"model":"claude-opus-4.6"}`)
  163. if w.Code != http.StatusNotFound {
  164. t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String())
  165. }
  166. if !strings.Contains(w.Body.String(), `"not_found_error"`) {
  167. t.Fatalf("expected Anthropic not_found_error format, got %s", w.Body.String())
  168. }
  169. }
  170. func TestGroupModelAllowlistGeminiDeniedGoogleFormat(t *testing.T) {
  171. router, _ := newGroupModelAllowlistTestRouter(allowlistAPIKey(true, "gemini-2.5-pro"), "/v1beta")
  172. w := doJSON(t, router, http.MethodPost, "/v1beta/models/gemini-2.5-flash:generateContent", `{}`)
  173. if w.Code != http.StatusNotFound {
  174. t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String())
  175. }
  176. if !strings.Contains(w.Body.String(), `"NOT_FOUND"`) {
  177. t.Fatalf("expected Google NOT_FOUND format, got %s", w.Body.String())
  178. }
  179. }
  180. func TestGroupModelAllowlistGeminiWildcardAndPrefixForms(t *testing.T) {
  181. router, _ := newGroupModelAllowlistTestRouter(allowlistAPIKey(true, "gemini-2.5-*"), "/v1beta")
  182. w := doJSON(t, router, http.MethodPost, "/v1beta/models/models/gemini-2.5-flash:generateContent", `{}`)
  183. if w.Code != http.StatusOK {
  184. t.Fatalf("models/ prefixed model should match wildcard entry, got %d: %s", w.Code, w.Body.String())
  185. }
  186. }
  187. func TestGroupModelAllowlistGeminiGetModelRouteParam(t *testing.T) {
  188. router, _ := newGroupModelAllowlistTestRouter(allowlistAPIKey(true, "gemini-2.5-pro"), "/v1beta")
  189. req := httptest.NewRequest(http.MethodGet, "/v1beta/models/gemini-2.5-flash", nil)
  190. w := httptest.NewRecorder()
  191. router.ServeHTTP(w, req)
  192. if w.Code != http.StatusNotFound {
  193. t.Fatalf("expected 404 for unlisted GET model, got %d", w.Code)
  194. }
  195. req = httptest.NewRequest(http.MethodGet, "/v1beta/models/gemini-2.5-pro", nil)
  196. w = httptest.NewRecorder()
  197. router.ServeHTTP(w, req)
  198. if w.Code != http.StatusOK {
  199. t.Fatalf("expected 200 for listed GET model, got %d", w.Code)
  200. }
  201. }
  202. func TestGroupModelAllowlistRealtimeQueryParameter(t *testing.T) {
  203. router, _ := newGroupModelAllowlistTestRouter(allowlistAPIKey(true, "grok-4.6"), "/v1")
  204. req := httptest.NewRequest(http.MethodGet, "/v1/realtime?model=grok-4.6", nil)
  205. w := httptest.NewRecorder()
  206. router.ServeHTTP(w, req)
  207. if w.Code != http.StatusOK {
  208. t.Fatalf("expected 200 for listed realtime model, got %d: %s", w.Code, w.Body.String())
  209. }
  210. req = httptest.NewRequest(http.MethodGet, "/v1/realtime?model=grok-4.20", nil)
  211. w = httptest.NewRecorder()
  212. router.ServeHTTP(w, req)
  213. if w.Code != http.StatusNotFound {
  214. t.Fatalf("expected 404 for unlisted realtime model, got %d", w.Code)
  215. }
  216. }
  217. func TestGroupModelAllowlistMultipartModelField(t *testing.T) {
  218. router, _ := newGroupModelAllowlistTestRouter(allowlistAPIKey(true, "gpt-image-1"), "/v1")
  219. var body bytes.Buffer
  220. writer := multipart.NewWriter(&body)
  221. _ = writer.WriteField("model", "gpt-image-1")
  222. _ = writer.WriteField("prompt", "cat")
  223. _ = writer.Close()
  224. req := httptest.NewRequest(http.MethodPost, "/v1/images/edits", &body)
  225. req.Header.Set("Content-Type", writer.FormDataContentType())
  226. w := httptest.NewRecorder()
  227. router.ServeHTTP(w, req)
  228. if w.Code != http.StatusOK {
  229. t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
  230. }
  231. }
  232. func TestGroupModelAllowlistMultipartDenied(t *testing.T) {
  233. router, _ := newGroupModelAllowlistTestRouter(allowlistAPIKey(true, "gpt-image-1"), "/v1")
  234. var body bytes.Buffer
  235. writer := multipart.NewWriter(&body)
  236. _ = writer.WriteField("model", "dall-e-3")
  237. _ = writer.WriteField("prompt", "cat")
  238. _ = writer.Close()
  239. req := httptest.NewRequest(http.MethodPost, "/v1/images/edits", &body)
  240. req.Header.Set("Content-Type", writer.FormDataContentType())
  241. w := httptest.NewRecorder()
  242. router.ServeHTTP(w, req)
  243. if w.Code != http.StatusNotFound {
  244. t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String())
  245. }
  246. }
  247. func TestGroupModelAllowlistNoModelPassesThrough(t *testing.T) {
  248. router, _ := newGroupModelAllowlistTestRouter(allowlistAPIKey(true, "claude-sonnet-4.5"), "/v1")
  249. w := doJSON(t, router, http.MethodPost, "/v1/messages", `{"input":"no model field"}`)
  250. if w.Code != http.StatusOK {
  251. t.Fatalf("expected 200 when no model extractable, got %d: %s", w.Code, w.Body.String())
  252. }
  253. }
  254. func TestGroupModelAllowlistBodyRestoredForHandler(t *testing.T) {
  255. gin.SetMode(gin.TestMode)
  256. router := gin.New()
  257. router.Use(func(c *gin.Context) {
  258. c.Set(string(ContextKeyAPIKey), allowlistAPIKey(true, "claude-sonnet-4.5"))
  259. c.Next()
  260. })
  261. router.Use(GroupModelAllowlist())
  262. router.POST("/v1/messages", func(c *gin.Context) {
  263. body, err := io.ReadAll(c.Request.Body)
  264. if err != nil {
  265. t.Errorf("handler reread body: %v", err)
  266. c.Status(http.StatusInternalServerError)
  267. return
  268. }
  269. if !strings.Contains(string(body), `"claude-sonnet-4.5"`) {
  270. t.Errorf("handler saw wrong body: %s", body)
  271. }
  272. c.Status(http.StatusOK)
  273. })
  274. w := doJSON(t, router, http.MethodPost, "/v1/messages", `{"model":"claude-sonnet-4.5","input":"hi"}`)
  275. if w.Code != http.StatusOK {
  276. t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
  277. }
  278. }
  279. func TestGroupModelAllowlistWebSocketUpgradeSkipsBodyExtraction(t *testing.T) {
  280. gin.SetMode(gin.TestMode)
  281. router := gin.New()
  282. router.Use(func(c *gin.Context) {
  283. c.Set(string(ContextKeyAPIKey), allowlistAPIKey(true, "claude-sonnet-4.5"))
  284. c.Next()
  285. })
  286. router.Use(GroupModelAllowlist())
  287. router.GET("/v1/responses", func(c *gin.Context) {
  288. c.Status(http.StatusSwitchingProtocols)
  289. })
  290. req := httptest.NewRequest(http.MethodGet, "/v1/responses", nil)
  291. req.Header.Set("Upgrade", "websocket")
  292. req.Header.Set("Connection", "Upgrade")
  293. w := httptest.NewRecorder()
  294. router.ServeHTTP(w, req)
  295. if w.Code != http.StatusSwitchingProtocols {
  296. t.Fatalf("expected WS upgrade to reach handler, got %d: %s", w.Code, w.Body.String())
  297. }
  298. }
  299. // 伪造 WebSocket Upgrade 头的普通请求不得绕过白名单校验。
  300. func TestGroupModelAllowlistForgedUpgradeHeaderDoesNotBypass(t *testing.T) {
  301. router, calls := newGroupModelAllowlistTestRouter(allowlistAPIKey(true, "claude-sonnet-4.5"), "/v1")
  302. req := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(`{"model":"claude-opus-4.6"}`))
  303. req.Header.Set("Content-Type", "application/json")
  304. req.Header.Set("Upgrade", "websocket")
  305. req.Header.Set("Connection", "Upgrade")
  306. w := httptest.NewRecorder()
  307. router.ServeHTTP(w, req)
  308. if w.Code != http.StatusNotFound {
  309. t.Fatalf("expected 404 despite forged upgrade headers, got %d: %s", w.Code, w.Body.String())
  310. }
  311. if !strings.Contains(w.Body.String(), "claude-opus-4.6") {
  312. t.Fatalf("expected blocked model in message, got %s", w.Body.String())
  313. }
  314. if len(*calls) != 0 {
  315. t.Fatalf("expected handler not to run, got %v", *calls)
  316. }
  317. }
  318. // Grok Realtime 升级请求的模型在查询参数里,携带 Upgrade 头也必须校验。
  319. func TestGroupModelAllowlistRealtimeUpgradeStillChecksQueryModel(t *testing.T) {
  320. router, _ := newGroupModelAllowlistTestRouter(allowlistAPIKey(true, "grok-4.6"), "/v1")
  321. req := httptest.NewRequest(http.MethodGet, "/v1/realtime?model=grok-4.20", nil)
  322. req.Header.Set("Upgrade", "websocket")
  323. req.Header.Set("Connection", "Upgrade")
  324. w := httptest.NewRecorder()
  325. router.ServeHTTP(w, req)
  326. if w.Code != http.StatusNotFound {
  327. t.Fatalf("expected 404 for unlisted realtime model with upgrade headers, got %d: %s", w.Code, w.Body.String())
  328. }
  329. }
  330. // Live 入口的 handler 只读 session.model:准入必须以 session.model 为准,
  331. // 与顶层 model 不一致时按实际执行模型校验。
  332. func TestGroupModelAllowlistLiveUsesSessionModel(t *testing.T) {
  333. router, _ := newGroupModelAllowlistTestRouter(allowlistAPIKey(true, "gpt-realtime-model"), "/v1")
  334. // session.model 不在白名单:即使顶层 model 命中也必须拒绝。
  335. w := doJSON(t, router, http.MethodPost, "/v1/live", `{"model":"gpt-realtime-model","session":{"model":"blocked-model"},"sdp":"v=0"}`)
  336. if w.Code != http.StatusNotFound {
  337. t.Fatalf("expected 404 when session.model is blocked, got %d: %s", w.Code, w.Body.String())
  338. }
  339. // session.model 命中:顶层 model 不在白名单也不影响(handler 不读它)。
  340. w = doJSON(t, router, http.MethodPost, "/v1/live", `{"model":"blocked-model","session":{"model":"gpt-realtime-model"},"sdp":"v=0"}`)
  341. if w.Code != http.StatusOK {
  342. t.Fatalf("expected 200 when session.model is allowlisted, got %d: %s", w.Code, w.Body.String())
  343. }
  344. }
  345. func TestGroupModelAllowlistMarksRejectionReasons(t *testing.T) {
  346. gin.SetMode(gin.TestMode)
  347. router := gin.New()
  348. router.Use(func(c *gin.Context) {
  349. c.Set(string(ContextKeyAPIKey), allowlistAPIKey(true, "claude-sonnet-4.5"))
  350. c.Next()
  351. })
  352. router.Use(GroupModelAllowlist())
  353. router.POST("/v1/responses", func(c *gin.Context) { c.Status(http.StatusOK) })
  354. w := doJSON(t, router, http.MethodPost, "/v1/responses", `{"model":"gpt-5.4"}`)
  355. if w.Code != http.StatusNotFound {
  356. t.Fatalf("expected 404, got %d", w.Code)
  357. }
  358. // IngressRejectModelNotAllowed 与业务限流原因由 ops 中间件在真实链路落日志,
  359. // 此处至少验证拒绝原因可从 context 读取(MarkIngressRejected 生效)。
  360. }
  361. func TestGroupModelAllowlistNilGroupPasses(t *testing.T) {
  362. router, calls := newGroupModelAllowlistTestRouter(&service.APIKey{}, "/v1")
  363. w := doJSON(t, router, http.MethodPost, "/v1/messages", `{"model":"whatever"}`)
  364. if w.Code != http.StatusOK || len(*calls) != 1 {
  365. t.Fatalf("expected passthrough for key without group, got %d", w.Code)
  366. }
  367. }
  368. // encoding/json 绑定系 handler(如批量生图 ShouldBindJSON)对键名大小写不敏感:
  369. // {"Model":"blocked"} 会绑定为 blocked,准入必须拒绝。
  370. func TestGroupModelAllowlistCaseVariantModelKeyRejected(t *testing.T) {
  371. router, calls := newGroupModelAllowlistTestRouter(allowlistAPIKey(true, "claude-sonnet-4.5"), "/v1")
  372. w := doJSON(t, router, http.MethodPost, "/v1/responses", `{"Model":"claude-opus-4.6"}`)
  373. if w.Code != http.StatusNotFound {
  374. t.Fatalf("case-variant model key must be validated, got %d: %s", w.Code, w.Body.String())
  375. }
  376. if !strings.Contains(w.Body.String(), "claude-opus-4.6") {
  377. t.Fatalf("expected blocked model in message, got %s", w.Body.String())
  378. }
  379. if len(*calls) != 0 {
  380. t.Fatalf("expected handler not to run, got %v", *calls)
  381. }
  382. }
  383. // encoding/json 绑定对重复键取末值、gjson 取首个:两个值都必须通过校验。
  384. func TestGroupModelAllowlistDuplicateModelKeysRejected(t *testing.T) {
  385. router, _ := newGroupModelAllowlistTestRouter(allowlistAPIKey(true, "claude-sonnet-4.5"), "/v1")
  386. w := doJSON(t, router, http.MethodPost, "/v1/responses", `{"model":"claude-sonnet-4.5","model":"claude-opus-4.6"}`)
  387. if w.Code != http.StatusNotFound {
  388. t.Fatalf("duplicate model keys must all be validated, got %d: %s", w.Code, w.Body.String())
  389. }
  390. if !strings.Contains(w.Body.String(), "claude-opus-4.6") {
  391. t.Fatalf("expected the disallowed duplicate to be reported, got %s", w.Body.String())
  392. }
  393. }
  394. // 图片编辑的 multipart 解析器对重复 model 字段取末值:全部字段值都必须校验。
  395. func TestGroupModelAllowlistMultipartDuplicateModelFieldsRejected(t *testing.T) {
  396. router, _ := newGroupModelAllowlistTestRouter(allowlistAPIKey(true, "gpt-image-1"), "/v1")
  397. var body bytes.Buffer
  398. writer := multipart.NewWriter(&body)
  399. _ = writer.WriteField("model", "gpt-image-1")
  400. _ = writer.WriteField("model", "gpt-image-1.5")
  401. _ = writer.WriteField("prompt", "cat")
  402. _ = writer.Close()
  403. req := httptest.NewRequest(http.MethodPost, "/v1/images/edits", &body)
  404. req.Header.Set("Content-Type", writer.FormDataContentType())
  405. w := httptest.NewRecorder()
  406. router.ServeHTTP(w, req)
  407. if w.Code != http.StatusNotFound {
  408. t.Fatalf("duplicate multipart model fields must all be validated, got %d: %s", w.Code, w.Body.String())
  409. }
  410. if !strings.Contains(w.Body.String(), "gpt-image-1.5") {
  411. t.Fatalf("expected the disallowed field value to be reported, got %s", w.Body.String())
  412. }
  413. }
  414. // 全部候选都在白名单内时放行(重复但同值的字段不误伤)。
  415. func TestGroupModelAllowlistDuplicateIdenticalModelsAllowed(t *testing.T) {
  416. router, calls := newGroupModelAllowlistTestRouter(allowlistAPIKey(true, "gpt-image-1"), "/v1")
  417. var body bytes.Buffer
  418. writer := multipart.NewWriter(&body)
  419. _ = writer.WriteField("model", "gpt-image-1")
  420. _ = writer.WriteField("model", "gpt-image-1")
  421. _ = writer.Close()
  422. req := httptest.NewRequest(http.MethodPost, "/v1/images/edits", &body)
  423. req.Header.Set("Content-Type", writer.FormDataContentType())
  424. w := httptest.NewRecorder()
  425. router.ServeHTTP(w, req)
  426. if w.Code != http.StatusOK {
  427. t.Fatalf("identical duplicate fields should pass, got %d: %s", w.Code, w.Body.String())
  428. }
  429. if len(*calls) != 1 {
  430. t.Fatalf("expected handler to run once, got %v", *calls)
  431. }
  432. }