createProductCompensation_audit_test.go 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
  1. package product
  2. import (
  3. "context"
  4. "database/sql"
  5. "testing"
  6. "time"
  7. "perms-system-server/internal/svc"
  8. "perms-system-server/internal/testutil"
  9. "perms-system-server/internal/testutil/ctxhelper"
  10. "perms-system-server/internal/types"
  11. "github.com/stretchr/testify/assert"
  12. "github.com/stretchr/testify/require"
  13. "github.com/zeromicro/go-zero/core/stores/redis"
  14. "github.com/zeromicro/go-zero/core/stores/sqlx"
  15. )
  16. // ---------------------------------------------------------------------------
  17. // 覆盖目标:审计 M-1(第 8 轮)—— CreateProduct 的 DB 事务提交后,ticket 生成 / JSON marshal /
  18. // Redis SetexCtx 任一步骤失败都必须走补偿:把 product / user / product_member 三行一并删除,
  19. // 让副作用回到"从未创建"。如果不补偿,新建的 admin 明文密码只留在本次内存里,一旦响应 500/503,
  20. // 账号就成了永久孤儿,必须手动改库。
  21. //
  22. // 注入手法:测试用例通过一个**无法连通**的 Redis 替换 svcCtx.Redis,让 SetexCtx 必然失败。
  23. // - CacheRedis 不动,所以 Model 层缓存 / Loader 行为保持正常;
  24. // - 只替换 svcCtx.Redis 这一个指针,CreateProduct 里就会打到挂线的 Redis,落入 503 分支;
  25. // - 补偿事务会同步跑一次 DB,验收的点就是 DB 里 **不存在** 任何残留行。
  26. //
  27. // 命名规则:TC-0976 ~ TC-0978
  28. // ---------------------------------------------------------------------------
  29. // newBrokenSvcCtxForM1 返回一个"真实 DB + CacheRedis 正常 + svcCtx.Redis 指向黑洞"的 svcCtx。
  30. // 黑洞 Redis 通过 `127.0.0.1:1` 构造,任何 Setex 都会立刻拿到 connection refused,不会把测试拖很久。
  31. func newBrokenSvcCtxForM1(t *testing.T) *svc.ServiceContext {
  32. t.Helper()
  33. cfg := testutil.GetTestConfig()
  34. svcCtx := svc.NewServiceContext(cfg)
  35. // 把 svcCtx.Redis 换掉;注意 MustNewRedis 不立即拨号,只有真正调 Setex 才会爆。
  36. // NonBlock=true 避免构造期强制 ping;PingTimeout 只在 NonBlock=false 时生效,这里保留是为
  37. // 防御未来默认值漂移。真正的失败发生在运行 SetexCtx 时,连不通 127.0.0.1:1 会立刻返 err。
  38. broken := redis.MustNewRedis(redis.RedisConf{
  39. Host: "127.0.0.1:1",
  40. Type: "node",
  41. NonBlock: true,
  42. PingTimeout: 200 * time.Millisecond,
  43. })
  44. svcCtx.Redis = broken
  45. return svcCtx
  46. }
  47. // assertNoOrphanRowsLeft 在补偿完成后,按 (productCode, adminUsername) 反查 DB 是否还有脏行。
  48. // 三张表都必须干净 —— 这是补偿契约的硬不变式。
  49. func assertNoOrphanRowsLeft(t *testing.T, ctx context.Context, conn sqlx.SqlConn, productCode, adminUsername string) {
  50. t.Helper()
  51. var productId int64
  52. err := conn.QueryRowCtx(ctx, &productId, "SELECT `id` FROM `sys_product` WHERE `code` = ? LIMIT 1", productCode)
  53. assert.ErrorIs(t, err, sql.ErrNoRows,
  54. "M-1:补偿后 sys_product 不得留下 code=%s 的行", productCode)
  55. var userId int64
  56. err = conn.QueryRowCtx(ctx, &userId, "SELECT `id` FROM `sys_user` WHERE `username` = ? LIMIT 1", adminUsername)
  57. assert.ErrorIs(t, err, sql.ErrNoRows,
  58. "M-1:补偿后 sys_user 不得留下 username=%s 的行", adminUsername)
  59. var memberId int64
  60. err = conn.QueryRowCtx(ctx, &memberId,
  61. "SELECT `id` FROM `sys_product_member` WHERE `productCode` = ? LIMIT 1", productCode)
  62. assert.ErrorIs(t, err, sql.ErrNoRows,
  63. "M-1:补偿后 sys_product_member 不得留下 productCode=%s 的行", productCode)
  64. }
  65. // TC-0976: Redis SetexCtx 失败时走补偿 —— DB 三张表必须回到"从未创建"。
  66. func TestCreateProduct_RedisSetexFail_CompensatesAllRows(t *testing.T) {
  67. ctx := ctxhelper.SuperAdminCtx()
  68. svcCtx := newBrokenSvcCtxForM1(t)
  69. conn := testutil.GetTestSqlConn()
  70. code := "m1_cpf_" + testutil.UniqueId()
  71. adminUsername := "admin_" + code
  72. // 兜底清理,防止断言失败后把孤儿行留下来污染下一次运行。
  73. t.Cleanup(func() {
  74. testutil.CleanTableByField(ctx, conn, "`sys_product_member`", "productCode", code)
  75. testutil.CleanTableByField(ctx, conn, "`sys_user`", "username", adminUsername)
  76. testutil.CleanTableByField(ctx, conn, "`sys_product`", "code", code)
  77. })
  78. // 审计 M-1 补偿路径必须在"入库 → SetexCtx 失败 → 补偿"整条链路生效。L-R10-1 要求传 AdminDeptId:
  79. // 这里必须用真实 DB(svcCtx.SysDeptModel 走 testutil 底层 conn),所以 seed 一条真实 dept。
  80. deptId := seedAdminDept(t, ctx, svcCtx)
  81. logic := NewCreateProductLogic(ctx, svcCtx)
  82. resp, err := logic.CreateProduct(&types.CreateProductReq{
  83. Code: code,
  84. Name: "m1压测产品",
  85. Remark: "审计M-1补偿验证",
  86. AdminDeptId: deptId,
  87. })
  88. // 审计要求:返回 503 "暂存初始凭证失败,请稍后重试";响应体不得携带 ticket / adminPassword。
  89. require.Error(t, err, "M-1:Redis 挂了时必须返回错误而不是静默吞掉")
  90. require.Nil(t, resp, "M-1:失败路径下不应把半成品 CreateProductResp 塞回给客户端")
  91. // 核心断言:补偿必须把产品 / admin 用户 / product_member 三行全部抹除。
  92. assertNoOrphanRowsLeft(t, ctx, conn, code, adminUsername)
  93. }
  94. // TC-0977: 同一 product code 在补偿后可以再次创建成功(幂等性)。
  95. // 没有这条断言的话,"补偿把行删干净"还可能与"索引未释放"组合成 ErrConflict,运维的修复动作会
  96. // 在二次尝试时被"产品编码已存在"挡回,补偿只是半成品。
  97. func TestCreateProduct_RedisSetexFail_AfterCompensation_CanRecreate(t *testing.T) {
  98. ctx := ctxhelper.SuperAdminCtx()
  99. brokenCtx := newBrokenSvcCtxForM1(t)
  100. conn := testutil.GetTestSqlConn()
  101. code := "m1_recreate_" + testutil.UniqueId()
  102. adminUsername := "admin_" + code
  103. t.Cleanup(func() {
  104. testutil.CleanTableByField(ctx, conn, "`sys_product_member`", "productCode", code)
  105. testutil.CleanTableByField(ctx, conn, "`sys_user`", "username", adminUsername)
  106. testutil.CleanTableByField(ctx, conn, "`sys_product`", "code", code)
  107. })
  108. // 审计 L-R10-1:两次 CreateProduct 复用同一个有效 dept,seedAdminDept 在 brokenCtx 下创建
  109. // (brokenCtx 的 Models 共享底层 conn,所以 goodCtx 二次查询也能读到)。
  110. deptId := seedAdminDept(t, ctx, brokenCtx)
  111. // 第一次:Redis 坏 → 补偿 → 503
  112. _, err := NewCreateProductLogic(ctx, brokenCtx).CreateProduct(&types.CreateProductReq{
  113. Code: code, Name: "first_attempt", Remark: "redis_down", AdminDeptId: deptId,
  114. })
  115. require.Error(t, err)
  116. assertNoOrphanRowsLeft(t, ctx, conn, code, adminUsername)
  117. // 第二次:Redis 好 → 必须成功;若第一次补偿不彻底会在 FindOneByCode/FindOneByUsername 里被拦。
  118. goodCtx := svc.NewServiceContext(testutil.GetTestConfig())
  119. resp2, err := NewCreateProductLogic(ctx, goodCtx).CreateProduct(&types.CreateProductReq{
  120. Code: code, Name: "second_attempt", Remark: "redis_ok", AdminDeptId: deptId,
  121. })
  122. require.NoError(t, err, "M-1:补偿后二次创建必须成功;若失败说明有行没被清干净")
  123. require.NotNil(t, resp2)
  124. assert.Equal(t, code, resp2.Code)
  125. assert.NotEmpty(t, resp2.CredentialsTicket)
  126. }
  127. // TC-0978: 补偿的三张表删除顺序必须是"子 → 父"(product_member → user → product),
  128. // 保证即使外键/缓存/唯一索引尚未释放也不会互相拦截。
  129. //
  130. // 验证手法:在第一次补偿完成后,直接用数据库元数据反查三张表里均无与 productCode 相关的残留;
  131. // 如果删除顺序错误(例如先 product 后 member),product 会被外键/级联规则阻塞,member 行会留下。
  132. //
  133. // 注:sys_product 与 sys_product_member 之间没有强制外键(见 perm.sql 确认),所以 MySQL 不会在
  134. // 引擎层阻止错序 DELETE。但错序在事务里依然会导致:
  135. // - 如果先删 product 再删 member,member 仍有引用的 productCode 已经没有对应 product,
  136. // 再之后的 "ON DUPLICATE KEY UPDATE" 或外部查询会看到脏引用;
  137. // - 即便 DB 不拦,测试也要在更高一层用"三张表均为 0 行"来钉死补偿是否真正覆盖 3 行。
  138. func TestCreateProduct_RedisSetexFail_CompensatesInChildFirstOrder(t *testing.T) {
  139. ctx := ctxhelper.SuperAdminCtx()
  140. svcCtx := newBrokenSvcCtxForM1(t)
  141. conn := testutil.GetTestSqlConn()
  142. code := "m1_order_" + testutil.UniqueId()
  143. adminUsername := "admin_" + code
  144. t.Cleanup(func() {
  145. testutil.CleanTableByField(ctx, conn, "`sys_product_member`", "productCode", code)
  146. testutil.CleanTableByField(ctx, conn, "`sys_user`", "username", adminUsername)
  147. testutil.CleanTableByField(ctx, conn, "`sys_product`", "code", code)
  148. })
  149. _, err := NewCreateProductLogic(ctx, svcCtx).CreateProduct(&types.CreateProductReq{
  150. Code: code, Name: "m1 order", Remark: "delete order", AdminDeptId: seedAdminDept(t, ctx, svcCtx),
  151. })
  152. require.Error(t, err)
  153. // 交叉验证:三张表按独立 SELECT COUNT 查询,每张都必须为 0。
  154. for _, tc := range []struct {
  155. sql string
  156. arg interface{}
  157. table string
  158. }{
  159. {"SELECT COUNT(*) FROM `sys_product_member` WHERE `productCode` = ?", code, "sys_product_member"},
  160. {"SELECT COUNT(*) FROM `sys_user` WHERE `username` = ?", adminUsername, "sys_user"},
  161. {"SELECT COUNT(*) FROM `sys_product` WHERE `code` = ?", code, "sys_product"},
  162. } {
  163. var n int64
  164. require.NoError(t, conn.QueryRowCtx(ctx, &n, tc.sql, tc.arg))
  165. assert.Equal(t, int64(0), n,
  166. "M-1:补偿完成后 %s 应 0 行(命名 code=%s / admin=%s)", tc.table, code, adminUsername)
  167. }
  168. }