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) }