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:
Calcium-Ion
2026-08-01 23:19:01 +08:00
committed by GitHub
parent bd585d78ef
commit 0ab0202060
57 changed files with 3922 additions and 210 deletions
+24 -21
View File
@@ -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))
+69 -1
View File
@@ -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")
+33
View File
@@ -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
View File
@@ -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 {
+234
View File
@@ -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)
}
+40
View File
@@ -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) {