Feat/auto group (#6590)
* feat(token): support custom auto group order * feat(keys): enhance auto group presentation * fix(keys): rework Auto flow border and compact inherited order The Auto group highlight previously tinted the whole control surface with a gradient and animated only a 1px top sweep, which read as a background color rather than a flowing border. Replace it with a border-only effect: an aria-hidden, pointer-events-none overlay whose conic gradient is masked down to a thin ring hugging the rounded perimeter, so the highlight travels around all four edges and corners every 3.2s. The interior stays neutral with a restrained static primary border and glow; prefers-reduced-motion hides the moving layer while keeping the static emphasis. The inherited global Auto order also rendered as spacious two-line rows with circular sequence markers, wasting drawer space. Render it as a compact wrapping strip of one-line chips (index, name, ratio badge) with descriptions kept accessible via title and sr-only text, scrolling only past a much smaller max height. Custom add/remove/reorder editing, empty-array inheritance semantics, and the submit payload are unchanged. * fix(keys): preserve Auto inheritance and unify effects * refactor(keys): temporarily disable AutoGroupBadge in api-key-group-cell
This commit is contained in:
+24
-21
@@ -20,6 +20,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
"github.com/QuantumNous/new-api/setting/ratio_setting"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
@@ -190,7 +191,7 @@ func getModelListGroups(c *gin.Context) (modelListGroups, error) {
|
||||
return modelListGroups{
|
||||
userGroup: userGroup,
|
||||
tokenGroup: tokenGroup,
|
||||
ownerGroups: service.GetUserAutoGroup(userGroup),
|
||||
ownerGroups: service.GetRequestAutoGroups(c, userGroup),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -228,32 +229,28 @@ func ListModels(c *gin.Context, modelType int) {
|
||||
}
|
||||
ownerGroups := groups.ownerGroups
|
||||
modelLimitEnable := common.GetContextKeyBool(c, constant.ContextKeyTokenModelLimitEnabled)
|
||||
var tokenModelLimit map[string]bool
|
||||
if modelLimitEnable {
|
||||
s, ok := common.GetContextKey(c, constant.ContextKeyTokenModelLimit)
|
||||
var tokenModelLimit map[string]bool
|
||||
if ok {
|
||||
tokenModelLimit = s.(map[string]bool)
|
||||
} else {
|
||||
tokenModelLimit, _ = s.(map[string]bool)
|
||||
}
|
||||
if tokenModelLimit == nil {
|
||||
tokenModelLimit = map[string]bool{}
|
||||
}
|
||||
for allowModel, _ := range tokenModelLimit {
|
||||
if !acceptUnsetRatioModel {
|
||||
if !helper.HasModelBillingConfig(allowModel) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
models := service.GetGroupsEnabledModels(ownerGroups)
|
||||
for _, modelName := range models {
|
||||
if modelLimitEnable {
|
||||
matchingName := ratio_setting.FormatMatchingModelName(modelName)
|
||||
if !tokenModelLimit[modelName] && !tokenModelLimit[matchingName] {
|
||||
continue
|
||||
}
|
||||
userModelNames = append(userModelNames, allowModel)
|
||||
}
|
||||
} else {
|
||||
models := service.GetGroupsEnabledModels(ownerGroups)
|
||||
for _, modelName := range models {
|
||||
if !acceptUnsetRatioModel {
|
||||
if !helper.HasModelBillingConfig(modelName) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
userModelNames = append(userModelNames, modelName)
|
||||
if !acceptUnsetRatioModel && !helper.HasModelBillingConfig(modelName) {
|
||||
continue
|
||||
}
|
||||
userModelNames = append(userModelNames, modelName)
|
||||
}
|
||||
|
||||
ownerByModel := map[string]string{}
|
||||
@@ -276,11 +273,17 @@ func ListModels(c *gin.Context, modelType int) {
|
||||
Type: "model",
|
||||
}
|
||||
}
|
||||
firstID := ""
|
||||
lastID := ""
|
||||
if len(useranthropicModels) > 0 {
|
||||
firstID = useranthropicModels[0].ID
|
||||
lastID = useranthropicModels[len(useranthropicModels)-1].ID
|
||||
}
|
||||
c.JSON(200, gin.H{
|
||||
"data": useranthropicModels,
|
||||
"first_id": useranthropicModels[0].ID,
|
||||
"first_id": firstID,
|
||||
"has_more": false,
|
||||
"last_id": useranthropicModels[len(useranthropicModels)-1].ID,
|
||||
"last_id": lastID,
|
||||
})
|
||||
case constant.ChannelTypeGemini:
|
||||
userGeminiModels := make([]dto.GeminiModel, len(userOpenAiModels))
|
||||
|
||||
@@ -402,7 +402,13 @@ func TestListModelsTokenLimitIncludesTieredBillingModel(t *testing.T) {
|
||||
"zz-token-tiered-visible-model": `tier("base", p * 1 + c * 2)`,
|
||||
"zz-token-tiered-empty-expr-model": "",
|
||||
})
|
||||
setupModelListControllerTestDB(t)
|
||||
db := setupModelListControllerTestDB(t)
|
||||
require.NoError(t, db.Create(&[]model.Ability{
|
||||
{Group: "default", Model: "zz-token-tiered-visible-model", ChannelId: 1, Enabled: true},
|
||||
{Group: "default", Model: "zz-token-tiered-empty-expr-model", ChannelId: 1, Enabled: true},
|
||||
{Group: "default", Model: "zz-token-tiered-missing-expr-model", ChannelId: 1, Enabled: true},
|
||||
{Group: "default", Model: "zz-token-unpriced-model", ChannelId: 1, Enabled: true},
|
||||
}).Error)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
@@ -425,6 +431,68 @@ func TestListModelsTokenLimitIncludesTieredBillingModel(t *testing.T) {
|
||||
require.NotContains(t, ids, "zz-token-unpriced-model")
|
||||
}
|
||||
|
||||
func TestListModelsTokenLimitUsesResolvedCustomAutoGroups(t *testing.T) {
|
||||
withSelfUseModeEnabled(t)
|
||||
originalMax := setting.GetMaxTokenAutoGroups()
|
||||
originalUsableGroups := setting.UserUsableGroups2JSONString()
|
||||
originalRatios := ratio_setting.GroupRatio2JSONString()
|
||||
require.NoError(t, setting.UpdateMaxTokenAutoGroups("5"))
|
||||
require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(`{"default":"Default","vip":"VIP"}`))
|
||||
require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(`{"default":1,"vip":1}`))
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, setting.UpdateMaxTokenAutoGroups(fmt.Sprintf("%d", originalMax)))
|
||||
require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(originalUsableGroups))
|
||||
require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(originalRatios))
|
||||
})
|
||||
|
||||
db := setupModelListControllerTestDB(t)
|
||||
require.NoError(t, db.Create(&[]model.Ability{
|
||||
{Group: "vip", Model: "zz-vip-allowed", ChannelId: 1, Enabled: true},
|
||||
{Group: "vip", Model: "zz-vip-denied", ChannelId: 1, Enabled: true},
|
||||
{Group: "default", Model: "zz-default-outside-snapshot", ChannelId: 1, Enabled: true},
|
||||
}).Error)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
ctx.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
|
||||
common.SetContextKey(ctx, constant.ContextKeyUserGroup, "default")
|
||||
common.SetContextKey(ctx, constant.ContextKeyTokenGroup, "auto")
|
||||
common.SetContextKey(ctx, constant.ContextKeyTokenAutoGroups, []string{"vip"})
|
||||
common.SetContextKey(ctx, constant.ContextKeyTokenModelLimitEnabled, true)
|
||||
common.SetContextKey(ctx, constant.ContextKeyTokenModelLimit, map[string]bool{
|
||||
"zz-vip-allowed": true,
|
||||
"zz-default-outside-snapshot": true,
|
||||
"zz-not-enabled": true,
|
||||
})
|
||||
|
||||
ListModels(ctx, constant.ChannelTypeOpenAI)
|
||||
ids := decodeListModelsResponse(t, recorder)
|
||||
require.Equal(t, map[string]struct{}{"zz-vip-allowed": {}}, ids)
|
||||
|
||||
require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(`{"default":"Default"}`))
|
||||
emptyRecorder := httptest.NewRecorder()
|
||||
emptyCtx, _ := gin.CreateTestContext(emptyRecorder)
|
||||
emptyCtx.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
|
||||
common.SetContextKey(emptyCtx, constant.ContextKeyUserGroup, "default")
|
||||
common.SetContextKey(emptyCtx, constant.ContextKeyTokenGroup, "auto")
|
||||
common.SetContextKey(emptyCtx, constant.ContextKeyTokenAutoGroups, []string{"vip"})
|
||||
common.SetContextKey(emptyCtx, constant.ContextKeyTokenModelLimitEnabled, true)
|
||||
common.SetContextKey(emptyCtx, constant.ContextKeyTokenModelLimit, map[string]bool{"zz-vip-allowed": true})
|
||||
|
||||
require.NotPanics(t, func() {
|
||||
ListModels(emptyCtx, constant.ChannelTypeAnthropic)
|
||||
})
|
||||
var anthropicResponse struct {
|
||||
Data []dto.AnthropicModel `json:"data"`
|
||||
FirstID string `json:"first_id"`
|
||||
LastID string `json:"last_id"`
|
||||
}
|
||||
require.NoError(t, common.Unmarshal(emptyRecorder.Body.Bytes(), &anthropicResponse))
|
||||
require.Empty(t, anthropicResponse.Data)
|
||||
require.Empty(t, anthropicResponse.FirstID)
|
||||
require.Empty(t, anthropicResponse.LastID)
|
||||
}
|
||||
|
||||
func TestCheckUpdatePasswordRequiresCurrentPassword(t *testing.T) {
|
||||
db := setupModelListControllerTestDB(t)
|
||||
hashedPassword, err := common.Password2Hash("CurrentPassword123")
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/setting"
|
||||
"github.com/QuantumNous/new-api/setting/ratio_setting"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -83,3 +86,33 @@ func TestGetModelListGroupsUsesExplicitTokenGroup(t *testing.T) {
|
||||
require.Equal(t, "vip", groups.tokenGroup)
|
||||
require.Equal(t, []string{"vip"}, groups.ownerGroups)
|
||||
}
|
||||
|
||||
func TestGetModelListGroupsUsesFilteredTokenAutoGroupsSnapshot(t *testing.T) {
|
||||
originalMax := setting.GetMaxTokenAutoGroups()
|
||||
originalUsableGroups := setting.UserUsableGroups2JSONString()
|
||||
originalRatios := ratio_setting.GroupRatio2JSONString()
|
||||
require.NoError(t, setting.UpdateMaxTokenAutoGroups("1"))
|
||||
require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(`{"default":"Default","vip":"VIP"}`))
|
||||
require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(`{"default":1,"vip":1}`))
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, setting.UpdateMaxTokenAutoGroups(fmt.Sprintf("%d", originalMax)))
|
||||
require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(originalUsableGroups))
|
||||
require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(originalRatios))
|
||||
})
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
common.SetContextKey(ctx, constant.ContextKeyUserGroup, "default")
|
||||
common.SetContextKey(ctx, constant.ContextKeyTokenGroup, "auto")
|
||||
common.SetContextKey(ctx, constant.ContextKeyTokenAutoGroups, []string{"vip", "default"})
|
||||
|
||||
groups, err := getModelListGroups(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"vip"}, groups.ownerGroups)
|
||||
|
||||
common.SetContextKey(ctx, constant.ContextKeyTokenAutoGroups, []string{"vip"})
|
||||
require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(`{"default":"Default"}`))
|
||||
groups, err = getModelListGroups(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, groups.ownerGroups)
|
||||
}
|
||||
|
||||
+124
-8
@@ -7,30 +7,115 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/i18n"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/QuantumNous/new-api/setting"
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func buildMaskedTokenResponse(token *model.Token) *model.Token {
|
||||
type tokenAutoGroupsInput struct {
|
||||
Set bool
|
||||
Groups []string
|
||||
}
|
||||
|
||||
func (input *tokenAutoGroupsInput) UnmarshalJSON(data []byte) error {
|
||||
input.Set = true
|
||||
if strings.TrimSpace(string(data)) == "null" {
|
||||
input.Groups = nil
|
||||
return nil
|
||||
}
|
||||
return common.Unmarshal(data, &input.Groups)
|
||||
}
|
||||
|
||||
type tokenRequest struct {
|
||||
model.Token
|
||||
AutoGroups tokenAutoGroupsInput `json:"auto_groups"`
|
||||
}
|
||||
|
||||
type tokenResponse struct {
|
||||
*model.Token
|
||||
AutoGroups []string `json:"auto_groups"`
|
||||
}
|
||||
|
||||
func buildMaskedTokenResponse(token *model.Token) *tokenResponse {
|
||||
if token == nil {
|
||||
return nil
|
||||
}
|
||||
maskedToken := *token
|
||||
maskedToken.Key = token.GetMaskedKey()
|
||||
return &maskedToken
|
||||
autoGroups, err := token.GetAutoGroups()
|
||||
if err != nil {
|
||||
common.SysError(fmt.Sprintf("failed to parse auto groups for token %d: %v", token.Id, err))
|
||||
autoGroups = nil
|
||||
}
|
||||
if len(autoGroups) == 0 {
|
||||
autoGroups = nil
|
||||
}
|
||||
return &tokenResponse{Token: &maskedToken, AutoGroups: autoGroups}
|
||||
}
|
||||
|
||||
func buildMaskedTokenResponses(tokens []*model.Token) []*model.Token {
|
||||
maskedTokens := make([]*model.Token, 0, len(tokens))
|
||||
func buildMaskedTokenResponses(tokens []*model.Token) []*tokenResponse {
|
||||
maskedTokens := make([]*tokenResponse, 0, len(tokens))
|
||||
for _, token := range tokens {
|
||||
maskedTokens = append(maskedTokens, buildMaskedTokenResponse(token))
|
||||
}
|
||||
return maskedTokens
|
||||
}
|
||||
|
||||
func getTokenRequestUserGroup(c *gin.Context) (string, error) {
|
||||
if userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup); userGroup != "" {
|
||||
return userGroup, nil
|
||||
}
|
||||
if userGroup := c.GetString("group"); userGroup != "" {
|
||||
return userGroup, nil
|
||||
}
|
||||
return model.GetUserGroup(c.GetInt("id"), false)
|
||||
}
|
||||
|
||||
func setTokenAutoGroups(c *gin.Context, token *model.Token, groups []string) bool {
|
||||
if len(groups) == 0 {
|
||||
if err := token.SetAutoGroups(nil); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
maxCount := setting.GetMaxTokenAutoGroups()
|
||||
if len(groups) > maxCount {
|
||||
common.ApiErrorI18n(c, i18n.MsgTokenAutoGroupsTooMany, map[string]any{"Max": maxCount})
|
||||
return false
|
||||
}
|
||||
|
||||
userGroup, err := getTokenRequestUserGroup(c)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return false
|
||||
}
|
||||
seen := make(map[string]struct{}, len(groups))
|
||||
for _, group := range groups {
|
||||
if _, ok := seen[group]; ok {
|
||||
common.ApiErrorI18n(c, i18n.MsgTokenAutoGroupsDuplicate, map[string]any{"Group": group})
|
||||
return false
|
||||
}
|
||||
seen[group] = struct{}{}
|
||||
if !service.IsUserSelectableGroup(userGroup, group) {
|
||||
common.ApiErrorI18n(c, i18n.MsgTokenAutoGroupsInvalid, map[string]any{"Group": group})
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if err := token.SetAutoGroups(groups); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func GetAllTokens(c *gin.Context) {
|
||||
userId := c.GetInt("id")
|
||||
pageInfo := common.GetPageQuery(c)
|
||||
@@ -77,6 +162,18 @@ func GetToken(c *gin.Context) {
|
||||
common.ApiSuccess(c, buildMaskedTokenResponse(token))
|
||||
}
|
||||
|
||||
func GetTokenAutoGroups(c *gin.Context) {
|
||||
userGroup, err := getTokenRequestUserGroup(c)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, gin.H{
|
||||
"groups": service.GetUserAutoGroup(userGroup),
|
||||
"max_count": setting.GetMaxTokenAutoGroups(),
|
||||
})
|
||||
}
|
||||
|
||||
func GetTokenKey(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
userId := c.GetInt("id")
|
||||
@@ -165,12 +262,13 @@ func GetTokenUsage(c *gin.Context) {
|
||||
}
|
||||
|
||||
func AddToken(c *gin.Context) {
|
||||
token := model.Token{}
|
||||
err := c.ShouldBindJSON(&token)
|
||||
request := tokenRequest{}
|
||||
err := c.ShouldBindJSON(&request)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
token := request.Token
|
||||
if len(token.Name) > 50 {
|
||||
common.ApiErrorI18n(c, i18n.MsgTokenNameTooLong)
|
||||
return
|
||||
@@ -201,6 +299,14 @@ func AddToken(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
if token.Group == "auto" {
|
||||
if !setTokenAutoGroups(c, &token, request.AutoGroups.Groups) {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
token.CrossGroupRetry = false
|
||||
_ = token.SetAutoGroups(nil)
|
||||
}
|
||||
key, err := common.GenerateKey()
|
||||
if err != nil {
|
||||
common.ApiErrorI18n(c, i18n.MsgTokenGenerateFailed)
|
||||
@@ -221,6 +327,7 @@ func AddToken(c *gin.Context) {
|
||||
AllowIps: token.AllowIps,
|
||||
Group: token.Group,
|
||||
CrossGroupRetry: token.CrossGroupRetry,
|
||||
AutoGroups: token.AutoGroups,
|
||||
}
|
||||
err = cleanToken.Insert()
|
||||
if err != nil {
|
||||
@@ -250,12 +357,13 @@ func DeleteToken(c *gin.Context) {
|
||||
func UpdateToken(c *gin.Context) {
|
||||
userId := c.GetInt("id")
|
||||
statusOnly := c.Query("status_only")
|
||||
token := model.Token{}
|
||||
err := c.ShouldBindJSON(&token)
|
||||
request := tokenRequest{}
|
||||
err := c.ShouldBindJSON(&request)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
token := request.Token
|
||||
if len(token.Name) > 50 {
|
||||
common.ApiErrorI18n(c, i18n.MsgTokenNameTooLong)
|
||||
return
|
||||
@@ -299,6 +407,14 @@ func UpdateToken(c *gin.Context) {
|
||||
cleanToken.AllowIps = token.AllowIps
|
||||
cleanToken.Group = token.Group
|
||||
cleanToken.CrossGroupRetry = token.CrossGroupRetry
|
||||
if token.Group != "auto" {
|
||||
cleanToken.CrossGroupRetry = false
|
||||
_ = cleanToken.SetAutoGroups(nil)
|
||||
} else if request.AutoGroups.Set {
|
||||
if !setTokenAutoGroups(c, cleanToken, request.AutoGroups.Groups) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
err = cleanToken.Update()
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/setting"
|
||||
"github.com/QuantumNous/new-api/setting/ratio_setting"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func configureTokenAutoGroupsTest(t *testing.T, maxCount string, autoGroups string) {
|
||||
t.Helper()
|
||||
originalMax := setting.GetMaxTokenAutoGroups()
|
||||
originalAutoGroups := setting.AutoGroups2JsonString()
|
||||
originalUsableGroups := setting.UserUsableGroups2JSONString()
|
||||
originalRatios := ratio_setting.GroupRatio2JSONString()
|
||||
require.NoError(t, setting.UpdateMaxTokenAutoGroups(maxCount))
|
||||
require.NoError(t, setting.UpdateAutoGroupsByJsonString(autoGroups))
|
||||
require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(`{"default":"Default","vip":"VIP"}`))
|
||||
require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(`{"default":1,"vip":1}`))
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, setting.UpdateMaxTokenAutoGroups(stringInt(originalMax)))
|
||||
require.NoError(t, setting.UpdateAutoGroupsByJsonString(originalAutoGroups))
|
||||
require.NoError(t, setting.UpdateUserUsableGroupsByJSONString(originalUsableGroups))
|
||||
require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(originalRatios))
|
||||
})
|
||||
}
|
||||
|
||||
func stringInt(value int) string {
|
||||
return fmt.Sprintf("%d", value)
|
||||
}
|
||||
|
||||
func setupTokenAutoGroupsControllerTest(t *testing.T) *model.User {
|
||||
t.Helper()
|
||||
db := setupTokenControllerTestDB(t)
|
||||
require.NoError(t, db.AutoMigrate(&model.User{}))
|
||||
user := &model.User{
|
||||
Id: 101,
|
||||
Username: "token-auto-user",
|
||||
Password: "password",
|
||||
Group: "default",
|
||||
Status: common.UserStatusEnabled,
|
||||
}
|
||||
require.NoError(t, db.Create(user).Error)
|
||||
return user
|
||||
}
|
||||
|
||||
func baseAutoTokenRequest(name string) map[string]any {
|
||||
return map[string]any{
|
||||
"name": name,
|
||||
"expired_time": -1,
|
||||
"remain_quota": 0,
|
||||
"unlimited_quota": true,
|
||||
"group": "auto",
|
||||
"cross_group_retry": true,
|
||||
}
|
||||
}
|
||||
|
||||
func newTokenAutoGroupsAuthenticatedContext(t *testing.T, method string, target string, body any, userID int) (*gin.Context, *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
ctx, recorder := newAuthenticatedContext(t, method, target, body, userID)
|
||||
common.SetContextKey(ctx, constant.ContextKeyUserGroup, "default")
|
||||
return ctx, recorder
|
||||
}
|
||||
|
||||
func TestAddTokenEmptyAutoGroupsInheritGlobalAuto(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
includeField bool
|
||||
value any
|
||||
}{
|
||||
{name: "omitted"},
|
||||
{name: "null", includeField: true, value: nil},
|
||||
{name: "empty array", includeField: true, value: []string{}},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
configureTokenAutoGroupsTest(t, "5", `["default","vip"]`)
|
||||
user := setupTokenAutoGroupsControllerTest(t)
|
||||
request := baseAutoTokenRequest("create-" + test.name)
|
||||
if test.includeField {
|
||||
request["auto_groups"] = test.value
|
||||
}
|
||||
|
||||
ctx, recorder := newTokenAutoGroupsAuthenticatedContext(t, http.MethodPost, "/api/token/", request, user.Id)
|
||||
AddToken(ctx)
|
||||
|
||||
response := decodeAPIResponse(t, recorder)
|
||||
require.True(t, response.Success, response.Message)
|
||||
var token model.Token
|
||||
require.NoError(t, model.DB.Where("name = ?", request["name"]).First(&token).Error)
|
||||
assert.Empty(t, token.AutoGroups)
|
||||
assert.True(t, token.CrossGroupRetry)
|
||||
payload, err := common.Marshal(buildMaskedTokenResponse(&token))
|
||||
require.NoError(t, err)
|
||||
var responseData map[string]any
|
||||
require.NoError(t, common.Unmarshal(payload, &responseData))
|
||||
assert.Nil(t, responseData["auto_groups"])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddTokenPersistsOrderedAutoGroupsSnapshot(t *testing.T) {
|
||||
configureTokenAutoGroupsTest(t, "5", `["default","vip"]`)
|
||||
user := setupTokenAutoGroupsControllerTest(t)
|
||||
request := baseAutoTokenRequest("ordered-snapshot")
|
||||
request["auto_groups"] = []string{"vip", "default"}
|
||||
|
||||
ctx, recorder := newTokenAutoGroupsAuthenticatedContext(t, http.MethodPost, "/api/token/", request, user.Id)
|
||||
AddToken(ctx)
|
||||
require.True(t, decodeAPIResponse(t, recorder).Success)
|
||||
|
||||
var token model.Token
|
||||
require.NoError(t, model.DB.Where("name = ?", "ordered-snapshot").First(&token).Error)
|
||||
assert.JSONEq(t, `["vip","default"]`, token.AutoGroups)
|
||||
|
||||
getCtx, getRecorder := newTokenAutoGroupsAuthenticatedContext(t, http.MethodGet, "/api/token/"+stringInt(token.Id), nil, user.Id)
|
||||
getCtx.Params = append(getCtx.Params, gin.Param{Key: "id", Value: stringInt(token.Id)})
|
||||
GetToken(getCtx)
|
||||
getResponse := decodeAPIResponse(t, getRecorder)
|
||||
require.True(t, getResponse.Success)
|
||||
var data struct {
|
||||
AutoGroups []string `json:"auto_groups"`
|
||||
}
|
||||
require.NoError(t, common.Unmarshal(getResponse.Data, &data))
|
||||
assert.Equal(t, []string{"vip", "default"}, data.AutoGroups)
|
||||
}
|
||||
|
||||
func TestUpdateTokenAutoGroupsTriStateAndNonAutoCleanup(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
includeField bool
|
||||
value any
|
||||
group string
|
||||
expectedAutoGroups string
|
||||
expectedRetry bool
|
||||
}{
|
||||
{name: "omitted preserves", group: "auto", expectedAutoGroups: `["vip","default"]`, expectedRetry: true},
|
||||
{name: "null inherits", includeField: true, value: nil, group: "auto", expectedRetry: true},
|
||||
{name: "empty inherits", includeField: true, value: []string{}, group: "auto", expectedRetry: true},
|
||||
{name: "non auto clears and disables retry", includeField: true, value: []string{"vip"}, group: "default"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
configureTokenAutoGroupsTest(t, "5", `["default","vip"]`)
|
||||
user := setupTokenAutoGroupsControllerTest(t)
|
||||
token := seedToken(t, model.DB, user.Id, "update-auto", "update-auto-key")
|
||||
token.Group = "auto"
|
||||
token.CrossGroupRetry = true
|
||||
require.NoError(t, token.SetAutoGroups([]string{"vip", "default"}))
|
||||
require.NoError(t, model.DB.Save(token).Error)
|
||||
|
||||
request := baseAutoTokenRequest("updated-auto")
|
||||
request["id"] = token.Id
|
||||
request["status"] = common.TokenStatusEnabled
|
||||
request["group"] = test.group
|
||||
if test.includeField {
|
||||
request["auto_groups"] = test.value
|
||||
}
|
||||
ctx, recorder := newTokenAutoGroupsAuthenticatedContext(t, http.MethodPut, "/api/token/", request, user.Id)
|
||||
UpdateToken(ctx)
|
||||
response := decodeAPIResponse(t, recorder)
|
||||
require.True(t, response.Success, response.Message)
|
||||
|
||||
var updated model.Token
|
||||
require.NoError(t, model.DB.First(&updated, token.Id).Error)
|
||||
if test.expectedAutoGroups == "" {
|
||||
assert.Empty(t, updated.AutoGroups)
|
||||
} else {
|
||||
assert.JSONEq(t, test.expectedAutoGroups, updated.AutoGroups)
|
||||
}
|
||||
assert.Equal(t, test.expectedRetry, updated.CrossGroupRetry)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddTokenRejectsInvalidAutoGroups(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
maxCount string
|
||||
groups []string
|
||||
}{
|
||||
{name: "over limit", maxCount: "1", groups: []string{"default", "vip"}},
|
||||
{name: "duplicate", maxCount: "5", groups: []string{"default", "default"}},
|
||||
{name: "auto pseudo group", maxCount: "5", groups: []string{"auto"}},
|
||||
{name: "unavailable", maxCount: "5", groups: []string{"missing"}},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
configureTokenAutoGroupsTest(t, test.maxCount, `["default","vip"]`)
|
||||
user := setupTokenAutoGroupsControllerTest(t)
|
||||
request := baseAutoTokenRequest("invalid-" + test.name)
|
||||
request["auto_groups"] = test.groups
|
||||
|
||||
ctx, recorder := newTokenAutoGroupsAuthenticatedContext(t, http.MethodPost, "/api/token/", request, user.Id)
|
||||
AddToken(ctx)
|
||||
|
||||
response := decodeAPIResponse(t, recorder)
|
||||
assert.False(t, response.Success)
|
||||
var count int64
|
||||
require.NoError(t, model.DB.Model(&model.Token{}).Count(&count).Error)
|
||||
assert.Zero(t, count)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTokenAutoGroupsReturnsFullFilteredGlobalOrderAndLimit(t *testing.T) {
|
||||
configureTokenAutoGroupsTest(t, "1", `["vip","missing","default"]`)
|
||||
user := setupTokenAutoGroupsControllerTest(t)
|
||||
|
||||
ctx, recorder := newTokenAutoGroupsAuthenticatedContext(t, http.MethodGet, "/api/token/auto-groups", nil, user.Id)
|
||||
GetTokenAutoGroups(ctx)
|
||||
|
||||
response := decodeAPIResponse(t, recorder)
|
||||
require.True(t, response.Success, response.Message)
|
||||
var data struct {
|
||||
Groups []string `json:"groups"`
|
||||
MaxCount int `json:"max_count"`
|
||||
}
|
||||
require.NoError(t, common.Unmarshal(response.Data, &data))
|
||||
assert.Equal(t, []string{"vip", "default"}, data.Groups)
|
||||
assert.Equal(t, 1, data.MaxCount)
|
||||
}
|
||||
@@ -273,6 +273,34 @@ func getTokenKeyColumnType(t *testing.T, db *gorm.DB, dialect string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func getTokenAutoGroupsColumnType(t *testing.T, db *gorm.DB, dialect string) string {
|
||||
t.Helper()
|
||||
|
||||
switch dialect {
|
||||
case "sqlite":
|
||||
return getSQLiteColumnType(t, db, "tokens", "auto_groups")
|
||||
case "mysql":
|
||||
var columnType string
|
||||
if err := db.Raw(`SELECT DATA_TYPE FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ?`,
|
||||
"tokens", "auto_groups").Scan(&columnType).Error; err != nil {
|
||||
t.Fatalf("failed to inspect mysql token auto_groups column: %v", err)
|
||||
}
|
||||
return strings.ToLower(columnType)
|
||||
case "postgres":
|
||||
var dataType string
|
||||
if err := db.Raw(`SELECT data_type FROM information_schema.columns
|
||||
WHERE table_schema = current_schema() AND table_name = ? AND column_name = ?`,
|
||||
"tokens", "auto_groups").Scan(&dataType).Error; err != nil {
|
||||
t.Fatalf("failed to inspect postgres token auto_groups column: %v", err)
|
||||
}
|
||||
return strings.ToLower(dataType)
|
||||
default:
|
||||
t.Fatalf("unsupported dialect %q", dialect)
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func runTokenMigrationCompatibilityTest(t *testing.T, db *gorm.DB, dialect string, managedTokensTable *bool) {
|
||||
t.Helper()
|
||||
|
||||
@@ -314,6 +342,12 @@ func runTokenMigrationCompatibilityTest(t *testing.T, db *gorm.DB, dialect strin
|
||||
if got := getTokenKeyColumnType(t, db, dialect); got != "varchar(128)" {
|
||||
t.Fatalf("expected migrated key column type varchar(128), got %q", got)
|
||||
}
|
||||
if !db.Migrator().HasColumn(&model.Token{}, "auto_groups") {
|
||||
t.Fatal("expected migration to add auto_groups column")
|
||||
}
|
||||
if got := getTokenAutoGroupsColumnType(t, db, dialect); got != "text" {
|
||||
t.Fatalf("expected migrated auto_groups column type text, got %q", got)
|
||||
}
|
||||
|
||||
var migratedToken model.Token
|
||||
if err := db.First(&migratedToken, "name = ?", "legacy-token").Error; err != nil {
|
||||
@@ -325,6 +359,9 @@ func runTokenMigrationCompatibilityTest(t *testing.T, db *gorm.DB, dialect strin
|
||||
if migratedToken.Name != "legacy-token" {
|
||||
t.Fatalf("expected migrated token name to be preserved, got %q", migratedToken.Name)
|
||||
}
|
||||
if migratedToken.AutoGroups != "" {
|
||||
t.Fatalf("expected legacy token to inherit global Auto groups, got %q", migratedToken.AutoGroups)
|
||||
}
|
||||
|
||||
inserted := model.Token{
|
||||
UserId: 8,
|
||||
@@ -362,6 +399,9 @@ func TestTokenAutoMigrateUsesVarchar128KeyColumn(t *testing.T) {
|
||||
if got := getTokenKeyColumnType(t, db, "sqlite"); got != "varchar(128)" {
|
||||
t.Fatalf("expected key column type varchar(128), got %q", got)
|
||||
}
|
||||
if got := getSQLiteColumnType(t, db, "tokens", "auto_groups"); got != "text" {
|
||||
t.Fatalf("expected auto_groups column type text, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenMigrationFromChar48ToVarchar128(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user