Files
new-api/model/user_cache_auth_version_test.go
Calcium-Ion 31d70fca39 refactor(auth): replace dashboard sessions with stateless tokens and session control (#6329)
* refactor(auth): replace dashboard sessions with stateless tokens

* feat(auth): harden session issuance and distributed enforcement

* fix(proxy): preserve trusted proxy compatibility defaults

* refactor: address dashboard auth review feedback

* refactor: remove classic frontend and flatten web app
2026-07-20 16:48:43 +08:00

224 lines
7.1 KiB
Go

package model
import (
"errors"
"sync/atomic"
"testing"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/alicebob/miniredis/v2"
"github.com/go-redis/redis/v8"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func useUserCacheMiniRedis(t *testing.T) *miniredis.Miniredis {
t.Helper()
server := miniredis.RunT(t)
oldRedisEnabled := common.RedisEnabled
oldRDB := common.RDB
oldSyncFrequency := common.SyncFrequency
common.RedisEnabled = true
common.SyncFrequency = 2
common.RDB = redis.NewClient(&redis.Options{Addr: server.Addr()})
t.Cleanup(func() {
_ = common.RDB.Close()
common.RedisEnabled = oldRedisEnabled
common.RDB = oldRDB
common.SyncFrequency = oldSyncFrequency
})
return server
}
func TestUserAuthFenceRollbackExpiresAndRecovers(t *testing.T) {
truncateTables(t)
server := useUserCacheMiniRedis(t)
user := User{
Username: "auth-fence-rollback",
Password: "password",
Role: common.RoleCommonUser,
Status: common.UserStatusEnabled,
Group: "default",
AuthVersion: 1,
}
require.NoError(t, DB.Create(&user).Error)
require.NoError(t, populateUserCache(user))
tx := DB.Begin()
require.NoError(t, tx.Error)
next, err := IncrementUserAuthVersionWithTx(tx, user.Id)
require.NoError(t, err)
assert.EqualValues(t, 2, next)
_, err = cacheGetUserBase(user.Id)
assert.ErrorIs(t, err, ErrUserAuthCachePending)
cacheTTL, err := common.RDB.TTL(t.Context(), getUserCacheKey(user.Id)).Result()
require.NoError(t, err)
fenceTTL, err := common.RDB.TTL(t.Context(), getUserAuthFenceKey(user.Id)).Result()
require.NoError(t, err)
assert.Greater(t, fenceTTL, cacheTTL)
require.NoError(t, tx.Rollback().Error)
server.FastForward(time.Duration(userAuthFenceTTLSeconds()+1) * time.Second)
assert.False(t, server.Exists(getUserAuthFenceKey(user.Id)))
committed, err := common.RDB.Get(t.Context(), getUserAuthVersionKey(user.Id)).Result()
require.NoError(t, err)
assert.Equal(t, "1", committed)
cached, err := GetUserCache(user.Id)
require.NoError(t, err)
assert.EqualValues(t, 1, cached.AuthVersion)
}
func TestPendingUserAuthFenceRejectsStaleCacheWrite(t *testing.T) {
server := useUserCacheMiniRedis(t)
const userID = 4201
require.NoError(t, SetUserAuthVersionFence(userID, 2))
err := writeUserCache(&UserBase{
Id: userID, Group: "default", Username: "stale", AuthVersion: 1,
}, true)
assert.ErrorIs(t, err, ErrUserAuthCachePending)
assert.False(t, server.Exists(getUserCacheKey(userID)))
}
func TestUserAuthFieldUpdateRejectsVersionMismatch(t *testing.T) {
useUserCacheMiniRedis(t)
const userID = 4202
require.NoError(t, writeUserCache(&UserBase{
Id: userID, Group: "current", Username: "cached", AuthVersion: 3,
}, true))
err := updateUserCacheFieldAtVersion(userID, "Group", "stale", 2)
assert.ErrorIs(t, err, ErrUserAuthCachePending)
group, err := common.RDB.HGet(t.Context(), getUserCacheKey(userID), "Group").Result()
require.NoError(t, err)
assert.Equal(t, "current", group)
}
func TestRefreshUserGroupCacheRepairsDelayedSameVersionWrite(t *testing.T) {
truncateTables(t)
useUserCacheMiniRedis(t)
user := User{
Username: "delayed-group-refresh",
Password: "password",
Role: common.RoleCommonUser,
Status: common.UserStatusEnabled,
Group: "default",
AuthVersion: 1,
}
require.NoError(t, DB.Create(&user).Error)
require.NoError(t, populateUserCache(user))
firstSnapshotRead := make(chan struct{})
releaseDelayedRefresh := make(chan struct{})
var intercepted atomic.Bool
const callbackName = "test:block_delayed_group_refresh"
require.NoError(t, DB.Callback().Query().After("gorm:query").Register(callbackName, func(*gorm.DB) {
if intercepted.CompareAndSwap(false, true) {
close(firstSnapshotRead)
<-releaseDelayedRefresh
}
}))
t.Cleanup(func() {
_ = DB.Callback().Query().Remove(callbackName)
})
delayedResult := make(chan error, 1)
go func() {
delayedResult <- RefreshUserGroupCache(user.Id)
}()
<-firstSnapshotRead
require.NoError(t, DB.Model(&User{}).Where("id = ?", user.Id).Update("group", "pro").Error)
require.NoError(t, RefreshUserGroupCache(user.Id))
cached, err := cacheGetUserBase(user.Id)
require.NoError(t, err)
assert.Equal(t, "pro", cached.Group)
assert.EqualValues(t, 1, cached.AuthVersion)
close(releaseDelayedRefresh)
require.NoError(t, <-delayedResult)
cached, err = cacheGetUserBase(user.Id)
require.NoError(t, err)
assert.Equal(t, "pro", cached.Group)
assert.EqualValues(t, 1, cached.AuthVersion)
}
func TestCommittedUserAuthVersionPermanentlyRejectsDelayedCacheFill(t *testing.T) {
truncateTables(t)
server := useUserCacheMiniRedis(t)
user := User{
Username: "auth-fence-commit",
Password: "password",
Role: common.RoleCommonUser,
Status: common.UserStatusEnabled,
Group: "default",
AuthVersion: 1,
}
require.NoError(t, DB.Create(&user).Error)
require.NoError(t, populateUserCache(user))
stale := *user.ToBaseUser()
require.NoError(t, DB.Transaction(func(tx *gorm.DB) error {
_, err := IncrementUserAuthVersionWithTx(tx, user.Id)
return err
}))
require.NoError(t, PublishUserAuthCache(user.Id))
assert.False(t, server.Exists(getUserAuthFenceKey(user.Id)))
committed, err := common.RDB.Get(t.Context(), getUserAuthVersionKey(user.Id)).Result()
require.NoError(t, err)
assert.Equal(t, "2", committed)
server.FastForward(time.Duration(userAuthFenceTTLSeconds()+1) * time.Second)
require.NoError(t, common.RedisDelKey(getUserCacheKey(user.Id)))
err = writeUserCache(&stale, true)
assert.True(t, errors.Is(err, ErrUserAuthCachePending))
committed, err = common.RDB.Get(t.Context(), getUserAuthVersionKey(user.Id)).Result()
require.NoError(t, err)
assert.Equal(t, "2", committed)
}
func TestUserAuthVersionFenceAndCommittedFloorAreMonotonic(t *testing.T) {
truncateTables(t)
server := useUserCacheMiniRedis(t)
const userID = 4101
require.NoError(t, SetUserAuthVersionFence(userID, 5))
require.NoError(t, SetUserAuthVersionFence(userID, 3))
pending, err := common.RDB.Get(t.Context(), getUserAuthFenceKey(userID)).Result()
require.NoError(t, err)
assert.Equal(t, "5", pending)
floor, err := getUserAuthVersionFloor(userID)
require.NoError(t, err)
assert.EqualValues(t, 5, floor)
// Committing an older transaction must neither clear a newer pending fence
// nor lower the effective deny floor.
require.NoError(t, publishCommittedUserAuthVersion(userID, 3))
pending, err = common.RDB.Get(t.Context(), getUserAuthFenceKey(userID)).Result()
require.NoError(t, err)
assert.Equal(t, "5", pending)
floor, err = getUserAuthVersionFloor(userID)
require.NoError(t, err)
assert.EqualValues(t, 5, floor)
require.NoError(t, publishCommittedUserAuthVersion(userID, 5))
assert.False(t, server.Exists(getUserAuthFenceKey(userID)))
committed, err := common.RDB.Get(t.Context(), getUserAuthVersionKey(userID)).Result()
require.NoError(t, err)
assert.Equal(t, "5", committed)
require.NoError(t, publishCommittedUserAuthVersion(userID, 4))
committed, err = common.RDB.Get(t.Context(), getUserAuthVersionKey(userID)).Result()
require.NoError(t, err)
assert.Equal(t, "5", committed)
}