Files
new-api/controller/user_manage_test.go
T
CaIon caca52f8de feat(auth): replace the dashboard access token with scoped access tokens
Each user can now hold up to 20 named access tokens in the
user_access_tokens table instead of a single token on the user row.
Tokens are shown once at creation (nap_ prefix) and stored as a SHA-256
digest. They expire after 30 days by default; a token without expiry is
allowed with a warning.

A token only reaches the routes its scopes allow. Scopes are picked from
the same permission matrix as admin permissions and are limited to what
the owner can do; every management route declares the scope it needs,
and routes without a declaration reject tokens. Token management itself
always requires a browser session.

Tokens can complete step-up verification, so verified actions such as
changing a password or 2FA settings work with a token; such changes sign
out all browser sessions. Disabling a user keeps their tokens, deleting
the account removes them.

The existing dashboard token keeps working for 30 days after the
upgrade. It can be revoked but no longer regenerated, and afterwards it
is rejected with ACCESS_TOKEN_LEGACY_RETIRED. The security page lists
tokens with their access records, and audit details show scope names.
2026-09-30 17:24:19 +08:00

644 lines
28 KiB
Go

package controller
import (
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"sort"
"strconv"
"strings"
"sync"
"testing"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/service/authz"
"github.com/alicebob/miniredis/v2"
"github.com/go-redis/redis/v8"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func setupManageUserTestDB(t *testing.T) *gorm.DB {
t.Helper()
require.NoError(t, i18n.Init())
previousDB, previousLogDB := model.DB, model.LOG_DB
previousRedisEnabled, previousSecret := common.RedisEnabled, common.SessionSecret
previousMainDatabaseType, previousLogDatabaseType := common.MainDatabaseType(), common.LogDatabaseType()
dialect := os.Getenv("TEST_MANAGE_USER_DIALECT")
if dialect == "" {
dialect = "sqlite"
}
databaseTypes := map[string]common.DatabaseType{
"sqlite": common.DatabaseTypeSQLite, "mysql": common.DatabaseTypeMySQL, "postgres": common.DatabaseTypePostgreSQL,
}
require.Contains(t, databaseTypes, dialect)
dsn := os.Getenv("TEST_" + strings.ToUpper(dialect) + "_DSN")
db, _ := newAuditTestDatabase(t, dialect, dsn)
logDB := db
if os.Getenv("TEST_MANAGE_USER_SEPARATE_LOG_DB") == "1" {
logDB, _ = newAuditTestDatabase(t, dialect, dsn)
}
model.DB, model.LOG_DB = db, logDB
common.RedisEnabled = false
common.SessionSecret = "manage-user-test-secret"
common.SetDatabaseTypes(databaseTypes[dialect], databaseTypes[dialect])
t.Cleanup(func() {
model.DB, model.LOG_DB = previousDB, previousLogDB
common.RedisEnabled, common.SessionSecret = previousRedisEnabled, previousSecret
common.SetDatabaseTypes(previousMainDatabaseType, previousLogDatabaseType)
sqlDB, err := db.DB()
if err == nil {
_ = sqlDB.Close()
}
if logDB != db {
sqlLogDB, err := logDB.DB()
if err == nil {
_ = sqlLogDB.Close()
}
}
})
require.NoError(t, db.AutoMigrate(&model.User{}, &model.UserSession{}, &model.CasbinRule{}, &model.AuthzRole{}, &model.AuthFlow{}, &model.TwoFA{}, &model.PasskeyCredential{}, &model.UserAccessToken{}))
require.NoError(t, logDB.AutoMigrate(&model.Log{}, &model.AuditLog{}))
versionQuery := "SELECT version()"
if dialect == "sqlite" {
versionQuery = "SELECT sqlite_version()"
}
var version string
require.NoError(t, db.Raw(versionQuery).Scan(&version).Error)
t.Logf("database: %s %s, separate log database: %v", dialect, version, logDB != db)
return db
}
func performManageUserRequest(t *testing.T, body string) *httptest.ResponseRecorder {
t.Helper()
return performVerifiedManageUserRequest(t, body, service.AuthIdentity{UserID: 9999}, "")
}
// performVerifiedManageUserRequest drives ManageUser as the root operator from
// a dashboard login session, carrying the step-up proof status or role changes need.
func performVerifiedManageUserRequest(t *testing.T, body string, identity service.AuthIdentity, proof string) *httptest.ResponseRecorder {
t.Helper()
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/api/user/manage", strings.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
if proof != "" {
c.Request.Header.Set("X-Security-Proof", proof)
}
c.Set("id", identity.UserID)
c.Set("role", common.RoleRootUser)
c.Set("username", "root-operator")
c.Set("session_id", identity.SessionID)
c.Set("auth_version", identity.UserAuthVersion)
c.Set("session_version", identity.SessionVersion)
c.Set(common.RequestIdKey, "quota-test-request")
ManageUser(c)
return recorder
}
// manageUserProof signs the root operator in and issues a password-method proof
// bound to one ManageUser operation.
func manageUserProof(t *testing.T, db *gorm.DB, operation service.VerificationOperation) (service.AuthIdentity, string) {
t.Helper()
operator := createQuotaTestOperator(t, db, common.RoleRootUser)
require.NoError(t, model.PublishUserAuthCache(operator.Id))
bundle, err := service.CreateLoginSession(operator.Id, "password", "127.0.0.1", "manage-user-test")
require.NoError(t, err)
identity, err := service.ParseAccessToken(bundle.AccessToken)
require.NoError(t, err)
binding, err := service.BindVerificationOperation(operation)
require.NoError(t, err)
proof, _, err := service.IssueSecurityProof(identity, service.VerificationMethodPassword, binding)
require.NoError(t, err)
return identity, proof
}
func TestManageUserDisableAdvancesAuthVersionOnceAndRevokesSession(t *testing.T) {
db := setupManageUserTestDB(t)
now := time.Now().Unix()
user := model.User{
Username: "managed-disable-user", Password: "password", Role: common.RoleCommonUser,
Status: common.UserStatusEnabled, Group: "default", AuthVersion: 1,
}
require.NoError(t, db.Create(&user).Error)
require.NoError(t, db.Create(&model.UserSession{
SID: "managed-disable-session", UserID: user.Id, Version: 1, UserAuthVersion: 1,
Status: model.UserSessionStatusActive, RefreshHash: "refresh-hash", LoginMethod: "password",
LastActiveAt: now, ExpiresAt: now + 3600,
}).Error)
identity, proof := manageUserProof(t, db, service.VerificationOperation{Scope: service.VerificationScopeAdminUserManage, Context: []byte(fmt.Sprintf(`{"user_id":%d,"action":"disable"}`, user.Id))})
recorder := performVerifiedManageUserRequest(t, fmt.Sprintf(`{"id":%d,"action":"disable"}`, user.Id), identity, proof)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Contains(t, recorder.Body.String(), `"success":true`)
var updated model.User
require.NoError(t, db.First(&updated, user.Id).Error)
assert.Equal(t, common.UserStatusDisabled, updated.Status)
assert.EqualValues(t, 2, updated.AuthVersion)
var session model.UserSession
require.NoError(t, db.First(&session, "sid = ?", "managed-disable-session").Error)
assert.Equal(t, model.UserSessionStatusRevoked, session.Status)
}
func TestManageUserDemoteAdvancesAuthVersionAndRevokesSessionsOnce(t *testing.T) {
db := setupManageUserTestDB(t)
previousMaster := common.IsMasterNode
common.IsMasterNode = false
t.Cleanup(func() { common.IsMasterNode = previousMaster })
require.NoError(t, authz.Init(db))
now := time.Now().Unix()
user := model.User{
Username: "managed-demote-user", Password: "password", Role: common.RoleAdminUser,
Status: common.UserStatusEnabled, Group: "default", AuthVersion: 1,
}
require.NoError(t, db.Create(&user).Error)
for _, sid := range []string{"managed-demote-session-one", "managed-demote-session-two"} {
require.NoError(t, db.Create(&model.UserSession{
SID: sid, UserID: user.Id, Version: 1, UserAuthVersion: 1,
Status: model.UserSessionStatusActive, RefreshHash: "refresh-" + sid, LoginMethod: "password",
LastActiveAt: now, ExpiresAt: now + 3600,
}).Error)
}
sessionUpdateCount := 0
require.NoError(t, db.Callback().Update().Before("gorm:update").Register("test:count_demote_session_updates", func(tx *gorm.DB) {
if tx.Statement != nil && tx.Statement.Table == "user_sessions" {
sessionUpdateCount++
}
}))
identity, proof := manageUserProof(t, db, service.VerificationOperation{Scope: service.VerificationScopeAdminUserManage, Context: []byte(fmt.Sprintf(`{"user_id":%d,"action":"demote"}`, user.Id))})
recorder := performVerifiedManageUserRequest(t, fmt.Sprintf(`{"id":%d,"action":"demote"}`, user.Id), identity, proof)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Contains(t, recorder.Body.String(), `"success":true`)
var updated model.User
require.NoError(t, db.First(&updated, user.Id).Error)
assert.Equal(t, common.RoleCommonUser, updated.Role)
assert.EqualValues(t, 2, updated.AuthVersion)
var sessions []model.UserSession
require.NoError(t, db.Where("user_id = ?", user.Id).Order("sid asc").Find(&sessions).Error)
require.Len(t, sessions, 2)
for _, session := range sessions {
assert.Equal(t, model.UserSessionStatusRevoked, session.Status)
assert.Equal(t, "admin_demote", session.RevokedReason)
}
assert.Equal(t, 1, sessionUpdateCount)
}
func TestManageUserDeleteReturnsImmediatelyAndUnknownActionFails(t *testing.T) {
db := setupManageUserTestDB(t)
deleted := model.User{
Username: "managed-delete-user", Password: "password", Role: common.RoleCommonUser,
Status: common.UserStatusEnabled, Group: "default", AuthVersion: 1, AffCode: "delete-aff",
}
require.NoError(t, db.Create(&deleted).Error)
identity, proof := manageUserProof(t, db, service.VerificationOperation{Scope: service.VerificationScopeAdminUserDelete, Context: []byte(fmt.Sprintf(`{"user_id":%d}`, deleted.Id))})
recorder := performVerifiedManageUserRequest(t, fmt.Sprintf(`{"id":%d,"action":"delete"}`, deleted.Id), identity, proof)
assert.Contains(t, recorder.Body.String(), `"success":true`)
var deletedCount int64
require.NoError(t, db.Unscoped().Model(&model.User{}).Where("id = ? AND deleted_at IS NOT NULL", deleted.Id).Count(&deletedCount).Error)
assert.EqualValues(t, 1, deletedCount)
unchanged := model.User{
Username: "managed-unknown-user", Password: "password", Role: common.RoleCommonUser,
Status: common.UserStatusEnabled, Group: "default", AuthVersion: 1, AffCode: "unknown-aff",
}
require.NoError(t, db.Create(&unchanged).Error)
recorder = performManageUserRequest(t, fmt.Sprintf(`{"id":%d,"action":"unknown"}`, unchanged.Id))
assert.Contains(t, recorder.Body.String(), `"success":false`)
require.NoError(t, db.First(&unchanged, unchanged.Id).Error)
assert.EqualValues(t, 1, unchanged.AuthVersion)
assert.Equal(t, common.UserStatusEnabled, unchanged.Status)
}
func createQuotaTestOperator(t *testing.T, db *gorm.DB, role int) model.User {
t.Helper()
if role == 0 {
role = common.RoleRootUser
}
operator := model.User{Id: 9999, Username: "root-operator", Password: "root-operator-hash", Role: role, Status: common.UserStatusEnabled, AuthVersion: 1, AffCode: "root-operator-aff"}
require.NoError(t, db.Create(&operator).Error)
return operator
}
func TestManageUserQuotaRecordsTopupAndAudit(t *testing.T) {
for _, tc := range []struct {
name, mode, action, content string
value, wantQuota int
}{
{"add", "add", "user.quota_add", "Increased user quota by 500", 500, 1500},
{"subtract", "subtract", "user.quota_subtract", "Decreased user quota by 500", 500, 500},
{"override_up", "override", "user.quota_override", "Overrode user quota from 1000 to 1500", 1500, 1500},
{"override_down", "override", "user.quota_override", "Overrode user quota from 1000 to 500", 500, 500},
{"override_unchanged", "override", "user.quota_override", "Overrode user quota from 1000 to 1000", 1000, 1000},
{"override_zero", "override", "user.quota_override", "Overrode user quota from 1000 to 0", 0, 0},
{"override_negative", "override", "user.quota_override", "Overrode user quota from 1000 to -1", -1, -1},
} {
t.Run(tc.name, func(t *testing.T) {
db := setupManageUserTestDB(t)
user := model.User{Username: "quota-owner", Role: common.RoleCommonUser, Quota: 1000, AffCode: "quota-owner-aff"}
require.NoError(t, db.Create(&user).Error)
createQuotaTestOperator(t, db, common.RoleRootUser)
recorder := performManageUserRequest(t, fmt.Sprintf(`{"id":%d,"action":"add_quota","mode":%q,"value":%d}`, user.Id, tc.mode, tc.value))
assert.Equal(t, http.StatusOK, recorder.Code)
require.Contains(t, recorder.Body.String(), `"success":true`)
require.NoError(t, db.First(&user, user.Id).Error)
assert.Equal(t, tc.wantQuota, user.Quota)
logs, total, err := model.GetAllLogs(model.LogTypeTopup, 0, 0, "", "", "", 0, 20, 0, "", "", "")
require.NoError(t, err)
assert.EqualValues(t, 1, total)
require.Len(t, logs, 1)
assert.Equal(t, user.Id, logs[0].UserId)
assert.Equal(t, user.Username, logs[0].Username)
assert.Equal(t, tc.content, logs[0].Content)
model.FormatAdminLogs(logs)
var other model.AuditOther
require.NoError(t, common.UnmarshalJsonStr(logs[0].Other, &other))
assert.Equal(t, &model.AuditAdminInfo{AdminID: 9999, AdminUsername: "root-operator", AdminRole: common.RoleRootUser, AuthMethod: "session"}, other.AdminInfo)
logs, total, err = model.GetUserLogs(user.Id, model.LogTypeTopup, 0, 0, "", "", 0, 20, "", "", "")
require.NoError(t, err)
assert.EqualValues(t, 1, total)
require.Len(t, logs, 1)
other = model.AuditOther{}
require.NoError(t, common.UnmarshalJsonStr(logs[0].Other, &other))
assert.Nil(t, other.AdminInfo)
require.NotNil(t, other.Op)
assert.Equal(t, tc.action, other.Op.Action)
params, err := common.Marshal(other.Op.Params)
require.NoError(t, err)
expectedParams := model.AuditFields{"target_user_id": user.Id, "target_username": user.Username, "mode": tc.mode, "requested_quota": tc.value, "from": 1000, "to": tc.wantQuota}
if tc.mode != "override" {
expectedParams["quota"] = tc.value
}
expected, err := common.Marshal(expectedParams)
require.NoError(t, err)
assert.JSONEq(t, string(expected), string(params))
assert.Equal(t, "quota-test-request", logs[0].RequestId)
assert.Empty(t, logs[0].Ip, "recipient logs must not disclose the administrator IP")
logs, total, err = model.GetUserLogs(9999, model.LogTypeTopup, 0, 0, "", "", 0, 20, "", "", "")
require.NoError(t, err)
assert.Zero(t, total)
assert.Empty(t, logs)
var audits []model.AuditLog
require.NoError(t, model.LOG_DB.Find(&audits).Error)
require.Len(t, audits, 1)
assert.Equal(t, 9999, audits[0].UserId)
assert.Equal(t, "root-operator", audits[0].Username)
assert.Equal(t, tc.action, audits[0].Action)
assert.True(t, audits[0].Success)
params, err = common.Marshal(audits[0].Other.Op.Params)
require.NoError(t, err)
assert.JSONEq(t, string(expected), string(params))
assert.Equal(t, "quota-test-request", audits[0].RequestId)
})
}
}
func TestManageUserQuotaFailuresDoNotRecordTopup(t *testing.T) {
for _, tc := range []struct {
name, mode string
value int
failUpdate bool
}{
{"zero_add", "add", 0, false},
{"negative_subtract", "subtract", -1, false},
{"invalid_mode", "invalid", 500, false},
{"add_update_error", "add", 500, true},
{"subtract_update_error", "subtract", 500, true},
{"override_update_error", "override", 500, true},
} {
t.Run(tc.name, func(t *testing.T) {
db := setupManageUserTestDB(t)
createQuotaTestOperator(t, db, common.RoleRootUser)
user := model.User{Username: "quota-owner", Role: common.RoleCommonUser, Quota: 1000}
require.NoError(t, db.Create(&user).Error)
if tc.failUpdate {
require.NoError(t, db.Callback().Update().Before("gorm:update").Register("test:fail_quota_update", func(tx *gorm.DB) {
if tx.Statement.Table == "users" {
tx.AddError(errors.New("quota update unavailable"))
}
}))
}
recorder := performManageUserRequest(t, fmt.Sprintf(`{"id":%d,"action":"add_quota","mode":%q,"value":%d}`, user.Id, tc.mode, tc.value))
assert.Contains(t, recorder.Body.String(), `"success":false`)
require.NoError(t, db.First(&user, user.Id).Error)
assert.Equal(t, 1000, user.Quota)
var logCount, auditCount int64
require.NoError(t, model.LOG_DB.Model(&model.Log{}).Count(&logCount).Error)
require.NoError(t, model.LOG_DB.Model(&model.AuditLog{}).Count(&auditCount).Error)
assert.Zero(t, logCount)
assert.EqualValues(t, 1, auditCount)
var audit model.AuditLog
require.NoError(t, model.LOG_DB.First(&audit).Error)
assert.False(t, audit.Success)
params, err := common.Marshal(audit.Other.Op.Params)
require.NoError(t, err)
assert.NotContains(t, string(params), `"from"`)
assert.NotContains(t, string(params), `"to"`)
assert.NotContains(t, string(params), `"target_username"`)
assert.NotContains(t, string(params), "quota update unavailable")
reason := "invalid_parameters"
if tc.failUpdate {
reason = "database_error"
}
assert.Contains(t, string(params), `"failure_reason":"`+reason+`"`)
assert.Contains(t, string(params), fmt.Sprintf(`"requested_quota":%d`, tc.value))
})
}
}
func TestManageUserQuotaLogFailureKeepsSuccessfulAdjustment(t *testing.T) {
for _, failedTable := range []string{"logs", "audit_logs"} {
t.Run(failedTable, func(t *testing.T) {
db := setupManageUserTestDB(t)
createQuotaTestOperator(t, db, common.RoleRootUser)
user := model.User{Username: "quota-owner", Role: common.RoleCommonUser, Quota: 1000}
require.NoError(t, db.Create(&user).Error)
require.NoError(t, model.LOG_DB.Callback().Create().Before("gorm:create").Register("test:fail_quota_log", func(tx *gorm.DB) {
if tx.Statement.Table == failedTable {
tx.AddError(errors.New("quota log unavailable"))
}
}))
recorder := performManageUserRequest(t, fmt.Sprintf(`{"id":%d,"action":"add_quota","mode":"add","value":500}`, user.Id))
assert.Contains(t, recorder.Body.String(), `"success":true`)
require.NoError(t, db.First(&user, user.Id).Error)
assert.Equal(t, 1500, user.Quota)
var logCount, auditCount int64
require.NoError(t, model.LOG_DB.Model(&model.Log{}).Count(&logCount).Error)
require.NoError(t, model.LOG_DB.Model(&model.AuditLog{}).Count(&auditCount).Error)
if failedTable == "logs" {
assert.Zero(t, logCount)
assert.EqualValues(t, 1, auditCount)
} else {
assert.EqualValues(t, 1, logCount)
assert.Zero(t, auditCount)
}
})
}
}
func TestManageUserQuotaTargetsAndWalletBounds(t *testing.T) {
for _, tc := range []struct {
name, mode, reason string
before, value, targetID, targetRole, operatorRole int
deleted, failRead bool
}{
{name: "zero_id", mode: "add", value: 1, reason: "invalid_parameters"},
{name: "negative_id", mode: "subtract", value: 1, targetID: -1, reason: "invalid_parameters"},
{name: "missing", mode: "override", value: 1, targetID: 12345, reason: "target_not_found"},
{name: "deleted", mode: "add", value: 1, targetID: 1, deleted: true, reason: "target_not_found"},
{name: "peer_admin", mode: "add", value: 1, targetID: 1, targetRole: common.RoleAdminUser, operatorRole: common.RoleAdminUser, reason: "permission_denied"},
{name: "higher_role", mode: "override", value: 1, targetID: 1, targetRole: common.RoleRootUser, operatorRole: common.RoleAdminUser, reason: "permission_denied"},
{name: "read_error", mode: "add", value: 1, targetID: 1, failRead: true, reason: "database_error"},
{name: "add_overflow", mode: "add", before: common.MaxWalletQuota, value: 1, targetID: 1, reason: "quota_limit_exceeded"},
{name: "subtract_underflow", mode: "subtract", before: -common.MaxWalletQuota, value: 1, targetID: 1, reason: "quota_limit_exceeded"},
{name: "oversized_add", mode: "add", value: common.MaxWalletQuota + 1, targetID: 1, reason: "quota_limit_exceeded"},
{name: "oversized_subtract", mode: "subtract", value: common.MaxWalletQuota + 1, targetID: 1, reason: "quota_limit_exceeded"},
{name: "oversized_override", mode: "override", value: -common.MaxWalletQuota - 1, targetID: 1, reason: "quota_limit_exceeded"},
} {
t.Run(tc.name, func(t *testing.T) {
db := setupManageUserTestDB(t)
createQuotaTestOperator(t, db, tc.operatorRole)
user := model.User{Id: 1, Username: "private-target-name", Role: tc.targetRole, Quota: tc.before}
require.NoError(t, db.Create(&user).Error)
if tc.deleted {
require.NoError(t, db.Delete(&user).Error)
}
if tc.failRead {
require.NoError(t, db.Callback().Query().Before("gorm:query").Register("test:quota_read_error", func(tx *gorm.DB) {
if tx.Statement.Table == "users" && len(tx.Statement.Selects) == 0 {
tx.AddError(errors.New("private database error"))
}
}))
}
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/api/user/manage", strings.NewReader(fmt.Sprintf(`{"id":%d,"action":"add_quota","mode":%q,"value":%d}`, tc.targetID, tc.mode, tc.value)))
role := tc.operatorRole
if role == 0 {
role = common.RoleRootUser
}
c.Set("id", 9999)
c.Set("role", role)
ManageUser(c)
require.Contains(t, recorder.Body.String(), `"success":false`)
if tc.failRead {
require.NoError(t, db.Callback().Query().Remove("test:quota_read_error"))
}
require.NoError(t, db.Unscoped().First(&user, user.Id).Error)
assert.Equal(t, tc.before, user.Quota)
var audits []model.AuditLog
require.NoError(t, model.LOG_DB.Find(&audits).Error)
require.Len(t, audits, 1)
assert.False(t, audits[0].Success)
params, err := common.Marshal(audits[0].Other.Op.Params)
require.NoError(t, err)
expected, err := common.Marshal(model.AuditFields{"target_user_id": tc.targetID, "mode": tc.mode, "requested_quota": tc.value, "failure_reason": tc.reason})
require.NoError(t, err)
assert.JSONEq(t, string(expected), string(params))
assert.NotContains(t, audits[0].Content, user.Username)
var count int64
require.NoError(t, model.LOG_DB.Model(&model.Log{}).Count(&count).Error)
assert.Zero(t, count)
})
}
}
func TestManageUserQuotaMiddlewareKeepsOneOperationPerRequest(t *testing.T) {
db := setupManageUserTestDB(t)
pat := "quota-middleware-test-token"
operator := model.User{Id: 9999, Username: "root-operator", Role: common.RoleRootUser, Status: common.UserStatusEnabled, AuthVersion: 1, AccessToken: &pat, Quota: 1000}
require.NoError(t, db.Create(&operator).Error)
router := gin.New()
router.Use(middleware.RequestId(), middleware.AccessTokenAudit())
router.POST("/api/user/manage", middleware.AdminAuth(), ManageUser)
for _, tc := range []struct {
body, action string
success bool
}{
{`{"id":9999,"action":"add_quota","mode":"add","value":100}`, "user.quota_add", true},
{`{"id":9999,"action":"add_quota","mode":"subtract","value":0}`, "user.quota_subtract", false},
{`{"id":9999,"action":"add_quota","mode":"invalid","value":1}`, "generic", false},
{`{"id":`, "generic", false},
} {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/api/user/manage", strings.NewReader(tc.body))
request.Header.Set("Authorization", "Bearer "+pat)
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Contains(t, recorder.Body.String(), fmt.Sprintf(`"success":%t`, tc.success))
requestID := recorder.Header().Get(common.RequestIdKey)
require.NotEmpty(t, requestID)
var audits []model.AuditLog
require.NoError(t, model.LOG_DB.Where("request_id = ?", requestID).Find(&audits).Error)
require.Len(t, audits, 2, "one operation audit and one PAT request audit")
for _, audit := range audits {
assert.Equal(t, tc.success, audit.Success)
if audit.Category != model.AuditCategoryOperation {
continue
}
assert.Equal(t, tc.action, audit.Action)
assert.Equal(t, operator.Id, audit.UserId)
assert.Equal(t, "/api/user/manage", audit.Route)
if tc.success {
params, err := common.Marshal(audit.Other.Op.Params)
require.NoError(t, err)
assert.Contains(t, string(params), `"target_user_id":9999`)
assert.Contains(t, string(params), `"target_username":"root-operator"`)
}
}
var count int64
require.NoError(t, model.LOG_DB.Model(&model.Log{}).Where("request_id = ?", requestID).Count(&count).Error)
if tc.success {
assert.EqualValues(t, 1, count)
} else {
assert.Zero(t, count)
}
}
require.NoError(t, db.First(&operator, operator.Id).Error)
assert.Equal(t, 1100, operator.Quota)
}
func TestManageUserQuotaConcurrentSnapshots(t *testing.T) {
db := setupManageUserTestDB(t)
user := model.User{Username: "concurrent-quota", Quota: 1000}
require.NoError(t, db.Create(&user).Error)
var ready sync.WaitGroup
ready.Add(2)
release := make(chan struct{})
require.NoError(t, db.Callback().Query().Before("gorm:query").Register("test:concurrent_quota_start", func(tx *gorm.DB) {
if tx.Statement.Table == "users" {
ready.Done()
<-release
}
}))
type result struct {
adjustment *model.UserQuotaAdjustment
err error
value int
}
results := make(chan result, 2)
for _, value := range []int{10, 20} {
go func(value int) {
adjustment, err := model.AdjustUserQuota(user.Id, common.RoleRootUser, "add", value)
results <- result{adjustment, err, value}
}(value)
}
ready.Wait()
close(release)
var committed []model.UserQuotaAdjustment
for range 2 {
result := <-results
if result.err != nil {
require.True(t, common.UsingMainDatabase(common.DatabaseTypeSQLite), "row-locking databases must serialize both adjustments: %v", result.err)
assert.Contains(t, strings.ToLower(result.err.Error()), "locked")
assert.Nil(t, result.adjustment)
continue
}
require.NotNil(t, result.adjustment)
assert.Equal(t, result.value, result.adjustment.After-result.adjustment.Before)
committed = append(committed, *result.adjustment)
}
require.NoError(t, db.Callback().Query().Remove("test:concurrent_quota_start"))
require.NotEmpty(t, committed)
sort.Slice(committed, func(i, j int) bool { return committed[i].Before < committed[j].Before })
balance := 1000
for _, adjustment := range committed {
assert.Equal(t, balance, adjustment.Before)
balance = adjustment.After
}
require.NoError(t, db.First(&user, user.Id).Error)
assert.Equal(t, balance, user.Quota)
}
func TestManageUserQuotaCacheUsesCommittedIntegerDifference(t *testing.T) {
for _, tc := range []struct {
name, mode string
before, cached, value, after, wantCached int
failUpdate, failCache, missingCache bool
}{
{name: "add_preserves_reservations", mode: "add", before: 1000, cached: 900, value: 500, after: 1500, wantCached: 1400},
{name: "subtract_preserves_reservations", mode: "subtract", before: 1000, cached: 900, value: 500, after: 500, wantCached: 400},
{name: "override_preserves_reservations", mode: "override", before: 1000, cached: 900, value: 2000, after: 2000, wantCached: 1900},
{name: "large_odd_difference", mode: "override", before: common.MaxWalletQuota - 1, cached: common.MaxWalletQuota - 1, value: -common.MaxWalletQuota, after: -common.MaxWalletQuota, wantCached: -common.MaxWalletQuota},
{name: "rollback_does_not_change_cache", mode: "subtract", before: 1000, cached: 900, value: 500, after: 1000, wantCached: 900, failUpdate: true},
{name: "cache_error_keeps_committed_change", mode: "add", before: 1000, value: 500, after: 1500, failCache: true},
{name: "missing_cache_is_not_partially_created", mode: "add", before: 1000, value: 500, after: 1500, missingCache: true},
} {
t.Run(tc.name, func(t *testing.T) {
db := setupManageUserTestDB(t)
operator := createQuotaTestOperator(t, db, common.RoleRootUser)
server := miniredis.RunT(t)
oldRDB := common.RDB
common.RDB = redis.NewClient(&redis.Options{Addr: server.Addr(), MaxRetries: -1})
common.RedisEnabled = true
t.Cleanup(func() { _ = common.RDB.Close(); common.RDB = oldRDB })
user := model.User{Username: "cached-quota", Quota: tc.before, AuthVersion: 1}
require.NoError(t, db.Create(&user).Error)
cache, err := model.GetUserCache(user.Id)
require.NoError(t, err)
assert.Equal(t, tc.before, cache.Quota)
_, err = model.GetUserCache(operator.Id)
require.NoError(t, err)
keys := server.Keys()
var quotaKey string
for _, key := range keys {
if server.HGet(key, "Id") == strconv.Itoa(user.Id) && server.HGet(key, "Quota") != "" {
quotaKey = key
break
}
}
require.NotEmpty(t, quotaKey)
server.HSet(quotaKey, "Quota", strconv.Itoa(tc.cached))
if tc.missingCache {
server.Del(quotaKey)
}
if tc.failCache {
server.SetError("ERR quota cache unavailable")
}
if tc.failUpdate {
require.NoError(t, db.Callback().Update().After("gorm:update").Register("test:cache_quota_rollback", func(tx *gorm.DB) {
if tx.Statement.Table == "users" {
tx.AddError(errors.New("quota rollback"))
}
}))
}
recorder := performManageUserRequest(t, fmt.Sprintf(`{"id":%d,"action":"add_quota","mode":%q,"value":%d}`, user.Id, tc.mode, tc.value))
assert.Contains(t, recorder.Body.String(), fmt.Sprintf(`"success":%t`, !tc.failUpdate))
require.NoError(t, db.First(&user, user.Id).Error)
assert.Equal(t, tc.after, user.Quota)
if tc.missingCache {
// Log username lookup may hydrate the whole user after commit.
if server.Exists(quotaKey) {
assert.Equal(t, strconv.Itoa(user.Id), server.HGet(quotaKey, "Id"))
assert.NotEmpty(t, server.HGet(quotaKey, "CacheSchema"))
assert.Equal(t, strconv.Itoa(tc.after), server.HGet(quotaKey, "Quota"))
}
} else if !tc.failCache {
assert.Equal(t, strconv.Itoa(tc.wantCached), server.HGet(quotaKey, "Quota"))
}
})
}
}