| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344 |
- 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: "",
- })
- 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 字符;12 字节 hex = 24 字符(与 createProductLogic 生成参数对齐)。
- assert.Len(t, cred.AppSecret, 64, "appSecret 必须是 32 字节 hex")
- assert.Len(t, cred.AdminPassword, 24, "adminPassword 必须是 12 字节 hex")
- }
- // 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",
- })
- 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",
- })
- 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",
- })
- 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",
- })
- 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",
- })
- 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
|