package pub import ( "context" "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" "perms-system-server/internal/testutil" "testing" ) // TC-0838: 冻结用户 + 错误密码 —— 不得返回 403,必须与"用户不存在"合并为 401 func TestValidateProductLogin_FrozenWrongPassword_Return401(t *testing.T) { ctx := context.Background() svcCtx := newTestSvcCtx() svcCtx.UsernameLoginLimit = nil // 隔离本测试变量 username := "h2_frozen_wrong_" + testutil.UniqueId() // status=2(冻结),isSuperAdmin=2(非超管) _, clean := insertRefreshTestUser(t, ctx, username, "CorrectPass123", 2, 2) t.Cleanup(clean) _, err := ValidateProductLogin(ctx, svcCtx, username, "WrongPass", "test_product", "127.0.0.1") require.Error(t, err) var le *LoginError require.True(t, errors.As(err, &le)) assert.Equal(t, 401, le.Code, "冻结用户 + 错误密码不得返回 403;必须 401 与'用户不存在/密码错'三合一") assert.Equal(t, "用户名或密码错误", le.Message, "文案不得泄露冻结态") } // TC-0839: 冻结用户 + 正确密码 —— 此时才允许披露"账号已被冻结"(攻击者已经猜中密码,继续隐藏已无意义)。 func TestValidateProductLogin_FrozenCorrectPassword_Return403(t *testing.T) { ctx := context.Background() svcCtx := newTestSvcCtx() svcCtx.UsernameLoginLimit = nil username := "h2_frozen_right_" + testutil.UniqueId() _, clean := insertRefreshTestUser(t, ctx, username, "RightPass123", 2, 2) t.Cleanup(clean) _, err := ValidateProductLogin(ctx, svcCtx, username, "RightPass123", "test_product", "127.0.0.1") require.Error(t, err) var le *LoginError require.True(t, errors.As(err, &le)) assert.Equal(t, 403, le.Code, "密码正确后的冻结分支仍走 403 披露") assert.Equal(t, "账号已被冻结", le.Message) } // TC-0840: 超管走产品端 + 错误密码 —— 不得提前暴露"该账号是超管"。 func TestValidateProductLogin_SuperAdminWrongPassword_Return401(t *testing.T) { ctx := context.Background() svcCtx := newTestSvcCtx() svcCtx.UsernameLoginLimit = nil username := "h2_sa_wrong_" + testutil.UniqueId() // status=1(启用),isSuperAdmin=1(超管) _, clean := insertRefreshTestUser(t, ctx, username, "RightPass123", 1, 1) t.Cleanup(clean) _, err := ValidateProductLogin(ctx, svcCtx, username, "WrongPass", "test_product", "127.0.0.1") require.Error(t, err) var le *LoginError require.True(t, errors.As(err, &le)) assert.Equal(t, 401, le.Code, "超管 + 错误密码必须归一到 401'用户名或密码错误',不得提前披露超管身份") assert.Equal(t, "用户名或密码错误", le.Message) } // TC-0841: 超管走产品端 + 正确密码 —— 密码正确后才披露"超管请走管理后台"。 func TestValidateProductLogin_SuperAdminCorrectPassword_Return403(t *testing.T) { ctx := context.Background() svcCtx := newTestSvcCtx() svcCtx.UsernameLoginLimit = nil username := "h2_sa_right_" + testutil.UniqueId() _, clean := insertRefreshTestUser(t, ctx, username, "RightPass123", 1, 1) t.Cleanup(clean) _, err := ValidateProductLogin(ctx, svcCtx, username, "RightPass123", "test_product", "127.0.0.1") require.Error(t, err) var le *LoginError require.True(t, errors.As(err, &le)) assert.Equal(t, 403, le.Code) assert.Equal(t, "超级管理员不允许通过产品端登录,请使用管理后台", le.Message) } // TC-0842: 用户名不存在 —— 沿用 dummy bcrypt 恒时对齐,文案必须与 TC-0838 完全一致。 // 这条是三重 oracle 消除的"对照组",缺了它单看 0838/0840 还不能证明"三路归一"。 func TestValidateProductLogin_UnknownUserSame401(t *testing.T) { ctx := context.Background() svcCtx := newTestSvcCtx() svcCtx.UsernameLoginLimit = nil _, err := ValidateProductLogin(ctx, svcCtx, "h2_noexist_"+testutil.UniqueId(), "anypwd", "test_product", "127.0.0.1") require.Error(t, err) var le *LoginError require.True(t, errors.As(err, &le)) assert.Equal(t, 401, le.Code) assert.Equal(t, "用户名或密码错误", le.Message, "未知用户 / 冻结+错密 / 存在+错密 三路必须归一为同一 401 + 文案") } // TC-0751: 用户名不存在 + 任意密码,不得暴露差异化文案 func TestValidateProductLogin_UnknownUserSameError(t *testing.T) { ctx := context.Background() svcCtx := newTestSvcCtx() username := "enum_unknown_" + testutil.UniqueId() _, err := ValidateProductLogin(ctx, svcCtx, username, "random-pw", "test_product", "127.0.0.1") require.Error(t, err) var le *LoginError require.True(t, errors.As(err, &le)) assert.Equal(t, 401, le.Code) assert.Equal(t, "用户名或密码错误", le.Message, "M-C:不存在用户名不得暴露差异化文案") } // TC-0752: -C 修复回归 —— 存在用户名但密码错,返回相同文案相同 code,供与 TC-0751 做对照。 func TestValidateProductLogin_KnownUserWrongPwd(t *testing.T) { ctx := context.Background() svcCtx := newTestSvcCtx() username := "enum_known_" + testutil.UniqueId() userId, cleanUser := insertRefreshTestUser(t, ctx, username, "RightPass123", 1, 2) t.Cleanup(cleanUser) _ = userId _, err := ValidateProductLogin(ctx, svcCtx, username, "wrong-pw", "test_product", "127.0.0.1") require.Error(t, err) var le *LoginError require.True(t, errors.As(err, &le)) assert.Equal(t, 401, le.Code, "M-C:Code 必须与未知用户完全一致") assert.Equal(t, "用户名或密码错误", le.Message, "M-C:文案必须与未知用户完全一致") } // TC-0753: -C 修复回归 —— UsernameLoginLimit 的 key 必须按 ip:username 构造。 // 同一 username 不同 IP 的配额互不共用,防止攻击者"用任意 IP 打爆某账号"导致账号 DoS。 func TestValidateProductLogin_RateLimitKeyedByIPAndUsername(t *testing.T) { ctx := context.Background() svcCtx := newTestSvcCtx() // 使用独立的 quota=1 limiter cfg := testutil.GetTestConfig() rds := redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf) svcCtx.UsernameLoginLimit = limit.NewPeriodLimit(300, 1, rds, cfg.CacheRedis.KeyPrefix+":rl:userlogin:ut:"+testutil.UniqueId()) username := "enum_rl_" + testutil.UniqueId() // IP-A 第 1 次:"用户名或密码错误" _, err := ValidateProductLogin(ctx, svcCtx, username, "x", "test_product", "1.1.1.1") require.Error(t, err) var le *LoginError require.True(t, errors.As(err, &le)) assert.Equal(t, 401, le.Code) // IP-A 第 2 次:超限 429 _, err = ValidateProductLogin(ctx, svcCtx, username, "x", "test_product", "1.1.1.1") require.Error(t, err) require.True(t, errors.As(err, &le)) assert.Equal(t, 429, le.Code, "M-C:同 IP 同 username 第 2 次必须触发 429") // IP-B 第 1 次:独立桶,仍应走到密码校验(不是 429) _, err = ValidateProductLogin(ctx, svcCtx, username, "x", "test_product", "2.2.2.2") require.Error(t, err) require.True(t, errors.As(err, &le)) assert.Equal(t, 401, le.Code, "M-C:不同 IP 的同 username 必须走独立限流桶(不是 429)") }