Files
jiu/backend/internal/service/session_quota_test.go
T
wangjia c9e3664d68 fix(backend): 登录设备配额三修复——过期会话不占坑/同设备重登不叠加/配额跟套餐档位
S003 线上登录锁死根因修复:
- 活跃会话统计排除 refresh 已过期的行(此前过期未清理的会话把配额占满,
  正常用户被锁在门外,需等 cleanup retention 天后才释放)
- 同 shop+user+device_id 重登先吊销旧会话(reason=relogin),重装/重复登录
  不再叠占配额
- effectiveTotalQuota 接线套餐档位:session_policy 运维覆盖 > 有效授权
  max_devices(标准 2/高级 5,floor 2 兜底 trial=1)> 全局默认 5(无有效授权)

test(backend): session_quota_test.go 五用例覆盖三分支 + policy 优先级 + trial 兜底

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 13:46:59 +08:00

161 lines
6.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
// 会话配额三项修复的回归测试(2026-07-04 S003 登录锁死事故):
// ① 过期未清理的会话不再占配额;② 同设备重登吊销旧会话、不叠占;
// ③ 配额跟套餐档位走(有效授权 max_devicesfloor 2;运维 session_policy 最优先)。
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/wangjia/jiu/backend/config"
"github.com/wangjia/jiu/backend/internal/model"
"github.com/wangjia/jiu/backend/testutil"
)
// 过期会话不占坑:配额被「refresh 已过期但未撤销」的行占满时,登录应放行。
func TestLogin_ExpiredSessionNotCounted(t *testing.T) {
db := testutil.SetupTestDB()
old := config.C.Session.LimitTotal
config.C.Session.LimitTotal = 2
defer func() { config.C.Session.LimitTotal = old }()
shop := testutil.CreateTestShop(db, "QUOTA1")
user := testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
svc := NewAuthService(db)
// 直接写入 2 条已过期(refresh_exp_at 在过去)但 revoked_at 为空的会话
past := time.Now().Add(-time.Hour)
for _, sid := range []string{"stale-1", "stale-2"} {
require.NoError(t, db.Create(&model.UserSession{
ShopID: shop.ID, UserID: user.ID, SID: sid,
DeviceID: sid, Platform: "macos", PlatformClass: "desktop",
RefreshExpAt: past,
}).Error)
}
_, _, err := svc.Login("QUOTA1", "admin", "password123",
DeviceInfo{DeviceID: "fresh-dev", Platform: "ios"})
assert.NoError(t, err, "过期会话不应计入配额")
}
// 同设备重登:旧会话被吊销(reason=relogin),配额只占一个坑。
func TestLogin_SameDeviceReloginReusesSlot(t *testing.T) {
db := testutil.SetupTestDB()
old := config.C.Session.LimitTotal
config.C.Session.LimitTotal = 2
defer func() { config.C.Session.LimitTotal = old }()
shop := testutil.CreateTestShop(db, "QUOTA2")
testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
svc := NewAuthService(db)
dev := DeviceInfo{DeviceID: "iphone-1", Platform: "ios"}
_, _, err := svc.Login("QUOTA2", "admin", "password123", dev)
require.NoError(t, err)
// 同一设备再登两次都应成功(每次吊销上一条,不叠加)
_, _, err = svc.Login("QUOTA2", "admin", "password123", dev)
require.NoError(t, err)
_, _, err = svc.Login("QUOTA2", "admin", "password123", dev)
require.NoError(t, err)
var active int64
db.Model(&model.UserSession{}).
Where("shop_id = ? AND device_id = ? AND revoked_at IS NULL", shop.ID, "iphone-1").
Count(&active)
assert.Equal(t, int64(1), active, "同设备只保留一条活跃会话")
var relogin int64
db.Model(&model.UserSession{}).
Where("shop_id = ? AND revoked_reason = ?", shop.ID, "relogin").
Count(&relogin)
assert.Equal(t, int64(2), relogin)
// 另一台设备占第 2 坑成功,第 3 台被拒
_, _, err = svc.Login("QUOTA2", "admin", "password123",
DeviceInfo{DeviceID: "mac-1", Platform: "macos"})
require.NoError(t, err)
_, _, err = svc.Login("QUOTA2", "admin", "password123",
DeviceInfo{DeviceID: "web-1", Platform: "web"})
assert.ErrorIs(t, err, ErrDeviceLimitReached)
}
// 配额跟套餐走:有效授权 max_devices=2(标准版)时,即使全局默认 5,第 3 台设备被拒。
func TestLogin_QuotaFollowsLicenseMaxDevices(t *testing.T) {
db := testutil.SetupTestDB()
old := config.C.Session.LimitTotal
config.C.Session.LimitTotal = 5
defer func() { config.C.Session.LimitTotal = old }()
shop := testutil.CreateTestShop(db, "QUOTA3")
testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
// 预置有效标准版授权(max_devices=2),登录时不再触发 auto-trial
future := time.Now().Add(30 * 24 * time.Hour)
require.NoError(t, db.Create(&model.License{
ShopID: shop.ID, LicenseKey: "STD-QUOTA3", Type: "annual", Tier: "standard",
ExpiresAt: &future, IsActive: true, MaxDevices: 2,
}).Error)
svc := NewAuthService(db)
_, _, err := svc.Login("QUOTA3", "admin", "password123",
DeviceInfo{DeviceID: "d1", Platform: "macos"})
require.NoError(t, err)
_, _, err = svc.Login("QUOTA3", "admin", "password123",
DeviceInfo{DeviceID: "d2", Platform: "ios"})
require.NoError(t, err)
_, _, err = svc.Login("QUOTA3", "admin", "password123",
DeviceInfo{DeviceID: "d3", Platform: "web"})
assert.ErrorIs(t, err, ErrDeviceLimitReached)
}
// 试用授权 max_devices=1 → floor 2 兜底:第二台设备仍可登录,第三台被拒。
func TestLogin_TrialQuotaFloorTwo(t *testing.T) {
db := testutil.SetupTestDB()
old := config.C.Session.LimitTotal
config.C.Session.LimitTotal = 5
defer func() { config.C.Session.LimitTotal = old }()
shop := testutil.CreateTestShop(db, "QUOTA4")
testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
svc := NewAuthService(db)
// 首登自动发 trialmax_devices=1)→ 配额兜底 2
_, _, err := svc.Login("QUOTA4", "admin", "password123",
DeviceInfo{DeviceID: "d1", Platform: "macos"})
require.NoError(t, err)
_, _, err = svc.Login("QUOTA4", "admin", "password123",
DeviceInfo{DeviceID: "d2", Platform: "ios"})
require.NoError(t, err, "trial 兜底 2 台")
_, _, err = svc.Login("QUOTA4", "admin", "password123",
DeviceInfo{DeviceID: "d3", Platform: "web"})
assert.ErrorIs(t, err, ErrDeviceLimitReached)
}
// 运维 session_policy.total 覆盖最优先:高于授权档位也生效。
func TestLogin_PolicyOverridesLicenseQuota(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "QUOTA5")
testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
future := time.Now().Add(30 * 24 * time.Hour)
require.NoError(t, db.Create(&model.License{
ShopID: shop.ID, LicenseKey: "STD-QUOTA5", Type: "annual", Tier: "standard",
ExpiresAt: &future, IsActive: true, MaxDevices: 2,
}).Error)
// 运维放宽到 3
require.NoError(t, db.Model(&model.Shop{}).Where("id = ?", shop.ID).
Update("custom_fields", model.JSON{"session_policy": map[string]interface{}{"total": 3}}).Error)
svc := NewAuthService(db)
for i, d := range []string{"d1", "d2", "d3"} {
_, _, err := svc.Login("QUOTA5", "admin", "password123",
DeviceInfo{DeviceID: d, Platform: "macos"})
require.NoError(t, err, "第 %d 台应放行(policy total=3", i+1)
}
_, _, err := svc.Login("QUOTA5", "admin", "password123",
DeviceInfo{DeviceID: "d4", Platform: "ios"})
assert.ErrorIs(t, err, ErrDeviceLimitReached)
}