package auth import ( "context" "database/sql" "errors" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/zeromicro/go-zero/core/limit" "github.com/zeromicro/go-zero/core/stores/redis" "go.uber.org/mock/gomock" "perms-system-server/internal/loaders" "perms-system-server/internal/middleware" userModel "perms-system-server/internal/model/user" "perms-system-server/internal/response" "perms-system-server/internal/svc" "perms-system-server/internal/testutil" "perms-system-server/internal/testutil/mocks" "testing" "time" ) func TestLogout_Normal_IncrementsTokenVersion(t *testing.T) { ctx := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() now := time.Now().Unix() username := testutil.UniqueId() res, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{ Username: username, Password: testutil.HashPassword("pw"), Nickname: "lg", Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2, Status: 1, TokenVersion: 0, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) userId, _ := res.LastInsertId() t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) }) ud, err := svcCtx.UserDetailsLoader.Load(ctx, userId, "") require.NoError(t, err, "正常用户 Load 应当 (*UserDetails, nil)") require.NotNil(t, ud) assert.Equal(t, int64(0), ud.TokenVersion) lctx := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{ UserId: userId, Username: username, Status: 1, IsSuperAdmin: false, }) logic := NewLogoutLogic(lctx, svcCtx) require.NoError(t, logic.Logout()) u, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) assert.Equal(t, int64(1), u.TokenVersion, "logout 必须递增 tokenVersion") ud2, err := svcCtx.UserDetailsLoader.Load(ctx, userId, "") require.NoError(t, err) require.NotNil(t, ud2) assert.Equal(t, int64(1), ud2.TokenVersion, "clean 后重新 Load 应拿到最新 TokenVersion") } // TC-0721: Logout 未登录返回 401 func TestLogout_Unauthorized(t *testing.T) { svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) logic := NewLogoutLogic(context.Background(), svcCtx) err := logic.Logout() require.Error(t, err) var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 401, ce.Code()) assert.Equal(t, "未登录", ce.Error()) } // TC-0722: Logout 连续两次:tokenVersion 累加 func TestLogout_TwiceAccumulates(t *testing.T) { ctx := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() now := time.Now().Unix() username := testutil.UniqueId() res, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{ Username: username, Password: testutil.HashPassword("pw"), Nickname: "lg2", Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2, Status: 1, TokenVersion: 0, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) userId, _ := res.LastInsertId() t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) }) lctx := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{ UserId: userId, Username: username, Status: 1, }) require.NoError(t, NewLogoutLogic(lctx, svcCtx).Logout()) require.NoError(t, NewLogoutLogic(lctx, svcCtx).Logout()) u, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) assert.Equal(t, int64(2), u.TokenVersion) } func TestLogout_TokenOpLimiter_BlocksThirdCall(t *testing.T) { ctx := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() now := time.Now().Unix() username := "lg_rl_" + testutil.UniqueId() res, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{ Username: username, Password: testutil.HashPassword("pw"), Nickname: "lg_rl", Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2, Status: 1, TokenVersion: 0, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) userId, _ := res.LastInsertId() t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) }) cfg := testutil.GetTestConfig() rds := redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf) // 独立 prefix 保证与全局 limiter 桶互不干扰,也避免用例互相污染 svcCtx.TokenOpLimiter = limit.NewPeriodLimit(60, 2, rds, cfg.CacheRedis.KeyPrefix+":rl:logout:ut:"+testutil.UniqueId()) lctx := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{ UserId: userId, Username: username, Status: 1, }) require.NoError(t, NewLogoutLogic(lctx, svcCtx).Logout(), "第 1 次 logout 应放行") require.NoError(t, NewLogoutLogic(lctx, svcCtx).Logout(), "第 2 次 logout 仍在配额内应放行") err = NewLogoutLogic(lctx, svcCtx).Logout() require.Error(t, err, "第 3 次必须被 TokenOpLimiter 拦截") var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 429, ce.Code()) assert.Contains(t, ce.Error(), "过于频繁") u, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) assert.Equal(t, int64(2), u.TokenVersion, "被限流的 logout 请求绝不能再触发 IncrementTokenVersion(否则攻击者可反复刷新缓存)") } // TC-0740: -C 修复 —— 限流 key 必须按 userId 隔离,A 用户打满不得影响 B 用户。 func TestLogout_TokenOpLimiter_PerUserIsolated(t *testing.T) { ctx := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() now := time.Now().Unix() mkUser := func(tag string) int64 { res, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{ Username: "lg_iso_" + tag + "_" + testutil.UniqueId(), Password: testutil.HashPassword("pw"), Nickname: "lg_iso", Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2, Status: 1, TokenVersion: 0, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) id, _ := res.LastInsertId() return id } aId := mkUser("a") bId := mkUser("b") t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", aId, bId) }) cfg := testutil.GetTestConfig() rds := redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf) svcCtx.TokenOpLimiter = limit.NewPeriodLimit(60, 1, rds, cfg.CacheRedis.KeyPrefix+":rl:logout:iso:"+testutil.UniqueId()) lctxA := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{UserId: aId, Status: 1}) lctxB := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{UserId: bId, Status: 1}) require.NoError(t, NewLogoutLogic(lctxA, svcCtx).Logout()) // A 打满后再打一次 err := NewLogoutLogic(lctxA, svcCtx).Logout() require.Error(t, err) var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 429, ce.Code()) require.NoError(t, NewLogoutLogic(lctxB, svcCtx).Logout(), "B 用户应当仍有独立配额,不被 A 用户的限流影响") } // TC-0790: -C 修复延伸 —— TokenOpLimiter 是 period 滚动窗口,配额打满后在窗口结束时必须自动恢复。 // 用 period=1 秒、quota=1 的 limiter 打满后: (1) 第 2 次立即调用被拒 (2) sleep >1s 窗口滑过后第 3 次放行。 // 这挡住了"限流误成了永久 deny" 的实现退化 (例如错用了 TokenBucket 但不补齐, 或 Redis key 设成永不过期)。 func TestLogout_TokenOpLimiter_WindowRecovers(t *testing.T) { ctx := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() now := time.Now().Unix() res, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{ Username: "lg_win_" + testutil.UniqueId(), Password: testutil.HashPassword("pw"), Nickname: "lg_win", Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2, Status: 1, TokenVersion: 0, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) userId, _ := res.LastInsertId() t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) }) cfg := testutil.GetTestConfig() rds := redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf) svcCtx.TokenOpLimiter = limit.NewPeriodLimit(1, 1, rds, cfg.CacheRedis.KeyPrefix+":rl:logout:win:"+testutil.UniqueId()) lctx := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{ UserId: userId, Status: 1, }) require.NoError(t, NewLogoutLogic(lctx, svcCtx).Logout(), "第 1 次 logout 放行") err = NewLogoutLogic(lctx, svcCtx).Logout() require.Error(t, err, "同一窗口内第 2 次必须 429") var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 429, ce.Code()) // 等窗口滚过 (period=1s, 多等 200ms 余量) time.Sleep(1200 * time.Millisecond) require.NoError(t, NewLogoutLogic(lctx, svcCtx).Logout(), "窗口滚过后配额必须自动恢复;若此处 429, 说明限流从滚动窗口退化成了永久封锁") u, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) assert.Equal(t, int64(2), u.TokenVersion, "恢复后的 logout 必须真正进入业务层递增 tokenVersion (= 1 窗口第一次 + 2 窗口第一次)") } // TC-0791: -C 修复延伸 —— 当 Redis 不可达时 limit.PeriodLimit.Take 返回错误。 // 生产代码使用 `code, _ := ...; if code == OverQuota` 模式, 即 Redis 宕机时 **fail-OPEN**: 仍允许登出。 // 这是工程取舍 —— 登出是"用户体验优先"的操作, 拒绝登出比放行更糟。本用例冻结此契约, // 未来若有人改成 fail-CLOSE (default deny) 必须在 code review 明确讨论, 不应静默发生。 func TestLogout_FailOpenWhenLimiterUnreachable(t *testing.T) { ctx := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() now := time.Now().Unix() res, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{ Username: "lg_down_" + testutil.UniqueId(), Password: testutil.HashPassword("pw"), Nickname: "lg_down", Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2, Status: 1, TokenVersion: 0, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) userId, _ := res.LastInsertId() t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) }) // 指向一个不可达的 redis 端口 (127.0.0.1:1 保证拨号失败), NonBlock=true 跳过启动 ping, // 否则 NewRedis 自身会在构造期就返回错误, 测不到"运行期 Take 失败"的分支。 badRds, err := redis.NewRedis(redis.RedisConf{ Host: "127.0.0.1:1", Type: "node", NonBlock: true, PingTimeout: 100 * time.Millisecond, }) require.NoError(t, err) svcCtx.TokenOpLimiter = limit.NewPeriodLimit(60, 1, badRds, "perms:test:rl:logout:down:"+testutil.UniqueId()) lctx := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{ UserId: userId, Status: 1, }) // Redis 不可达 → limit.Take 返回 err, code 非 OverQuota → 放行业务 require.NoError(t, NewLogoutLogic(lctx, svcCtx).Logout(), "Redis 宕机时 logout 必须 fail-OPEN 放行 (业务层应正常递增 tokenVersion)") u, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) assert.Equal(t, int64(1), u.TokenVersion, "fail-OPEN 放行后必须真正执行 IncrementTokenVersion, 否则是 fail-CLOSE 伪装") } func TestLogout_ForwardsUsername_NoInternalFindOne(t *testing.T) { ctrl := gomock.NewController(t) t.Cleanup(ctrl.Finish) const userId = int64(7777) const username = "m_r11_2_subject" mockUser := mocks.NewMockSysUserModel(ctrl) // 契约:username 参数必须与 ud.Username 字面一致。 mockUser.EXPECT(). IncrementTokenVersion(gomock.Any(), userId, username). Return(int64(1), nil) // 不允许再出现任何 FindOne / FindOneByUsername 的调用(gomock 默认就会对未声明的调用 fail)。 svcCtx := mocks.NewMockServiceContext(mocks.MockModels{User: mockUser}) ctx := middleware.WithUserDetails(t.Context(), &loaders.UserDetails{ UserId: userId, Username: username, Status: 1, }) require.NoError(t, NewLogoutLogic(ctx, svcCtx).Logout(), "Logout 正常路径应通过,且必须按签名字面透传 username") } // TC-1048: Logout 在 Username 为 "" 的极端场景下仍然透传空串(不隐式 FindOne 修补) // 该契约保证 Model 层对 "调用方未提供 username" 的场景**不做缓存键兜底**; // 若 DEV 回退成 Model 内部 FindOne,行为会变:Logic 传 "" 进来时 Model 会真的去 DB 查一次。 func TestLogout_EmptyUsernameStillForwarded(t *testing.T) { ctrl := gomock.NewController(t) t.Cleanup(ctrl.Finish) const userId = int64(8888) mockUser := mocks.NewMockSysUserModel(ctrl) mockUser.EXPECT(). IncrementTokenVersion(gomock.Any(), userId, ""). // 严格 "" 不是 gomock.Any() Return(int64(3), nil) svcCtx := mocks.NewMockServiceContext(mocks.MockModels{User: mockUser}) ctx := middleware.WithUserDetails(t.Context(), &loaders.UserDetails{ UserId: userId, Username: "", Status: 1, }) require.NoError(t, NewLogoutLogic(ctx, svcCtx).Logout()) }