| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189 |
- 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>',不同 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;双维度是'谁先爆谁拒'")
- }
|