c9e3664d68
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>
161 lines
6.3 KiB
Go
161 lines
6.3 KiB
Go
package service
|
||
|
||
// 会话配额三项修复的回归测试(2026-07-04 S003 登录锁死事故):
|
||
// ① 过期未清理的会话不再占配额;② 同设备重登吊销旧会话、不叠占;
|
||
// ③ 配额跟套餐档位走(有效授权 max_devices,floor 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)
|
||
|
||
// 首登自动发 trial(max_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)
|
||
}
|