package server import ( "context" "testing" "perms-system-server/internal/svc" "perms-system-server/internal/testutil" "perms-system-server/pb" "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" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) // --------------------------------------------------------------------------- // 覆盖目标:审计 M-R11-1 —— gRPC SyncPermissions / GetUserPerms 入口缺少限流。 // 修复后: // SyncPermissions:按 appKey 维度做 GrpcSyncLimiter 入口限流,超限 ResourceExhausted; // 限流在 bcrypt.Compare(appSecret) / LockByCodeTx 之前执行,避免 // 恶意重放以高 CPU / 事务级 X 锁打满。 // GetUserPerms :按 appKey + 源 IP 双维度做 GrpcGetUserPermsLimiter 限流,任一桶超限 // 都拒绝;防止合法 appKey 泄漏后遍历 userId 或多实例 DDoS 放大。 // // 这里每个契约都做两件事: // 1) 把限流上限调到 quota=1 并观察第 2 次请求必是 ResourceExhausted; // 2) 提供"第 3 次换一个完全不相关的桶键"必须放行,证明限流口径是**按 key**隔离的, // 不是简单全局计数。 // --------------------------------------------------------------------------- // TC-1052: M-R11-1 —— SyncPermissions 的 appKey 维度限流 func TestGrpcSyncPermissions_AppKeyRateLimit(t *testing.T) { ctx := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) cfg := testutil.GetTestConfig() rds := redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf) svcCtx.GrpcSyncLimiter = limit.NewPeriodLimit( 60, 1, rds, cfg.CacheRedis.KeyPrefix+":rl:grpc:sync:ut:"+testutil.UniqueId()) srv := NewPermServer(svcCtx) // 同一 appKey 的第 1 次:limiter 放行,业务层因 appKey 非法走 Unauthenticated。 appKey := "unknown_" + testutil.UniqueId() _, err1 := srv.SyncPermissions(ctx, &pb.SyncPermissionsReq{ AppKey: appKey, AppSecret: "anything", Perms: []*pb.PermItem{{Code: "p.a", Name: "A"}}, }) require.Error(t, err1) st1, _ := status.FromError(err1) assert.Equal(t, codes.Unauthenticated, st1.Code(), "首次 limiter 放行,业务应因 appKey 不存在 Unauthenticated,非 ResourceExhausted") // 同一 appKey 的第 2 次:必是 ResourceExhausted。 _, err2 := srv.SyncPermissions(ctx, &pb.SyncPermissionsReq{ AppKey: appKey, AppSecret: "whatever", Perms: []*pb.PermItem{{Code: "p.b", Name: "B"}}, }) require.Error(t, err2) st2, _ := status.FromError(err2) assert.Equal(t, codes.ResourceExhausted, st2.Code(), "M-R11-1:同 appKey 达到配额必须 ResourceExhausted;严禁恶意方反复重放触发 bcrypt / X 锁") assert.Contains(t, st2.Message(), "过于频繁") // 另一 appKey 放行:证明 limiter 按 appKey 隔离,不是全局计数器。 otherKey := "unknown_other_" + testutil.UniqueId() _, err3 := srv.SyncPermissions(ctx, &pb.SyncPermissionsReq{ AppKey: otherKey, AppSecret: "whatever", Perms: []*pb.PermItem{{Code: "p.c", Name: "C"}}, }) require.Error(t, err3) st3, _ := status.FromError(err3) assert.Equal(t, codes.Unauthenticated, st3.Code(), "M-R11-1:limiter 桶键形如 'grpc:sync:',不同 appKey 互不串扰") } // TC-1053: M-R11-1 —— SyncPermissions 空 AppKey 不消耗 limiter 配额 // 代码里 `if req.AppKey != "" { Take(...) }` 的两层防护: // 1) 恶意方用空串连续打,不会把 limiter key space 膨胀为一个永不过期的"空串大桶"; // 2) 业务层统一由 FindOneByAppKey("") 命中 ErrNotFound 返回 Unauthenticated。 // 契约:空 AppKey 连打 3 次后,quota=1 的 limiter 仍然是**全新**状态;任意新 appKey 的第一次 // 请求必须走业务层(Unauthenticated),绝不允许被 ResourceExhausted 截断。 func TestGrpcSyncPermissions_EmptyAppKeyDoesNotConsumeQuota(t *testing.T) { ctx := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) cfg := testutil.GetTestConfig() rds := redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf) svcCtx.GrpcSyncLimiter = limit.NewPeriodLimit( 60, 1, rds, cfg.CacheRedis.KeyPrefix+":rl:grpc:sync:empty:"+testutil.UniqueId()) srv := NewPermServer(svcCtx) for i := 0; i < 3; i++ { _, err := srv.SyncPermissions(ctx, &pb.SyncPermissionsReq{ AppKey: "", AppSecret: "x", Perms: []*pb.PermItem{{Code: "p", Name: "n"}}, }) require.Error(t, err) st, _ := status.FromError(err) assert.Equal(t, codes.Unauthenticated, st.Code(), "空 AppKey 走 FindOneByAppKey('') → Unauthenticated;此路径不得触达 limiter") } // 真实新 AppKey 的第 1 次请求必须得到业务层的 Unauthenticated, // 而不是因"空串占用配额"退化出的 ResourceExhausted。 realKey := "sync_empty_probe_" + testutil.UniqueId() _, err := srv.SyncPermissions(ctx, &pb.SyncPermissionsReq{ AppKey: realKey, AppSecret: "x", Perms: []*pb.PermItem{{Code: "p", Name: "n"}}, }) require.Error(t, err) st, _ := status.FromError(err) assert.Equal(t, codes.Unauthenticated, st.Code(), "M-R11-1:空 AppKey 不消耗 limiter 配额;若这里返回 ResourceExhausted 则说明"+ "空串也被计数,`req.AppKey != \"\"` 前置分支缺失或被回退") } // TC-1054: M-R11-1 —— GetUserPerms 的 appKey 维度限流 func TestGrpcGetUserPerms_AppKeyRateLimit(t *testing.T) { ctx := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) cfg := testutil.GetTestConfig() rds := redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf) svcCtx.GrpcGetUserPermsLimiter = limit.NewPeriodLimit( 60, 1, rds, cfg.CacheRedis.KeyPrefix+":rl:grpc:perms:ut:"+testutil.UniqueId()) srv := NewPermServer(svcCtx) appKey := "perms_ak_" + testutil.UniqueId() ctx1 := withPeerIP(ctx, "172.31.0.10:40001") _, err1 := srv.GetUserPerms(ctx1, &pb.GetUserPermsReq{ AppKey: appKey, AppSecret: "x", ProductCode: "test_product", UserId: 1, }) require.Error(t, err1) st1, _ := status.FromError(err1) assert.Equal(t, codes.Unauthenticated, st1.Code(), "首次放行,业务层应因 appKey 不存在 Unauthenticated") // 同 appKey 第二次:appKey 桶即告罄。 ctx2 := withPeerIP(ctx, "172.31.0.11:40002") // 换 IP,证明拦的是 appKey 桶而不是 IP 桶 _, err2 := srv.GetUserPerms(ctx2, &pb.GetUserPermsReq{ AppKey: appKey, AppSecret: "x", ProductCode: "test_product", UserId: 2, }) require.Error(t, err2) st2, _ := status.FromError(err2) assert.Equal(t, codes.ResourceExhausted, st2.Code(), "M-R11-1:同 appKey 达到 appKey 维度配额,必须 ResourceExhausted") } // TC-1055: M-R11-1 —— GetUserPerms 的 IP 维度限流 // 双维度叠加意味着:若 appKey 维度没爆但 IP 维度爆了,同样必须拒绝。 // 这里用两个不同 appKey(消耗两份 appKey 配额,各占 1 个)但共用同一源 IP, // 第 2 次因为 IP 桶也只剩 1 个配额而必定 ResourceExhausted。 func TestGrpcGetUserPerms_IPRateLimit_OrthogonalToAppKey(t *testing.T) { ctx := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) cfg := testutil.GetTestConfig() rds := redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf) // quota=1:一次调用会消耗 appKey 桶 + IP 桶 各 1 个; // 第 2 次用"新 appKey"但"同 IP",appKey 桶还够,IP 桶已见底 → IP 桶拒绝。 svcCtx.GrpcGetUserPermsLimiter = limit.NewPeriodLimit( 60, 1, rds, cfg.CacheRedis.KeyPrefix+":rl:grpc:perms:ip:"+testutil.UniqueId()) srv := NewPermServer(svcCtx) appKeyA := "perms_ak_a_" + testutil.UniqueId() appKeyB := "perms_ak_b_" + testutil.UniqueId() ctxSameIP1 := withPeerIP(ctx, "198.51.100.7:50001") ctxSameIP2 := withPeerIP(ctx, "198.51.100.7:50002") // 同 IP 不同端口 _, err1 := srv.GetUserPerms(ctxSameIP1, &pb.GetUserPermsReq{ AppKey: appKeyA, AppSecret: "x", ProductCode: "test_product", UserId: 1, }) require.Error(t, err1) st1, _ := status.FromError(err1) assert.Equal(t, codes.Unauthenticated, st1.Code(), "首次放行(appKey 桶 + IP 桶各耗 1 个)") // 第 2 次:appKey 不同(appKey 桶还有配额),但同 IP 的 IP 桶已耗尽。 _, err2 := srv.GetUserPerms(ctxSameIP2, &pb.GetUserPermsReq{ AppKey: appKeyB, AppSecret: "x", ProductCode: "test_product", UserId: 2, }) require.Error(t, err2) st2, _ := status.FromError(err2) assert.Equal(t, codes.ResourceExhausted, st2.Code(), "M-R11-1:appKey 桶有余但 IP 桶已爆,必须 ResourceExhausted;双维度是'谁先爆谁拒'") }