group_handler_simple_mode_test.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308
  1. package admin
  2. import (
  3. "bytes"
  4. "encoding/json"
  5. "net/http"
  6. "net/http/httptest"
  7. "testing"
  8. "github.com/Wei-Shaw/sub2api/internal/config"
  9. "github.com/Wei-Shaw/sub2api/internal/service"
  10. "github.com/gin-gonic/gin"
  11. "github.com/stretchr/testify/require"
  12. )
  13. func newSimpleModeGroupRouter(svc *stubAdminService) *gin.Engine {
  14. h := NewGroupHandlerWithConfig(svc, nil, nil, &config.Config{RunMode: config.RunModeSimple})
  15. r := gin.New()
  16. r.GET("/groups", h.List)
  17. r.GET("/groups/all", h.GetAll)
  18. r.GET("/groups/live-capability", h.GetLiveCapability)
  19. r.GET("/groups/usage-summary", h.GetUsageSummary)
  20. r.GET("/groups/capacity-summary", h.GetCapacitySummary)
  21. r.PUT("/groups/sort-order", h.UpdateSortOrder)
  22. r.GET("/groups/:id", h.GetByID)
  23. r.GET("/groups/:id/stats", h.GetStats)
  24. r.GET("/groups/:id/api-keys", h.GetGroupAPIKeys)
  25. r.GET("/groups/:id/model-allowlist-candidates", h.GetGroupModelAllowlistCandidates)
  26. r.POST("/groups", h.Create)
  27. r.PUT("/groups/:id", h.Update)
  28. r.POST("/groups/:id/duplicate", h.Duplicate)
  29. r.GET("/groups/:id/composite-routes", h.ListCompositeRoutes)
  30. r.POST("/groups/:id/composite-routes", h.CreateCompositeRoute)
  31. r.POST("/groups/:id/composite-routes/preview", h.PreviewCompositeRoute)
  32. r.PUT("/groups/:id/composite-routes/:route_id", h.UpdateCompositeRoute)
  33. r.DELETE("/groups/:id/composite-routes/:route_id", h.DeleteCompositeRoute)
  34. r.GET("/groups/:id/rate-multipliers", h.GetGroupRateMultipliers)
  35. r.PUT("/groups/:id/rate-multipliers", h.BatchSetGroupRateMultipliers)
  36. r.DELETE("/groups/:id/rate-multipliers", h.ClearGroupRateMultipliers)
  37. r.PUT("/groups/:id/rpm-overrides", h.BatchSetGroupRPMOverrides)
  38. r.DELETE("/groups/:id/rpm-overrides", h.ClearGroupRPMOverrides)
  39. r.DELETE("/groups/:id", h.Delete)
  40. return r
  41. }
  42. func TestGroupHandlerSimpleModeSanitizesCommercialFields(t *testing.T) {
  43. gin.SetMode(gin.TestMode)
  44. svc := newStubAdminService()
  45. r := newSimpleModeGroupRouter(svc)
  46. create := `{"name":"simple","description":"basic grouping","platform":"anthropic","rate_multiplier":7,"is_exclusive":true,"subscription_type":"subscription","daily_limit_usd":10,"long_context_pricing_enabled":true,"model_pricing":[{"model":"claude","input_price":1}],"allow_image_generation":true,"allow_batch_image_generation":true,"video_price_720p":2,"web_search_price_per_call":3,"audio_realtime_price_per_min":4,"rpm_limit":99}`
  47. req := httptest.NewRequest(http.MethodPost, "/groups", bytes.NewBufferString(create))
  48. req.Header.Set("Content-Type", "application/json")
  49. res := httptest.NewRecorder()
  50. r.ServeHTTP(res, req)
  51. require.Equal(t, http.StatusOK, res.Code)
  52. require.Len(t, svc.createdGroups, 1)
  53. created := svc.createdGroups[0]
  54. require.Equal(t, "basic grouping", created.Description)
  55. require.Equal(t, 1.0, created.RateMultiplier)
  56. require.False(t, created.IsExclusive)
  57. require.Equal(t, service.SubscriptionTypeStandard, created.SubscriptionType)
  58. require.Nil(t, created.DailyLimitUSD)
  59. require.False(t, created.AllowImageGeneration)
  60. require.False(t, created.LongContextPricingEnabled)
  61. require.Empty(t, created.ModelPricing)
  62. require.False(t, created.AllowBatchImageGeneration)
  63. require.Nil(t, created.VideoPrice720P)
  64. require.Nil(t, created.WebSearchPricePerCall)
  65. require.Nil(t, created.AudioRealtimePricePerMin)
  66. require.Zero(t, created.RPMLimit)
  67. update := `{"name":"renamed","description":"still basic","rate_multiplier":9,"is_exclusive":true,"subscription_type":"subscription","daily_limit_usd":12,"long_context_pricing_enabled":true,"model_pricing":[{"model":"claude","input_price":1}],"allow_image_generation":true,"allow_batch_image_generation":true,"video_price_720p":2,"web_search_price_per_call":3,"audio_realtime_price_per_min":4,"status":"inactive","rpm_limit":123}`
  68. req = httptest.NewRequest(http.MethodPut, "/groups/2", bytes.NewBufferString(update))
  69. req.Header.Set("Content-Type", "application/json")
  70. res = httptest.NewRecorder()
  71. r.ServeHTTP(res, req)
  72. require.Equal(t, http.StatusOK, res.Code)
  73. require.Len(t, svc.updatedGroups, 1)
  74. updated := svc.updatedGroups[0]
  75. require.NotNil(t, updated.Description)
  76. require.Equal(t, "still basic", *updated.Description)
  77. require.Nil(t, updated.RateMultiplier)
  78. require.Nil(t, updated.IsExclusive)
  79. require.Empty(t, updated.SubscriptionType)
  80. require.Nil(t, updated.DailyLimitUSD)
  81. require.Nil(t, updated.AllowImageGeneration)
  82. require.Nil(t, updated.LongContextPricingEnabled)
  83. require.Nil(t, updated.ModelPricing)
  84. require.Nil(t, updated.AllowBatchImageGeneration)
  85. require.Nil(t, updated.VideoPrice720P)
  86. require.Nil(t, updated.WebSearchPricePerCall)
  87. require.Nil(t, updated.AudioRealtimePricePerMin)
  88. require.Empty(t, updated.Status)
  89. require.Nil(t, updated.RPMLimit)
  90. }
  91. func TestGroupHandlerSimpleModeRejectsCompositeBeforeService(t *testing.T) {
  92. svc := newStubAdminService()
  93. r := newSimpleModeGroupRouter(svc)
  94. req := httptest.NewRequest(http.MethodPost, "/groups", bytes.NewBufferString(`{"name":"composite","platform":"composite"}`))
  95. req.Header.Set("Content-Type", "application/json")
  96. res := httptest.NewRecorder()
  97. r.ServeHTTP(res, req)
  98. require.Equal(t, http.StatusBadRequest, res.Code)
  99. require.Empty(t, svc.createdGroups)
  100. }
  101. func TestGroupHandlerSimpleModeDeleteRejectsNonEmptyGroupBeforeCascade(t *testing.T) {
  102. svc := newStubAdminService()
  103. svc.deleteGroupIfEmptyErr = service.ErrGroupNotEmpty
  104. r := newSimpleModeGroupRouter(svc)
  105. res := httptest.NewRecorder()
  106. r.ServeHTTP(res, httptest.NewRequest(http.MethodDelete, "/groups/7", nil))
  107. require.Equal(t, http.StatusConflict, res.Code)
  108. require.Equal(t, []int64{7}, svc.guardedDeletedGroupIDs)
  109. require.Empty(t, svc.deletedGroupIDs)
  110. }
  111. func TestGroupHandlerSimpleModeDeletePermitsEmptyGroup(t *testing.T) {
  112. svc := newStubAdminService()
  113. r := newSimpleModeGroupRouter(svc)
  114. res := httptest.NewRecorder()
  115. r.ServeHTTP(res, httptest.NewRequest(http.MethodDelete, "/groups/8", nil))
  116. require.Equal(t, http.StatusOK, res.Code)
  117. require.Equal(t, []int64{8}, svc.guardedDeletedGroupIDs)
  118. require.Empty(t, svc.deletedGroupIDs)
  119. }
  120. func TestGroupHandlerSimpleModeIgnoresExclusiveFilter(t *testing.T) {
  121. svc := newStubAdminService()
  122. r := newSimpleModeGroupRouter(svc)
  123. res := httptest.NewRecorder()
  124. r.ServeHTTP(res, httptest.NewRequest(http.MethodGet, "/groups?is_exclusive=true", nil))
  125. require.Equal(t, http.StatusOK, res.Code)
  126. require.Nil(t, svc.lastListGroupsIsExclusive)
  127. }
  128. func float64PtrForSimpleModeTest(value float64) *float64 { return &value }
  129. func TestGroupHandlerSimpleModeResponseUsesFieldAllowlist(t *testing.T) {
  130. gin.SetMode(gin.TestMode)
  131. svc := newStubAdminService()
  132. svc.groups = []service.Group{{
  133. ID: 1, Name: "basic", Description: "allowed", Platform: service.PlatformAnthropic,
  134. AccountCount: 3, ActiveAccountCount: 2, RateLimitedAccountCount: 1,
  135. Status: service.StatusActive, RateMultiplier: 9, RPMLimit: 42,
  136. LongContextPricingEnabled: true,
  137. ModelPricing: []service.ChannelModelPricing{{Models: []string{"claude"}}},
  138. AllowBatchImageGeneration: true, VideoPrice720P: float64PtrForSimpleModeTest(2),
  139. WebSearchPricePerCall: float64PtrForSimpleModeTest(3), AudioRealtimePricePerMin: float64PtrForSimpleModeTest(4),
  140. ModelRouting: map[string][]int64{"claude": {2}},
  141. }}
  142. r := newSimpleModeGroupRouter(svc)
  143. res := httptest.NewRecorder()
  144. r.ServeHTTP(res, httptest.NewRequest(http.MethodGet, "/groups", nil))
  145. require.Equal(t, http.StatusOK, res.Code)
  146. var payload struct {
  147. Data struct {
  148. Items []map[string]any `json:"items"`
  149. } `json:"data"`
  150. }
  151. require.NoError(t, json.Unmarshal(res.Body.Bytes(), &payload))
  152. require.Len(t, payload.Data.Items, 1)
  153. item := payload.Data.Items[0]
  154. require.Equal(t, "basic", item["name"])
  155. require.Equal(t, "allowed", item["description"])
  156. require.Equal(t, "anthropic", item["platform"])
  157. require.Equal(t, float64(3), item["account_count"])
  158. require.ElementsMatch(t, []string{
  159. "id", "name", "description", "platform", "status", "account_count",
  160. "active_account_count", "rate_limited_account_count", "sort_order", "created_at", "updated_at",
  161. }, mapKeys(item))
  162. for _, forbidden := range []string{
  163. "rate_multiplier", "rpm_limit", "long_context_pricing_enabled", "model_pricing",
  164. "allow_batch_image_generation", "video_price_720p", "web_search_price_per_call",
  165. "audio_realtime_price_per_min", "model_routing",
  166. } {
  167. _, exposed := item[forbidden]
  168. require.Falsef(t, exposed, "simple mode exposed %s", forbidden)
  169. }
  170. }
  171. func TestGroupHandlerSimpleModeListsOnlyBindableGroups(t *testing.T) {
  172. svc := newStubAdminService()
  173. svc.groups = []service.Group{{ID: 1, Name: "basic", Platform: service.PlatformAnthropic}, {ID: 2, Name: "composite", Platform: service.PlatformComposite}}
  174. r := newSimpleModeGroupRouter(svc)
  175. for _, path := range []string{"/groups", "/groups/all"} {
  176. res := httptest.NewRecorder()
  177. r.ServeHTTP(res, httptest.NewRequest(http.MethodGet, path, nil))
  178. require.Equal(t, http.StatusOK, res.Code)
  179. require.Contains(t, res.Body.String(), `"name":"basic"`)
  180. require.NotContains(t, res.Body.String(), `"name":"composite"`)
  181. }
  182. }
  183. func mapKeys(values map[string]any) []string {
  184. keys := make([]string, 0, len(values))
  185. for key := range values {
  186. keys = append(keys, key)
  187. }
  188. return keys
  189. }
  190. func TestGroupHandlerSimpleModeAllReadAndWriteResponsesUseFieldAllowlist(t *testing.T) {
  191. gin.SetMode(gin.TestMode)
  192. svc := newStubAdminService()
  193. svc.groups = []service.Group{{
  194. ID: 1, Name: "basic", Description: "allowed", Platform: service.PlatformAnthropic, AccountCount: 3,
  195. Status: service.StatusActive, RateMultiplier: 9, RPMLimit: 42,
  196. LongContextPricingEnabled: true,
  197. ModelPricing: []service.ChannelModelPricing{{Models: []string{"claude"}}},
  198. }}
  199. r := newSimpleModeGroupRouter(svc)
  200. assertNoAdvancedFields := func(t *testing.T, item map[string]any) {
  201. t.Helper()
  202. for _, forbidden := range []string{
  203. "rate_multiplier", "rpm_limit", "long_context_pricing_enabled", "model_pricing",
  204. "allow_batch_image_generation", "video_price_720p", "web_search_price_per_call",
  205. "audio_realtime_price_per_min", "model_routing",
  206. } {
  207. _, exposed := item[forbidden]
  208. require.Falsef(t, exposed, "simple mode exposed %s", forbidden)
  209. }
  210. }
  211. for _, tt := range []struct {
  212. name string
  213. method string
  214. path string
  215. body string
  216. list bool
  217. }{
  218. {name: "get all", method: http.MethodGet, path: "/groups/all", list: true},
  219. {name: "get by id", method: http.MethodGet, path: "/groups/1"},
  220. {name: "create", method: http.MethodPost, path: "/groups", body: `{"name":"created","platform":"anthropic","model_pricing":[{"models":["claude"]}]}`},
  221. {name: "update", method: http.MethodPut, path: "/groups/1", body: `{"name":"updated","model_pricing":[{"models":["claude"]}]}`},
  222. } {
  223. t.Run(tt.name, func(t *testing.T) {
  224. res := httptest.NewRecorder()
  225. req := httptest.NewRequest(tt.method, tt.path, bytes.NewBufferString(tt.body))
  226. req.Header.Set("Content-Type", "application/json")
  227. r.ServeHTTP(res, req)
  228. require.Equal(t, http.StatusOK, res.Code)
  229. var payload struct {
  230. Data json.RawMessage `json:"data"`
  231. }
  232. require.NoError(t, json.Unmarshal(res.Body.Bytes(), &payload))
  233. if tt.list {
  234. var items []map[string]any
  235. require.NoError(t, json.Unmarshal(payload.Data, &items))
  236. require.Len(t, items, 1)
  237. assertNoAdvancedFields(t, items[0])
  238. return
  239. }
  240. var item map[string]any
  241. require.NoError(t, json.Unmarshal(payload.Data, &item))
  242. assertNoAdvancedFields(t, item)
  243. })
  244. }
  245. }
  246. func TestGroupHandlerSimpleModeBlocksAdvancedOperations(t *testing.T) {
  247. gin.SetMode(gin.TestMode)
  248. tests := []struct {
  249. method string
  250. path string
  251. body string
  252. }{
  253. {http.MethodPost, "/groups/1/duplicate", ""},
  254. {http.MethodGet, "/groups/1/model-allowlist-candidates", ""},
  255. {http.MethodGet, "/groups/1/composite-routes", ""},
  256. {http.MethodPost, "/groups/1/composite-routes", `{"public_model":"x","target_platform":"openai"}`},
  257. {http.MethodPost, "/groups/1/composite-routes/preview", `{"model":"x"}`},
  258. {http.MethodPut, "/groups/1/composite-routes/2", `{"public_model":"x","target_platform":"openai"}`},
  259. {http.MethodDelete, "/groups/1/composite-routes/2", ""},
  260. {http.MethodGet, "/groups/1/rate-multipliers", ""},
  261. {http.MethodPut, "/groups/1/rate-multipliers", `{"entries":[]}`},
  262. {http.MethodDelete, "/groups/1/rate-multipliers", ""},
  263. {http.MethodPut, "/groups/1/rpm-overrides", `{"entries":[]}`},
  264. {http.MethodDelete, "/groups/1/rpm-overrides", ""},
  265. {http.MethodGet, "/groups/1/api-keys", ""},
  266. {http.MethodGet, "/groups/live-capability", ""},
  267. {http.MethodGet, "/groups/usage-summary", ""},
  268. {http.MethodGet, "/groups/capacity-summary", ""},
  269. {http.MethodPut, "/groups/sort-order", `{"updates":[{"id":1,"sort_order":1}]}`},
  270. }
  271. for _, tt := range tests {
  272. t.Run(tt.method+" "+tt.path, func(t *testing.T) {
  273. svc := newStubAdminService()
  274. r := newSimpleModeGroupRouter(svc)
  275. res := httptest.NewRecorder()
  276. req := httptest.NewRequest(tt.method, tt.path, bytes.NewBufferString(tt.body))
  277. req.Header.Set("Content-Type", "application/json")
  278. r.ServeHTTP(res, req)
  279. require.Equal(t, http.StatusForbidden, res.Code)
  280. require.Zero(t, svc.advancedGroupOperationCalls)
  281. })
  282. }
  283. }