package product import ( "context" "encoding/json" "errors" "fmt" "sync" "sync/atomic" "testing" "perms-system-server/internal/response" "perms-system-server/internal/svc" "perms-system-server/internal/testutil" "perms-system-server/internal/testutil/ctxhelper" "perms-system-server/internal/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // --------------------------------------------------------------------------- // 覆盖目标:审计 M-4 —— CreateProduct 不再明文回吐 appSecret / adminPassword, // 改为发放一次性 credentialsTicket,调用方再凭 ticket 调 FetchInitialCredentials 领取。 // 安全契约必须钉死: // 1) 只有超管能消费 ticket(非超管必须 403); // 2) 必须一次性消费(consumed 后再次消费必须 400); // 3) 错误/过期 ticket 必须 400; // 4) 空 ticket 必须 400; // 5) 并发消费同一 ticket 时,有且仅有一个请求能拿到明文(GetDelCtx 原子性); // 6) Redis 中落盘的 value 必须是结构化 JSON,而不是裸明文(便于未来加密 / schema 演进); // 7) FetchInitialCredentialsResp 必须暴露 appSecret / adminPassword / appKey / adminUser。 // --------------------------------------------------------------------------- // TC-0901: FetchInitialCredentials 正常路径 —— 用 CreateProduct 返回的 ticket 领取凭证。 func TestFetchInitialCredentials_HappyPath(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() code := testutil.UniqueId() createResp, err := NewCreateProductLogic(ctx, svcCtx).CreateProduct(&types.CreateProductReq{ Code: code, Name: "tic_ok", Remark: "", AdminDeptId: seedAdminDept(t, ctx, svcCtx), }) require.NoError(t, err) require.NotNil(t, createResp) require.NotEmpty(t, createResp.CredentialsTicket) t.Cleanup(func() { testutil.CleanTableByField(ctx, conn, "`sys_product_member`", "productCode", code) testutil.CleanTableByField(ctx, conn, "`sys_user`", "username", "admin_"+code) testutil.CleanTable(ctx, conn, "`sys_product`", createResp.Id) _, _ = svcCtx.Redis.DelCtx(ctx, initialCredentialsKeyPrefix+createResp.CredentialsTicket) }) cred, err := NewFetchInitialCredentialsLogic(ctx, svcCtx).FetchInitialCredentials( &types.FetchInitialCredentialsReq{Ticket: createResp.CredentialsTicket}, ) require.NoError(t, err) require.NotNil(t, cred) assert.Equal(t, createResp.AppKey, cred.AppKey, "appKey 必须与 CreateProduct 响应一致") assert.Equal(t, createResp.AdminUser, cred.AdminUser, "adminUser 必须与 CreateProduct 响应一致") assert.NotEmpty(t, cred.AppSecret, "必须返回明文 appSecret") assert.NotEmpty(t, cred.AdminPassword, "必须返回明文 adminPassword") // 基础合理性:32 字节 hex = 64 字符;审计 L-R10-2 改为混合字符集强密码,长度固定 16。 assert.Len(t, cred.AppSecret, 64, "appSecret 必须是 32 字节 hex") assert.Len(t, cred.AdminPassword, 16, "L-R10-2:adminPassword 改由 generateStrongInitialPassword(16) 生成,长度恒为 16") } // TC-0902: FetchInitialCredentials 一次性消费 —— 同一 ticket 第二次消费必须 400。 func TestFetchInitialCredentials_OneShotConsumption(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() code := testutil.UniqueId() createResp, err := NewCreateProductLogic(ctx, svcCtx).CreateProduct(&types.CreateProductReq{ Code: code, Name: "tic_once", AdminDeptId: seedAdminDept(t, ctx, svcCtx), }) require.NoError(t, err) t.Cleanup(func() { testutil.CleanTableByField(ctx, conn, "`sys_product_member`", "productCode", code) testutil.CleanTableByField(ctx, conn, "`sys_user`", "username", "admin_"+code) testutil.CleanTable(ctx, conn, "`sys_product`", createResp.Id) _, _ = svcCtx.Redis.DelCtx(ctx, initialCredentialsKeyPrefix+createResp.CredentialsTicket) }) logic := NewFetchInitialCredentialsLogic(ctx, svcCtx) first, err := logic.FetchInitialCredentials(&types.FetchInitialCredentialsReq{Ticket: createResp.CredentialsTicket}) require.NoError(t, err) require.NotNil(t, first) second, err := logic.FetchInitialCredentials(&types.FetchInitialCredentialsReq{Ticket: createResp.CredentialsTicket}) require.Error(t, err, "M-4:一次性 ticket 不能被第二次消费") assert.Nil(t, second) var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 400, ce.Code()) assert.Contains(t, ce.Error(), "凭证票据无效或已过期") } // TC-0903: FetchInitialCredentials 未知 ticket 必须 400 func TestFetchInitialCredentials_UnknownTicketRejected(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) _, err := NewFetchInitialCredentialsLogic(ctx, svcCtx).FetchInitialCredentials( &types.FetchInitialCredentialsReq{Ticket: "definitely_not_a_real_ticket_" + testutil.UniqueId()}, ) require.Error(t, err) var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 400, ce.Code()) } // TC-0904: 空 ticket 必须 400 func TestFetchInitialCredentials_EmptyTicketRejected(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) _, err := NewFetchInitialCredentialsLogic(ctx, svcCtx).FetchInitialCredentials( &types.FetchInitialCredentialsReq{Ticket: ""}, ) require.Error(t, err) var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 400, ce.Code()) assert.Contains(t, ce.Error(), "ticket 不能为空") } // TC-0905: nil req 必须 400 func TestFetchInitialCredentials_NilRequestRejected(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) _, err := NewFetchInitialCredentialsLogic(ctx, svcCtx).FetchInitialCredentials(nil) require.Error(t, err) var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 400, ce.Code()) } // TC-0906: 非超管禁止消费 ticket —— 即便 ticket 偶然外泄,非超管会被直接 403, // 把攻击面进一步压缩。 func TestFetchInitialCredentials_NonSuperAdminRejected(t *testing.T) { svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) superCtx := ctxhelper.SuperAdminCtx() conn := testutil.GetTestSqlConn() code := testutil.UniqueId() createResp, err := NewCreateProductLogic(superCtx, svcCtx).CreateProduct(&types.CreateProductReq{ Code: code, Name: "tic_403", AdminDeptId: seedAdminDept(t, superCtx, svcCtx), }) require.NoError(t, err) t.Cleanup(func() { testutil.CleanTableByField(superCtx, conn, "`sys_product_member`", "productCode", code) testutil.CleanTableByField(superCtx, conn, "`sys_user`", "username", "admin_"+code) testutil.CleanTable(superCtx, conn, "`sys_product`", createResp.Id) _, _ = svcCtx.Redis.DelCtx(superCtx, initialCredentialsKeyPrefix+createResp.CredentialsTicket) }) // 用产品 ADMIN 身份尝试消费 adminCtx := ctxhelper.AdminCtx(code) _, err = NewFetchInitialCredentialsLogic(adminCtx, svcCtx).FetchInitialCredentials( &types.FetchInitialCredentialsReq{Ticket: createResp.CredentialsTicket}, ) require.Error(t, err, "非超管必须被直接 403,不进入 Redis 消费阶段") var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 403, ce.Code()) // 同时断言 ticket 没被拒绝请求的副作用消费掉——后续超管仍可正常领取。 cred, err := NewFetchInitialCredentialsLogic(superCtx, svcCtx).FetchInitialCredentials( &types.FetchInitialCredentialsReq{Ticket: createResp.CredentialsTicket}, ) require.NoError(t, err, "M-4:非超管被拒时不得把 ticket 吞掉,否则超管会领取不到") assert.NotEmpty(t, cred.AppSecret) } // TC-0907: 未登录上下文必须 401 func TestFetchInitialCredentials_UnauthenticatedRejected(t *testing.T) { svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) _, err := NewFetchInitialCredentialsLogic(context.Background(), svcCtx).FetchInitialCredentials( &types.FetchInitialCredentialsReq{Ticket: "any"}, ) require.Error(t, err) var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 401, ce.Code()) } // TC-0908: Redis 中 ticket 载荷若不可解析,必须返回 500 结构错误而非把 raw 塞给调用方。 func TestFetchInitialCredentials_MalformedPayloadIn500(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) ticket := "mal_" + testutil.UniqueId() key := initialCredentialsKeyPrefix + ticket require.NoError(t, svcCtx.Redis.SetexCtx(ctx, key, "{this is not valid json", 30)) t.Cleanup(func() { _, _ = svcCtx.Redis.DelCtx(ctx, key) }) _, err := NewFetchInitialCredentialsLogic(ctx, svcCtx).FetchInitialCredentials( &types.FetchInitialCredentialsReq{Ticket: ticket}, ) require.Error(t, err) var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 500, ce.Code(), "损坏的载荷必须走 500 错误通道而非 panic / 静默成功") // 同时断言:损坏载荷在失败后已经被 GetDelCtx 消费掉,不会永久留在 Redis 里形成噪声。 val, _ := svcCtx.Redis.GetCtx(ctx, key) assert.Empty(t, val, "损坏载荷在 GetDelCtx 过程中必须被一并删除(DEL 是原子的)") } // TC-0909: Redis 中落盘的 value 必须是结构化 JSON,包含 4 个字段。 // 如果未来有人把 Marshal 换回裸字符串(又一个 M-4 回归),这个测试立刻炸。 func TestFetchInitialCredentials_StoredPayloadIsStructuredJSON(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() code := testutil.UniqueId() createResp, err := NewCreateProductLogic(ctx, svcCtx).CreateProduct(&types.CreateProductReq{ Code: code, Name: "tic_json", AdminDeptId: seedAdminDept(t, ctx, svcCtx), }) require.NoError(t, err) t.Cleanup(func() { testutil.CleanTableByField(ctx, conn, "`sys_product_member`", "productCode", code) testutil.CleanTableByField(ctx, conn, "`sys_user`", "username", "admin_"+code) testutil.CleanTable(ctx, conn, "`sys_product`", createResp.Id) _, _ = svcCtx.Redis.DelCtx(ctx, initialCredentialsKeyPrefix+createResp.CredentialsTicket) }) val, err := svcCtx.Redis.GetCtx(ctx, initialCredentialsKeyPrefix+createResp.CredentialsTicket) require.NoError(t, err) require.NotEmpty(t, val) var payload map[string]interface{} require.NoError(t, json.Unmarshal([]byte(val), &payload), "Redis 载荷必须是结构化 JSON") for _, key := range []string{"appKey", "appSecret", "adminUser", "adminPassword"} { v, ok := payload[key] assert.True(t, ok, "Redis 载荷必须包含字段 %s", key) assert.NotEmpty(t, v, "字段 %s 不能为空", key) } } // TC-0910: Redis TTL 在合理区间(> 0 且 <= 5 分钟 = 300s)。 func TestFetchInitialCredentials_TicketTTLWithinWindow(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() code := testutil.UniqueId() createResp, err := NewCreateProductLogic(ctx, svcCtx).CreateProduct(&types.CreateProductReq{ Code: code, Name: "tic_ttl", AdminDeptId: seedAdminDept(t, ctx, svcCtx), }) require.NoError(t, err) t.Cleanup(func() { testutil.CleanTableByField(ctx, conn, "`sys_product_member`", "productCode", code) testutil.CleanTableByField(ctx, conn, "`sys_user`", "username", "admin_"+code) testutil.CleanTable(ctx, conn, "`sys_product`", createResp.Id) _, _ = svcCtx.Redis.DelCtx(ctx, initialCredentialsKeyPrefix+createResp.CredentialsTicket) }) ttl, err := svcCtx.Redis.TtlCtx(ctx, initialCredentialsKeyPrefix+createResp.CredentialsTicket) require.NoError(t, err) assert.Greater(t, ttl, 0, "ticket 必须是短 TTL 而不是永久") assert.LessOrEqual(t, ttl, 300, "M-4:ticket TTL 不得超过 5 分钟 300s") } // TC-0911: 并发消费同一 ticket —— 有且仅有一个请求能拿到明文,其他全部 400。 // 依赖 GetDelCtx 的原子 GET+DEL 语义,这是 M-4 防竞态的核心契约。 func TestFetchInitialCredentials_ConcurrentConsumptionSingleWinner(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() code := testutil.UniqueId() createResp, err := NewCreateProductLogic(ctx, svcCtx).CreateProduct(&types.CreateProductReq{ Code: code, Name: "tic_conc", AdminDeptId: seedAdminDept(t, ctx, svcCtx), }) require.NoError(t, err) t.Cleanup(func() { testutil.CleanTableByField(ctx, conn, "`sys_product_member`", "productCode", code) testutil.CleanTableByField(ctx, conn, "`sys_user`", "username", "admin_"+code) testutil.CleanTable(ctx, conn, "`sys_product`", createResp.Id) _, _ = svcCtx.Redis.DelCtx(ctx, initialCredentialsKeyPrefix+createResp.CredentialsTicket) }) const N = 8 var wg sync.WaitGroup var successes int32 var fails int32 start := make(chan struct{}) for i := 0; i < N; i++ { wg.Add(1) go func() { defer wg.Done() <-start _, err := NewFetchInitialCredentialsLogic(ctx, svcCtx).FetchInitialCredentials( &types.FetchInitialCredentialsReq{Ticket: createResp.CredentialsTicket}, ) if err == nil { atomic.AddInt32(&successes, 1) } else { atomic.AddInt32(&fails, 1) } }() } close(start) wg.Wait() assert.Equal(t, int32(1), atomic.LoadInt32(&successes), "并发消费必须恰好 1 个胜出(GetDelCtx 原子 GET+DEL),实际 successes=%d fails=%d", atomic.LoadInt32(&successes), atomic.LoadInt32(&fails)) assert.Equal(t, int32(N-1), atomic.LoadInt32(&fails), "其余 %d 个并发都必须拿到 400", N-1) } // TC-0912: 契约回归 —— CreateProductResp 的公开字段集合不得再包含 appSecret / adminPassword。 // 这是对 M-4 的"结构体层面"回归(TC-0064 只覆盖到 JSON 序列化层面)。 func TestCreateProductResp_NoLongerExposesPlaintextCredentials(t *testing.T) { resp := &types.CreateProductResp{} bs, err := json.Marshal(resp) require.NoError(t, err) // 序列化结果里出现 "appSecret" 或 "adminPassword" 都属于 M-4 回归。 asStr := string(bs) assert.NotContains(t, asStr, "\"appSecret\"", "M-4:CreateProductResp 不得再含有 appSecret(哪怕是空串序列化)") assert.NotContains(t, asStr, "\"adminPassword\"", "M-4:CreateProductResp 不得再含有 adminPassword(哪怕是空串序列化)") // 必须有 ticket 相关字段 assert.Contains(t, asStr, "credentialsTicket") assert.Contains(t, asStr, "credentialsExpiresAt") } // fmt 引用以避免 import 被误清理(某些工具链会 trim 没用到的 import) var _ = fmt.Sprintf