gateway_model_allowlist_test.go 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208
  1. package routes
  2. import (
  3. "net/http"
  4. "net/http/httptest"
  5. "os"
  6. "regexp"
  7. "strings"
  8. "testing"
  9. "github.com/Wei-Shaw/sub2api/internal/config"
  10. "github.com/Wei-Shaw/sub2api/internal/handler"
  11. servermiddleware "github.com/Wei-Shaw/sub2api/internal/server/middleware"
  12. "github.com/Wei-Shaw/sub2api/internal/service"
  13. "github.com/gin-gonic/gin"
  14. "github.com/stretchr/testify/require"
  15. )
  16. func newGatewayRoutesTestRouterWithGroup(group *service.Group) *gin.Engine {
  17. gin.SetMode(gin.TestMode)
  18. router := gin.New()
  19. RegisterGatewayRoutes(
  20. router,
  21. &handler.Handlers{
  22. Gateway: &handler.GatewayHandler{},
  23. OpenAIGateway: &handler.OpenAIGatewayHandler{},
  24. AsyncImage: handler.NewAsyncImageHandler(nil, nil),
  25. },
  26. servermiddleware.APIKeyAuthMiddleware(func(c *gin.Context) {
  27. groupID := int64(1)
  28. c.Set(string(servermiddleware.ContextKeyAPIKey), &service.APIKey{
  29. GroupID: &groupID,
  30. Group: group,
  31. })
  32. c.Next()
  33. }),
  34. nil,
  35. nil,
  36. nil,
  37. nil,
  38. nil,
  39. &config.Config{
  40. Gateway: config.GatewayConfig{
  41. MaxBodySize: 1024 * 1024,
  42. TextMaxBodySize: 1024 * 1024,
  43. },
  44. },
  45. )
  46. return router
  47. }
  48. func allowlistGroup(platform string, enabled bool, models ...string) *service.Group {
  49. return &service.Group{
  50. Platform: platform,
  51. ModelAllowlist: service.GroupModelAllowlist{
  52. Enabled: enabled,
  53. Models: models,
  54. },
  55. }
  56. }
  57. // TestGatewayRoutesGroupModelAllowlistMountedOnEveryGatewayRoute follows the
  58. // source-level route assertion convention of prompt_audit_route_coverage_test.go:
  59. // every gateway chain must mount groupModelAllowlist after api key auth and
  60. // before the composite rewrite (gateway.go + rootRoute helper).
  61. func TestGatewayRoutesGroupModelAllowlistMountedOnEveryGatewayRoute(t *testing.T) {
  62. routeSource, err := os.ReadFile("gateway.go")
  63. require.NoError(t, err)
  64. source := string(routeSource)
  65. // rootRoute helper:apiKeyAuth 之后、compositeTarget 之前。
  66. rootHelper := regexp.MustCompile(regexp.QuoteMeta(`r.Handle(method, path, limit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), groupModelAllowlist, compositeTarget, requireGroupAnthropic, handler)`))
  67. require.Regexp(t, rootHelper, source,
  68. "root alias helper must place the allowlist between apiKeyAuth and compositeTarget")
  69. chains := []struct {
  70. group string
  71. auth string
  72. marker string
  73. composite string
  74. }{
  75. {group: "gateway", auth: "gin.HandlerFunc(apiKeyAuth)", marker: "gateway.Use(groupModelAllowlist)", composite: "gateway.Use(compositeTarget)"},
  76. {group: "gemini", auth: "middleware.APIKeyAuthWithSubscriptionGoogle(apiKeyService, subscriptionService, cfg)", marker: "gemini.Use(groupModelAllowlist)", composite: "gemini.Use(compositeGeminiTarget)"},
  77. {group: "antigravityV1", auth: "gin.HandlerFunc(apiKeyAuth)", marker: "antigravityV1.Use(groupModelAllowlist)", composite: "antigravityV1.Use(requireGroupAnthropic)"},
  78. {group: "antigravityV1Beta", auth: "middleware.APIKeyAuthWithSubscriptionGoogle(apiKeyService, subscriptionService, cfg)", marker: "antigravityV1Beta.Use(groupModelAllowlist)", composite: "antigravityV1Beta.Use(requireGroupGoogle)"},
  79. }
  80. for _, chain := range chains {
  81. re := regexp.MustCompile(
  82. regexp.QuoteMeta(chain.group+".Use("+chain.auth) +
  83. `[\s\S]{0,400}?` + regexp.QuoteMeta(chain.marker) +
  84. `[\s\S]{0,400}?` + regexp.QuoteMeta(chain.composite))
  85. require.Regexp(t, re, source,
  86. "%s chain must mount groupModelAllowlist after auth and before %s", chain.group, chain.composite)
  87. }
  88. // codexDirect 链是一条 Use 调用,直接断言顺序。
  89. codexDirect := regexp.MustCompile(regexp.QuoteMeta(`codexDirect.Use(bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), groupModelAllowlist, compositeTarget, requireGroupAnthropic)`))
  90. require.Regexp(t, codexDirect, source, "codexDirect chain must mount the allowlist after auth and before compositeTarget")
  91. // 所有带 apiKeyAuth 的根路径路由必须收敛到 rootRoute,避免漏挂。
  92. stray := regexp.MustCompile(`\br\.(GET|POST|PUT|PATCH|DELETE)\("[^"]+",[^(]*apiKeyAuth`)
  93. require.NotRegexp(t, stray, source,
  94. "root alias routes must use rootRoute so the allowlist cannot be forgotten")
  95. }
  96. // TestGatewayRoutesGroupModelAllowlistBlocksCompositeModelBeforeRewrite asserts
  97. // admission is decided on the client-written public model before the composite
  98. // route middleware can rewrite it to an upstream model.
  99. func TestGatewayRoutesGroupModelAllowlistBlocksCompositeModelBeforeRewrite(t *testing.T) {
  100. router := newGatewayRoutesTestRouterWithGroup(allowlistGroup(service.PlatformComposite, true, "claude-*"))
  101. req := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(`{"model":"grok-4.3","messages":[{"role":"user","content":"hi"}]}`))
  102. req.Header.Set("Content-Type", "application/json")
  103. w := httptest.NewRecorder()
  104. router.ServeHTTP(w, req)
  105. require.Equal(t, http.StatusNotFound, w.Code)
  106. require.Contains(t, w.Body.String(), "not_found_error")
  107. require.Contains(t, w.Body.String(), "grok-4.3")
  108. }
  109. func TestGatewayRoutesGroupModelAllowlistAllowsListedCompositeModel(t *testing.T) {
  110. router := newGatewayRoutesTestRouterWithGroup(allowlistGroup(service.PlatformComposite, true, "claude-*"))
  111. req := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(`{"model":"claude-sonnet-4.5","messages":[{"role":"user","content":"hi"}]}`))
  112. req.Header.Set("Content-Type", "application/json")
  113. w := httptest.NewRecorder()
  114. router.ServeHTTP(w, req)
  115. require.NotEqual(t, http.StatusNotFound, w.Code, "allowlisted model must pass admission: %s", w.Body.String())
  116. }
  117. func TestGatewayRoutesGroupModelAllowlistCoversRootAliasRoutes(t *testing.T) {
  118. router := newGatewayRoutesTestRouterWithGroup(allowlistGroup(service.PlatformOpenAI, true, "gpt-5.4"))
  119. paths := []struct {
  120. method string
  121. path string
  122. body string
  123. }{
  124. {http.MethodPost, "/responses", `{"model":"gpt-4.1"}`},
  125. {http.MethodPost, "/responses/compact", `{"model":"gpt-4.1"}`},
  126. {http.MethodPost, "/chat/completions", `{"model":"gpt-4.1"}`},
  127. {http.MethodPost, "/embeddings", `{"model":"gpt-4.1","input":"hi"}`},
  128. {http.MethodPost, "/images/generations", `{"model":"gpt-4.1"}`},
  129. {http.MethodPost, "/images/edits", `{"model":"gpt-4.1"}`},
  130. {http.MethodPost, "/videos/generations", `{"model":"gpt-4.1"}`},
  131. {http.MethodPost, "/messages/count_tokens", `{"model":"gpt-4.1","messages":[]}`},
  132. {http.MethodPost, "/tts", `{"model":"gpt-4.1"}`},
  133. {http.MethodPost, "/stt", `{"model":"gpt-4.1"}`},
  134. {http.MethodPost, "/alpha/search", `{"model":"gpt-4.1"}`},
  135. {http.MethodGet, "/realtime?model=gpt-4.1", ""},
  136. {http.MethodPost, "/v1/responses", `{"model":"gpt-4.1"}`},
  137. {http.MethodPost, "/v1/messages", `{"model":"gpt-4.1"}`},
  138. {http.MethodPost, "/v1/messages/count_tokens", `{"model":"gpt-4.1","messages":[]}`},
  139. {http.MethodPost, "/v1/chat/completions", `{"model":"gpt-4.1"}`},
  140. {http.MethodPost, "/v1/embeddings", `{"model":"gpt-4.1","input":"hi"}`},
  141. {http.MethodPost, "/v1/images/generations", `{"model":"gpt-4.1"}`},
  142. {http.MethodPost, "/v1/videos/generations", `{"model":"gpt-4.1"}`},
  143. {http.MethodPost, "/v1/live", `{"session":{"model":"gpt-4.1"},"sdp":"v=0"}`},
  144. {http.MethodPost, "/backend-api/codex/responses", `{"model":"gpt-4.1"}`},
  145. {http.MethodPost, "/backend-api/codex/realtime/calls", `{"session":{"model":"gpt-4.1"},"sdp":"v=0"}`},
  146. {http.MethodPost, "/antigravity/v1/messages", `{"model":"gemini-2.5-pro","messages":[]}`},
  147. }
  148. for _, tc := range paths {
  149. req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body))
  150. if tc.body != "" {
  151. req.Header.Set("Content-Type", "application/json")
  152. }
  153. w := httptest.NewRecorder()
  154. router.ServeHTTP(w, req)
  155. require.Equal(t, http.StatusNotFound, w.Code, "%s %s should be denied by the allowlist, got body: %s", tc.method, tc.path, w.Body.String())
  156. require.Contains(t, w.Body.String(), "not available for this group", "%s %s", tc.method, tc.path)
  157. }
  158. }
  159. func TestGatewayRoutesGroupModelAllowlistSkipsWebSocketUpgrade(t *testing.T) {
  160. router := newGatewayRoutesTestRouterWithGroup(allowlistGroup(service.PlatformOpenAI, true, "gpt-5.4"))
  161. req := httptest.NewRequest(http.MethodGet, "/v1/responses", nil)
  162. req.Header.Set("Upgrade", "websocket")
  163. req.Header.Set("Connection", "Upgrade")
  164. w := httptest.NewRecorder()
  165. router.ServeHTTP(w, req)
  166. require.NotEqual(t, http.StatusNotFound, w.Code,
  167. "WS upgrade requests must be admitted and per-frame checked in the handler, got: %s", w.Body.String())
  168. }
  169. func TestGatewayRoutesGroupModelAllowlistModelFreeRoutesUnaffected(t *testing.T) {
  170. router := newGatewayRoutesTestRouterWithGroup(allowlistGroup(service.PlatformGrok, true, "grok-4.6"))
  171. // 不携带模型的入口天然不受白名单影响(状态查询/内容下载/模型列表)。
  172. for _, path := range []string{
  173. "/v1/videos/generations/req-1",
  174. "/v1/videos/req-1/content",
  175. "/v1/images/tasks/task-1",
  176. "/v1/custom-voices",
  177. } {
  178. req := httptest.NewRequest(http.MethodGet, path, nil)
  179. w := httptest.NewRecorder()
  180. router.ServeHTTP(w, req)
  181. require.NotContains(t, w.Body.String(), "not available for this group",
  182. "%s should not be blocked by the allowlist middleware, got: %s", path, w.Body.String())
  183. }
  184. }