updateProductLogic_test.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411
  1. package product
  2. import (
  3. "context"
  4. "database/sql"
  5. "errors"
  6. "fmt"
  7. "testing"
  8. "time"
  9. "perms-system-server/internal/consts"
  10. productModel "perms-system-server/internal/model/product"
  11. memberModel "perms-system-server/internal/model/productmember"
  12. userModel "perms-system-server/internal/model/user"
  13. "perms-system-server/internal/response"
  14. "perms-system-server/internal/svc"
  15. "perms-system-server/internal/testutil"
  16. "perms-system-server/internal/testutil/ctxhelper"
  17. "perms-system-server/internal/types"
  18. "github.com/stretchr/testify/assert"
  19. "github.com/stretchr/testify/require"
  20. "github.com/zeromicro/go-zero/core/stores/redis"
  21. )
  22. func insertTestProduct(t *testing.T, ctx context.Context) *productModel.SysProduct {
  23. t.Helper()
  24. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  25. conn := testutil.GetTestSqlConn()
  26. code := testutil.UniqueId()
  27. now := time.Now().Unix()
  28. result, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{
  29. Code: code,
  30. Name: "待更新产品",
  31. AppKey: "test_key_" + code,
  32. AppSecret: "test_secret_" + code,
  33. Remark: "原始备注",
  34. Status: 1,
  35. CreateTime: now,
  36. UpdateTime: now,
  37. })
  38. require.NoError(t, err)
  39. id, _ := result.LastInsertId()
  40. t.Cleanup(func() {
  41. testutil.CleanTable(ctx, conn, "`sys_product`", id)
  42. })
  43. return &productModel.SysProduct{
  44. Id: id,
  45. Code: code,
  46. Name: "待更新产品",
  47. AppKey: "test_key_" + code,
  48. AppSecret: "test_secret_" + code,
  49. Remark: "原始备注",
  50. Status: 1,
  51. CreateTime: now,
  52. UpdateTime: now,
  53. }
  54. }
  55. // TC-0076: 正常更新
  56. func TestUpdateProduct_Success(t *testing.T) {
  57. ctx := ctxhelper.SuperAdminCtx()
  58. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  59. p := insertTestProduct(t, ctx)
  60. logic := NewUpdateProductLogic(ctx, svcCtx)
  61. err := logic.UpdateProduct(&types.UpdateProductReq{
  62. Id: p.Id,
  63. Name: "已更新名称",
  64. Remark: "已更新备注",
  65. Status: 2,
  66. })
  67. require.NoError(t, err)
  68. updated, err := svcCtx.SysProductModel.FindOne(ctx, p.Id)
  69. require.NoError(t, err)
  70. assert.Equal(t, "已更新名称", updated.Name)
  71. assert.Equal(t, "已更新备注", updated.Remark)
  72. assert.Equal(t, int64(2), updated.Status)
  73. }
  74. // TC-0077: 不存在
  75. func TestUpdateProduct_NotFound(t *testing.T) {
  76. ctx := ctxhelper.SuperAdminCtx()
  77. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  78. logic := NewUpdateProductLogic(ctx, svcCtx)
  79. err := logic.UpdateProduct(&types.UpdateProductReq{
  80. Id: 999999999,
  81. Name: "不存在",
  82. })
  83. require.Error(t, err)
  84. var codeErr *response.CodeError
  85. require.True(t, errors.As(err, &codeErr))
  86. assert.Equal(t, 404, codeErr.Code())
  87. assert.Equal(t, "产品不存在", codeErr.Error())
  88. }
  89. // TC-0078: 不传status
  90. func TestUpdateProduct_StatusZeroUnchanged(t *testing.T) {
  91. ctx := ctxhelper.SuperAdminCtx()
  92. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  93. p := insertTestProduct(t, ctx)
  94. logic := NewUpdateProductLogic(ctx, svcCtx)
  95. err := logic.UpdateProduct(&types.UpdateProductReq{
  96. Id: p.Id,
  97. Name: "新名称",
  98. Remark: "新备注",
  99. Status: 0,
  100. })
  101. require.NoError(t, err)
  102. updated, err := svcCtx.SysProductModel.FindOne(ctx, p.Id)
  103. require.NoError(t, err)
  104. assert.Equal(t, "新名称", updated.Name)
  105. assert.Equal(t, "新备注", updated.Remark)
  106. assert.Equal(t, int64(1), updated.Status, "status should remain unchanged when req.Status is 0")
  107. }
  108. // TC-0536: updateProduct非超管拒绝
  109. func TestUpdateProduct_NonSuperAdminRejected(t *testing.T) {
  110. ctx := ctxhelper.AdminCtx("test_product")
  111. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  112. logic := NewUpdateProductLogic(ctx, svcCtx)
  113. err := logic.UpdateProduct(&types.UpdateProductReq{Id: 1, Name: "test"})
  114. require.Error(t, err)
  115. var ce *response.CodeError
  116. require.True(t, errors.As(err, &ce))
  117. assert.Equal(t, 403, ce.Code())
  118. }
  119. // TC-0090: updateProduct 非法状态值被拒绝(修复验证)
  120. func TestUpdateProduct_InvalidStatusRejected(t *testing.T) {
  121. ctx := ctxhelper.SuperAdminCtx()
  122. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  123. p := insertTestProduct(t, ctx)
  124. logic := NewUpdateProductLogic(ctx, svcCtx)
  125. invalid := []int64{3, 99, -1}
  126. for _, st := range invalid {
  127. err := logic.UpdateProduct(&types.UpdateProductReq{Id: p.Id, Status: st})
  128. require.Error(t, err, "status=%d 应被拒绝", st)
  129. var ce *response.CodeError
  130. require.True(t, errors.As(err, &ce))
  131. assert.Equal(t, 400, ce.Code())
  132. }
  133. after, err := svcCtx.SysProductModel.FindOne(ctx, p.Id)
  134. require.NoError(t, err)
  135. assert.Equal(t, int64(1), after.Status, "非法状态不应落库")
  136. }
  137. // ---------------------------------------------------------------------------
  138. // L-R15-3:UpdateProduct 在 Enabled → Disabled 迁移时,事务内同步对该产品下
  139. // 所有启用成员的 sys_user.tokenVersion 做 +1,让旧 access token 在下一次
  140. // middleware 校验时被 401 踢出。Disabled → Enabled / 非状态更新不触发。
  141. // 断言三件事:
  142. // - revoke 集合完备且不越界(只影响 target product 的 active 成员);
  143. // - 事务原子:产品写失败则 tokenVersion 不涨,反之亦然;
  144. // - post-commit 失效 sysProduct + sysUser 两侧低层缓存。
  145. // ---------------------------------------------------------------------------
  146. // seedProductWithMembers 构造一个启用中的产品,附带若干成员:
  147. // - memberSpec 每项是 {memberType, status},按顺序 seed;
  148. // - 返回产品及各 userId(顺序对应入参)。
  149. // 统一用 UniqueId 避免与既有数据冲突;t.Cleanup 里按 productCode 清理成员,再删用户/产品。
  150. type memberSpec struct {
  151. MemberType string
  152. Status int64 // 1=Enabled, 2=Disabled
  153. }
  154. type seededProductMembers struct {
  155. product *productModel.SysProduct
  156. userIds []int64
  157. }
  158. func seedProductWithMembers(t *testing.T, ctx context.Context, svcCtx *svc.ServiceContext, specs []memberSpec) seededProductMembers {
  159. t.Helper()
  160. conn := testutil.GetTestSqlConn()
  161. code := testutil.UniqueId()
  162. now := time.Now().Unix()
  163. pRes, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{
  164. Code: code, Name: "p_" + code, AppKey: code + "_k", AppSecret: "s",
  165. Status: consts.StatusEnabled, CreateTime: now, UpdateTime: now,
  166. })
  167. require.NoError(t, err)
  168. pId, _ := pRes.LastInsertId()
  169. var userIds []int64
  170. for i, s := range specs {
  171. uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{
  172. Username: fmt.Sprintf("%s_u%d", code, i),
  173. Password: testutil.HashPassword("pw"),
  174. Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2,
  175. Status: 1, CreateTime: now, UpdateTime: now,
  176. })
  177. require.NoError(t, err)
  178. uId, _ := uRes.LastInsertId()
  179. userIds = append(userIds, uId)
  180. _, err = svcCtx.SysProductMemberModel.Insert(ctx, &memberModel.SysProductMember{
  181. ProductCode: code, UserId: uId, MemberType: s.MemberType,
  182. Status: s.Status, CreateTime: now, UpdateTime: now,
  183. })
  184. require.NoError(t, err)
  185. }
  186. t.Cleanup(func() {
  187. testutil.CleanTableByField(ctx, conn, "`sys_product_member`", "productCode", code)
  188. for _, uId := range userIds {
  189. testutil.CleanTable(ctx, conn, "`sys_user`", uId)
  190. }
  191. testutil.CleanTable(ctx, conn, "`sys_product`", pId)
  192. })
  193. p, err := svcCtx.SysProductModel.FindOne(ctx, pId)
  194. require.NoError(t, err)
  195. return seededProductMembers{product: p, userIds: userIds}
  196. }
  197. func readTokenVersion(t *testing.T, ctx context.Context, userId int64) int64 {
  198. t.Helper()
  199. conn := testutil.GetTestSqlConn()
  200. var tv int64
  201. require.NoError(t,
  202. conn.QueryRowCtx(ctx, &tv,
  203. "SELECT `tokenVersion` FROM `sys_user` WHERE `id` = ?", userId))
  204. return tv
  205. }
  206. // TC-1138:Enabled→Disabled 时,产品下的启用成员 tokenVersion 全部 +1;禁用成员不影响。
  207. // 覆盖 ADMIN/DEVELOPER/MEMBER 三种 type,证明 revoke 集合按 status 过滤而非按 memberType。
  208. func TestUpdateProduct_Disable_BumpsAllActiveMemberTokenVersions(t *testing.T) {
  209. ctx := ctxhelper.SuperAdminCtx()
  210. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  211. seeded := seedProductWithMembers(t, ctx, svcCtx, []memberSpec{
  212. {consts.MemberTypeAdmin, consts.StatusEnabled},
  213. {consts.MemberTypeDeveloper, consts.StatusEnabled},
  214. {consts.MemberTypeMember, consts.StatusEnabled},
  215. {consts.MemberTypeMember, consts.StatusDisabled}, // 禁用成员必须被跳过
  216. })
  217. before := make([]int64, len(seeded.userIds))
  218. for i, uId := range seeded.userIds {
  219. before[i] = readTokenVersion(t, ctx, uId)
  220. }
  221. require.NoError(t,
  222. NewUpdateProductLogic(ctx, svcCtx).UpdateProduct(&types.UpdateProductReq{
  223. Id: seeded.product.Id,
  224. Name: seeded.product.Name,
  225. Status: consts.StatusDisabled,
  226. }))
  227. // 前 3 个(启用)每个 +1;最后一个(禁用)保持不变
  228. for i := 0; i < 3; i++ {
  229. after := readTokenVersion(t, ctx, seeded.userIds[i])
  230. assert.Equal(t, before[i]+1, after,
  231. "第 %d 个启用成员(userId=%d)tokenVersion 必须 +1", i, seeded.userIds[i])
  232. }
  233. after := readTokenVersion(t, ctx, seeded.userIds[3])
  234. assert.Equal(t, before[3], after,
  235. "禁用成员不应被 bump——FindActiveMemberUserIdsByProductCodeTx 必须 WHERE status=1 过滤,"+
  236. "否则已经冻结的旧成员会被二次踢出(无意义)且放大批量 UPDATE 的行数")
  237. }
  238. // TC-1139:Disabled→Enabled 时 tokenVersion 不变(重启用不吊销 session)。
  239. // 对应 shouldRevokeSessions = (prev==Enabled && next==Disabled) 这一严格方向性判断。
  240. func TestUpdateProduct_Enable_DoesNotBumpTokenVersion(t *testing.T) {
  241. ctx := ctxhelper.SuperAdminCtx()
  242. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  243. seeded := seedProductWithMembers(t, ctx, svcCtx, []memberSpec{
  244. {consts.MemberTypeMember, consts.StatusEnabled},
  245. })
  246. // 先禁用一次把前置条件做足
  247. require.NoError(t,
  248. NewUpdateProductLogic(ctx, svcCtx).UpdateProduct(&types.UpdateProductReq{
  249. Id: seeded.product.Id, Name: seeded.product.Name, Status: consts.StatusDisabled,
  250. }))
  251. before := readTokenVersion(t, ctx, seeded.userIds[0])
  252. // 重启用
  253. require.NoError(t,
  254. NewUpdateProductLogic(ctx, svcCtx).UpdateProduct(&types.UpdateProductReq{
  255. Id: seeded.product.Id, Name: seeded.product.Name, Status: consts.StatusEnabled,
  256. }))
  257. after := readTokenVersion(t, ctx, seeded.userIds[0])
  258. assert.Equal(t, before, after,
  259. "Disabled→Enabled 不递增——重启用不会让成员获得未曾持有的权限(他们此前即处于 token 已失效态),"+
  260. "再递增只会给合法重登录增加一次无谓的 401")
  261. }
  262. // TC-1140:仅改名不改 status 时 tokenVersion 不变。
  263. // 非状态迁移走 shouldRevokeSessions=false 分支,不得踢任何人下线。
  264. func TestUpdateProduct_NameOnlyChange_DoesNotBumpTokenVersion(t *testing.T) {
  265. ctx := ctxhelper.SuperAdminCtx()
  266. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  267. seeded := seedProductWithMembers(t, ctx, svcCtx, []memberSpec{
  268. {consts.MemberTypeAdmin, consts.StatusEnabled},
  269. {consts.MemberTypeMember, consts.StatusEnabled},
  270. })
  271. before := []int64{
  272. readTokenVersion(t, ctx, seeded.userIds[0]),
  273. readTokenVersion(t, ctx, seeded.userIds[1]),
  274. }
  275. require.NoError(t,
  276. NewUpdateProductLogic(ctx, svcCtx).UpdateProduct(&types.UpdateProductReq{
  277. Id: seeded.product.Id, Name: "new_" + seeded.product.Name, Status: 0,
  278. }))
  279. for i, uId := range seeded.userIds {
  280. assert.Equal(t, before[i], readTokenVersion(t, ctx, uId),
  281. "非状态更新不得触发 revoke——userId=%d 被意外递增意味着 shouldRevokeSessions 判定漂移", uId)
  282. }
  283. }
  284. // TC-1141:跨产品隔离——禁用 P1 不应影响只属于 P2 的用户 tokenVersion。
  285. // 对应 FindActiveMemberUserIdsByProductCodeTx 的 WHERE productCode=? 精确过滤。
  286. func TestUpdateProduct_Disable_CrossProductIsolation(t *testing.T) {
  287. ctx := ctxhelper.SuperAdminCtx()
  288. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  289. p1 := seedProductWithMembers(t, ctx, svcCtx, []memberSpec{
  290. {consts.MemberTypeMember, consts.StatusEnabled},
  291. })
  292. p2 := seedProductWithMembers(t, ctx, svcCtx, []memberSpec{
  293. {consts.MemberTypeMember, consts.StatusEnabled},
  294. })
  295. p1Before := readTokenVersion(t, ctx, p1.userIds[0])
  296. p2Before := readTokenVersion(t, ctx, p2.userIds[0])
  297. require.NoError(t,
  298. NewUpdateProductLogic(ctx, svcCtx).UpdateProduct(&types.UpdateProductReq{
  299. Id: p1.product.Id, Name: p1.product.Name, Status: consts.StatusDisabled,
  300. }))
  301. assert.Equal(t, p1Before+1, readTokenVersion(t, ctx, p1.userIds[0]),
  302. "P1 成员必须 +1")
  303. assert.Equal(t, p2Before, readTokenVersion(t, ctx, p2.userIds[0]),
  304. "P2 成员 tokenVersion 绝不允许被波及——这类'共享 userId'跨产品污染是 L-R15-3 的反面教材")
  305. }
  306. // TC-1142:空活跃成员产品禁用时,UpdateProduct 仍必须成功(不能因 ids 为空误抛错)。
  307. // 同时产品行必须按期从 Enabled 翻到 Disabled,post-commit 失效 productCache 的三把 key。
  308. func TestUpdateProduct_Disable_NoActiveMembers_StillSucceeds(t *testing.T) {
  309. ctx := ctxhelper.SuperAdminCtx()
  310. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  311. seeded := seedProductWithMembers(t, ctx, svcCtx, nil) // 没有成员
  312. require.NoError(t,
  313. NewUpdateProductLogic(ctx, svcCtx).UpdateProduct(&types.UpdateProductReq{
  314. Id: seeded.product.Id, Name: seeded.product.Name, Status: consts.StatusDisabled,
  315. }),
  316. "len(ids)==0 的快捷分支必须 nil,不得因为 BatchIncrement 空入参抛错")
  317. p, err := svcCtx.SysProductModel.FindOne(ctx, seeded.product.Id)
  318. require.NoError(t, err)
  319. assert.Equal(t, int64(consts.StatusDisabled), p.Status,
  320. "产品行仍必须被正确更新——revoke 集为空不构成短路 UPDATE 的理由")
  321. }
  322. // TC-1143(新增):post-commit 必须失效 sysProduct 的 id / appKey / code 三把低层缓存。
  323. // 否则下次 FindOne/FindOneByAppKey/FindOneByCode cache-hit 会读到旧 Status=Enabled,
  324. // 令客户端观察到"产品已禁但还能正常查到"的脏态。
  325. func TestUpdateProduct_Disable_InvalidatesProductCache(t *testing.T) {
  326. ctx := ctxhelper.SuperAdminCtx()
  327. cfg := testutil.GetTestConfig()
  328. svcCtx := svc.NewServiceContext(cfg)
  329. rds := redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf)
  330. seeded := seedProductWithMembers(t, ctx, svcCtx, []memberSpec{
  331. {consts.MemberTypeMember, consts.StatusEnabled},
  332. })
  333. // 预热三把 key
  334. _, err := svcCtx.SysProductModel.FindOne(ctx, seeded.product.Id)
  335. require.NoError(t, err)
  336. _, err = svcCtx.SysProductModel.FindOneByAppKey(ctx, seeded.product.AppKey)
  337. require.NoError(t, err)
  338. _, err = svcCtx.SysProductModel.FindOneByCode(ctx, seeded.product.Code)
  339. require.NoError(t, err)
  340. prefix := testutil.GetTestCachePrefix()
  341. keyId := fmt.Sprintf("%s:cache:sysProduct:id:%d", prefix, seeded.product.Id)
  342. keyAppKey := fmt.Sprintf("%s:cache:sysProduct:appKey:%s", prefix, seeded.product.AppKey)
  343. keyCode := fmt.Sprintf("%s:cache:sysProduct:code:%s", prefix, seeded.product.Code)
  344. for _, k := range []string{keyId, keyAppKey, keyCode} {
  345. v, _ := rds.Get(k)
  346. require.NotEmpty(t, v, "预置:%s 必须已写入缓存", k)
  347. }
  348. require.NoError(t,
  349. NewUpdateProductLogic(ctx, svcCtx).UpdateProduct(&types.UpdateProductReq{
  350. Id: seeded.product.Id, Name: seeded.product.Name, Status: consts.StatusDisabled,
  351. }))
  352. for _, k := range []string{keyId, keyAppKey, keyCode} {
  353. v, _ := rds.Get(k)
  354. assert.Empty(t, v,
  355. "L-R15-3:post-commit 必须清理 sysProduct 低层缓存 %s,否则 FindOne 仍命中旧 Enabled 值", k)
  356. }
  357. }