updateProductLogic_test.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412
  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. //
  150. // 统一用 UniqueId 避免与既有数据冲突;t.Cleanup 里按 productCode 清理成员,再删用户/产品。
  151. type memberSpec struct {
  152. MemberType string
  153. Status int64 // 1=Enabled, 2=Disabled
  154. }
  155. type seededProductMembers struct {
  156. product *productModel.SysProduct
  157. userIds []int64
  158. }
  159. func seedProductWithMembers(t *testing.T, ctx context.Context, svcCtx *svc.ServiceContext, specs []memberSpec) seededProductMembers {
  160. t.Helper()
  161. conn := testutil.GetTestSqlConn()
  162. code := testutil.UniqueId()
  163. now := time.Now().Unix()
  164. pRes, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{
  165. Code: code, Name: "p_" + code, AppKey: code + "_k", AppSecret: "s",
  166. Status: consts.StatusEnabled, CreateTime: now, UpdateTime: now,
  167. })
  168. require.NoError(t, err)
  169. pId, _ := pRes.LastInsertId()
  170. var userIds []int64
  171. for i, s := range specs {
  172. uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{
  173. Username: fmt.Sprintf("%s_u%d", code, i),
  174. Password: testutil.HashPassword("pw"),
  175. Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2,
  176. Status: 1, CreateTime: now, UpdateTime: now,
  177. })
  178. require.NoError(t, err)
  179. uId, _ := uRes.LastInsertId()
  180. userIds = append(userIds, uId)
  181. _, err = svcCtx.SysProductMemberModel.Insert(ctx, &memberModel.SysProductMember{
  182. ProductCode: code, UserId: uId, MemberType: s.MemberType,
  183. Status: s.Status, CreateTime: now, UpdateTime: now,
  184. })
  185. require.NoError(t, err)
  186. }
  187. t.Cleanup(func() {
  188. testutil.CleanTableByField(ctx, conn, "`sys_product_member`", "productCode", code)
  189. for _, uId := range userIds {
  190. testutil.CleanTable(ctx, conn, "`sys_user`", uId)
  191. }
  192. testutil.CleanTable(ctx, conn, "`sys_product`", pId)
  193. })
  194. p, err := svcCtx.SysProductModel.FindOne(ctx, pId)
  195. require.NoError(t, err)
  196. return seededProductMembers{product: p, userIds: userIds}
  197. }
  198. func readTokenVersion(t *testing.T, ctx context.Context, userId int64) int64 {
  199. t.Helper()
  200. conn := testutil.GetTestSqlConn()
  201. var tv int64
  202. require.NoError(t,
  203. conn.QueryRowCtx(ctx, &tv,
  204. "SELECT `tokenVersion` FROM `sys_user` WHERE `id` = ?", userId))
  205. return tv
  206. }
  207. // TC-1138:Enabled→Disabled 时,产品下的启用成员 tokenVersion 全部 +1;禁用成员不影响。
  208. // 覆盖 ADMIN/DEVELOPER/MEMBER 三种 type,证明 revoke 集合按 status 过滤而非按 memberType。
  209. func TestUpdateProduct_Disable_BumpsAllActiveMemberTokenVersions(t *testing.T) {
  210. ctx := ctxhelper.SuperAdminCtx()
  211. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  212. seeded := seedProductWithMembers(t, ctx, svcCtx, []memberSpec{
  213. {consts.MemberTypeAdmin, consts.StatusEnabled},
  214. {consts.MemberTypeDeveloper, consts.StatusEnabled},
  215. {consts.MemberTypeMember, consts.StatusEnabled},
  216. {consts.MemberTypeMember, consts.StatusDisabled}, // 禁用成员必须被跳过
  217. })
  218. before := make([]int64, len(seeded.userIds))
  219. for i, uId := range seeded.userIds {
  220. before[i] = readTokenVersion(t, ctx, uId)
  221. }
  222. require.NoError(t,
  223. NewUpdateProductLogic(ctx, svcCtx).UpdateProduct(&types.UpdateProductReq{
  224. Id: seeded.product.Id,
  225. Name: seeded.product.Name,
  226. Status: consts.StatusDisabled,
  227. }))
  228. // 前 3 个(启用)每个 +1;最后一个(禁用)保持不变
  229. for i := 0; i < 3; i++ {
  230. after := readTokenVersion(t, ctx, seeded.userIds[i])
  231. assert.Equal(t, before[i]+1, after,
  232. "第 %d 个启用成员(userId=%d)tokenVersion 必须 +1", i, seeded.userIds[i])
  233. }
  234. after := readTokenVersion(t, ctx, seeded.userIds[3])
  235. assert.Equal(t, before[3], after,
  236. "禁用成员不应被 bump——FindActiveMemberUserIdsByProductCodeTx 必须 WHERE status=1 过滤,"+
  237. "否则已经冻结的旧成员会被二次踢出(无意义)且放大批量 UPDATE 的行数")
  238. }
  239. // TC-1139:Disabled→Enabled 时 tokenVersion 不变(重启用不吊销 session)。
  240. // 对应 shouldRevokeSessions = (prev==Enabled && next==Disabled) 这一严格方向性判断。
  241. func TestUpdateProduct_Enable_DoesNotBumpTokenVersion(t *testing.T) {
  242. ctx := ctxhelper.SuperAdminCtx()
  243. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  244. seeded := seedProductWithMembers(t, ctx, svcCtx, []memberSpec{
  245. {consts.MemberTypeMember, consts.StatusEnabled},
  246. })
  247. // 先禁用一次把前置条件做足
  248. require.NoError(t,
  249. NewUpdateProductLogic(ctx, svcCtx).UpdateProduct(&types.UpdateProductReq{
  250. Id: seeded.product.Id, Name: seeded.product.Name, Status: consts.StatusDisabled,
  251. }))
  252. before := readTokenVersion(t, ctx, seeded.userIds[0])
  253. // 重启用
  254. require.NoError(t,
  255. NewUpdateProductLogic(ctx, svcCtx).UpdateProduct(&types.UpdateProductReq{
  256. Id: seeded.product.Id, Name: seeded.product.Name, Status: consts.StatusEnabled,
  257. }))
  258. after := readTokenVersion(t, ctx, seeded.userIds[0])
  259. assert.Equal(t, before, after,
  260. "Disabled→Enabled 不递增——重启用不会让成员获得未曾持有的权限(他们此前即处于 token 已失效态),"+
  261. "再递增只会给合法重登录增加一次无谓的 401")
  262. }
  263. // TC-1140:仅改名不改 status 时 tokenVersion 不变。
  264. // 非状态迁移走 shouldRevokeSessions=false 分支,不得踢任何人下线。
  265. func TestUpdateProduct_NameOnlyChange_DoesNotBumpTokenVersion(t *testing.T) {
  266. ctx := ctxhelper.SuperAdminCtx()
  267. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  268. seeded := seedProductWithMembers(t, ctx, svcCtx, []memberSpec{
  269. {consts.MemberTypeAdmin, consts.StatusEnabled},
  270. {consts.MemberTypeMember, consts.StatusEnabled},
  271. })
  272. before := []int64{
  273. readTokenVersion(t, ctx, seeded.userIds[0]),
  274. readTokenVersion(t, ctx, seeded.userIds[1]),
  275. }
  276. require.NoError(t,
  277. NewUpdateProductLogic(ctx, svcCtx).UpdateProduct(&types.UpdateProductReq{
  278. Id: seeded.product.Id, Name: "new_" + seeded.product.Name, Status: 0,
  279. }))
  280. for i, uId := range seeded.userIds {
  281. assert.Equal(t, before[i], readTokenVersion(t, ctx, uId),
  282. "非状态更新不得触发 revoke——userId=%d 被意外递增意味着 shouldRevokeSessions 判定漂移", uId)
  283. }
  284. }
  285. // TC-1141:跨产品隔离——禁用 P1 不应影响只属于 P2 的用户 tokenVersion。
  286. // 对应 FindActiveMemberUserIdsByProductCodeTx 的 WHERE productCode=? 精确过滤。
  287. func TestUpdateProduct_Disable_CrossProductIsolation(t *testing.T) {
  288. ctx := ctxhelper.SuperAdminCtx()
  289. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  290. p1 := seedProductWithMembers(t, ctx, svcCtx, []memberSpec{
  291. {consts.MemberTypeMember, consts.StatusEnabled},
  292. })
  293. p2 := seedProductWithMembers(t, ctx, svcCtx, []memberSpec{
  294. {consts.MemberTypeMember, consts.StatusEnabled},
  295. })
  296. p1Before := readTokenVersion(t, ctx, p1.userIds[0])
  297. p2Before := readTokenVersion(t, ctx, p2.userIds[0])
  298. require.NoError(t,
  299. NewUpdateProductLogic(ctx, svcCtx).UpdateProduct(&types.UpdateProductReq{
  300. Id: p1.product.Id, Name: p1.product.Name, Status: consts.StatusDisabled,
  301. }))
  302. assert.Equal(t, p1Before+1, readTokenVersion(t, ctx, p1.userIds[0]),
  303. "P1 成员必须 +1")
  304. assert.Equal(t, p2Before, readTokenVersion(t, ctx, p2.userIds[0]),
  305. "P2 成员 tokenVersion 绝不允许被波及——这类'共享 userId'跨产品污染是 L-R15-3 的反面教材")
  306. }
  307. // TC-1142:空活跃成员产品禁用时,UpdateProduct 仍必须成功(不能因 ids 为空误抛错)。
  308. // 同时产品行必须按期从 Enabled 翻到 Disabled,post-commit 失效 productCache 的三把 key。
  309. func TestUpdateProduct_Disable_NoActiveMembers_StillSucceeds(t *testing.T) {
  310. ctx := ctxhelper.SuperAdminCtx()
  311. svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
  312. seeded := seedProductWithMembers(t, ctx, svcCtx, nil) // 没有成员
  313. require.NoError(t,
  314. NewUpdateProductLogic(ctx, svcCtx).UpdateProduct(&types.UpdateProductReq{
  315. Id: seeded.product.Id, Name: seeded.product.Name, Status: consts.StatusDisabled,
  316. }),
  317. "len(ids)==0 的快捷分支必须 nil,不得因为 BatchIncrement 空入参抛错")
  318. p, err := svcCtx.SysProductModel.FindOne(ctx, seeded.product.Id)
  319. require.NoError(t, err)
  320. assert.Equal(t, int64(consts.StatusDisabled), p.Status,
  321. "产品行仍必须被正确更新——revoke 集为空不构成短路 UPDATE 的理由")
  322. }
  323. // TC-1143(新增):post-commit 必须失效 sysProduct 的 id / appKey / code 三把低层缓存。
  324. // 否则下次 FindOne/FindOneByAppKey/FindOneByCode cache-hit 会读到旧 Status=Enabled,
  325. // 令客户端观察到"产品已禁但还能正常查到"的脏态。
  326. func TestUpdateProduct_Disable_InvalidatesProductCache(t *testing.T) {
  327. ctx := ctxhelper.SuperAdminCtx()
  328. cfg := testutil.GetTestConfig()
  329. svcCtx := svc.NewServiceContext(cfg)
  330. rds := redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf)
  331. seeded := seedProductWithMembers(t, ctx, svcCtx, []memberSpec{
  332. {consts.MemberTypeMember, consts.StatusEnabled},
  333. })
  334. // 预热三把 key
  335. _, err := svcCtx.SysProductModel.FindOne(ctx, seeded.product.Id)
  336. require.NoError(t, err)
  337. _, err = svcCtx.SysProductModel.FindOneByAppKey(ctx, seeded.product.AppKey)
  338. require.NoError(t, err)
  339. _, err = svcCtx.SysProductModel.FindOneByCode(ctx, seeded.product.Code)
  340. require.NoError(t, err)
  341. prefix := testutil.GetTestCachePrefix()
  342. keyId := fmt.Sprintf("%s:cache:sysProduct:id:%d", prefix, seeded.product.Id)
  343. keyAppKey := fmt.Sprintf("%s:cache:sysProduct:appKey:%s", prefix, seeded.product.AppKey)
  344. keyCode := fmt.Sprintf("%s:cache:sysProduct:code:%s", prefix, seeded.product.Code)
  345. for _, k := range []string{keyId, keyAppKey, keyCode} {
  346. v, _ := rds.Get(k)
  347. require.NotEmpty(t, v, "预置:%s 必须已写入缓存", k)
  348. }
  349. require.NoError(t,
  350. NewUpdateProductLogic(ctx, svcCtx).UpdateProduct(&types.UpdateProductReq{
  351. Id: seeded.product.Id, Name: seeded.product.Name, Status: consts.StatusDisabled,
  352. }))
  353. for _, k := range []string{keyId, keyAppKey, keyCode} {
  354. v, _ := rds.Get(k)
  355. assert.Empty(t, v,
  356. "L-R15-3:post-commit 必须清理 sysProduct 低层缓存 %s,否则 FindOne 仍命中旧 Enabled 值", k)
  357. }
  358. }