productAccessControl_audit_test.go 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215
  1. package product
  2. import (
  3. "context"
  4. "errors"
  5. "testing"
  6. "perms-system-server/internal/consts"
  7. "perms-system-server/internal/loaders"
  8. "perms-system-server/internal/middleware"
  9. productModel "perms-system-server/internal/model/product"
  10. "perms-system-server/internal/response"
  11. "perms-system-server/internal/testutil/mocks"
  12. "perms-system-server/internal/types"
  13. "github.com/stretchr/testify/assert"
  14. "github.com/stretchr/testify/require"
  15. "go.uber.org/mock/gomock"
  16. )
  17. // ---------------------------------------------------------------------------
  18. // 覆盖目标:审计第 6 轮 M-2 修复回归 —— 非超管调用 /api/product/list、/api/product/detail
  19. // 时必须做 "行/资源级" 访问控制:
  20. // * List:只返回 caller.ProductCode 对应的那一条;caller 无 productCode 时返回空列表。
  21. // * Detail:目标 product.Code != caller.ProductCode 时 404(不披露 "存在但无权")。
  22. // 同时保留字段级脱敏:非超管看不到 AppKey。
  23. // ---------------------------------------------------------------------------
  24. // ——— 工具:各种身份的 ctx ———
  25. func memberWithProduct(productCode string) context.Context {
  26. return middleware.WithUserDetails(context.Background(), &loaders.UserDetails{
  27. UserId: 42, Username: "m",
  28. IsSuperAdmin: false, MemberType: consts.MemberTypeMember,
  29. Status: consts.StatusEnabled, ProductCode: productCode,
  30. })
  31. }
  32. func orphanMember() context.Context {
  33. return middleware.WithUserDetails(context.Background(), &loaders.UserDetails{
  34. UserId: 43, Username: "orphan",
  35. IsSuperAdmin: false, MemberType: consts.MemberTypeMember,
  36. Status: consts.StatusEnabled, ProductCode: "", // 无 product
  37. })
  38. }
  39. func superAdminCtx() context.Context {
  40. return middleware.WithUserDetails(context.Background(), &loaders.UserDetails{
  41. UserId: 1, Username: "su",
  42. IsSuperAdmin: true, MemberType: consts.MemberTypeSuperAdmin,
  43. Status: consts.StatusEnabled,
  44. })
  45. }
  46. // TC-0850: MEMBER 调 ProductList —— 即使 DB 里有多个产品,也只看到自己的一个。
  47. func TestProductList_Member_OnlySeesOwnProduct(t *testing.T) {
  48. ctrl := gomock.NewController(t)
  49. t.Cleanup(ctrl.Finish)
  50. mockProd := mocks.NewMockSysProductModel(ctrl)
  51. // 关键:非超管路径不应调用 FindList(全站列表),而应精确调用 FindOneByCode(ownCode)。
  52. mockProd.EXPECT().FindOneByCode(gomock.Any(), "pA").
  53. Return(&productModel.SysProduct{Id: 11, Code: "pA", Name: "ProdA", AppKey: "SECRET-A"}, nil)
  54. svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Product: mockProd})
  55. resp, err := NewProductListLogic(memberWithProduct("pA"), svcCtx).
  56. ProductList(&types.ProductListReq{Page: 1, PageSize: 50})
  57. require.NoError(t, err)
  58. require.NotNil(t, resp)
  59. assert.Equal(t, int64(1), resp.Total, "M-2:MEMBER 只能看到自己一个产品")
  60. items, ok := resp.List.([]types.ProductItem)
  61. require.True(t, ok, "resp.List 必须是 []types.ProductItem")
  62. require.Len(t, items, 1)
  63. item := items[0]
  64. assert.Equal(t, "pA", item.Code)
  65. assert.Empty(t, item.AppKey,
  66. "M-2:非超管路径下 AppKey 必须保持脱敏,不得泄露")
  67. }
  68. // TC-0851: MEMBER 调 ProductList 但 ProductCode=="" —— 返回空列表,不访问 DB。
  69. func TestProductList_OrphanMember_ReturnsEmpty(t *testing.T) {
  70. ctrl := gomock.NewController(t)
  71. t.Cleanup(ctrl.Finish)
  72. mockProd := mocks.NewMockSysProductModel(ctrl)
  73. // 断言:任何 DB 查询都不应发生。不登记任何 EXPECT 即隐含 Times(0)。
  74. svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Product: mockProd})
  75. resp, err := NewProductListLogic(orphanMember(), svcCtx).
  76. ProductList(&types.ProductListReq{Page: 1, PageSize: 50})
  77. require.NoError(t, err)
  78. require.NotNil(t, resp)
  79. assert.Equal(t, int64(0), resp.Total)
  80. items, ok := resp.List.([]types.ProductItem)
  81. require.True(t, ok, "resp.List 必须是 []types.ProductItem")
  82. assert.Len(t, items, 0)
  83. }
  84. // TC-0852: MEMBER 请求查询 "别人的产品" 详情 —— 必须 404(而非 403 或 200)。
  85. func TestProductDetail_Member_OtherProduct_Returns404(t *testing.T) {
  86. ctrl := gomock.NewController(t)
  87. t.Cleanup(ctrl.Finish)
  88. mockProd := mocks.NewMockSysProductModel(ctrl)
  89. // DB 里 id=22 属于产品 pB,调用方属于 pA → 应当 404。
  90. mockProd.EXPECT().FindOne(gomock.Any(), int64(22)).
  91. Return(&productModel.SysProduct{Id: 22, Code: "pB", Name: "ProdB", AppKey: "SECRET-B"}, nil)
  92. svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Product: mockProd})
  93. resp, err := NewProductDetailLogic(memberWithProduct("pA"), svcCtx).
  94. ProductDetail(&types.ProductDetailReq{Id: 22})
  95. assert.Nil(t, resp)
  96. require.Error(t, err)
  97. ce, ok := err.(*response.CodeError)
  98. require.True(t, ok, "M-2:应返回 response.CodeError")
  99. assert.Equal(t, 404, ce.Code(),
  100. "M-2:非超管查他产品必须 404,而不是 403/200,避免被用作存在性 oracle")
  101. }
  102. // TC-0853: MEMBER 查自己产品详情 —— 200 OK,但 AppKey 必须为空。
  103. func TestProductDetail_Member_OwnProduct_AppKeyHidden(t *testing.T) {
  104. ctrl := gomock.NewController(t)
  105. t.Cleanup(ctrl.Finish)
  106. mockProd := mocks.NewMockSysProductModel(ctrl)
  107. mockProd.EXPECT().FindOne(gomock.Any(), int64(11)).
  108. Return(&productModel.SysProduct{Id: 11, Code: "pA", Name: "ProdA", AppKey: "SECRET-A"}, nil)
  109. svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Product: mockProd})
  110. resp, err := NewProductDetailLogic(memberWithProduct("pA"), svcCtx).
  111. ProductDetail(&types.ProductDetailReq{Id: 11})
  112. require.NoError(t, err)
  113. require.NotNil(t, resp)
  114. assert.Equal(t, "pA", resp.Code)
  115. assert.Empty(t, resp.AppKey,
  116. "M-2:自己产品也不应看到 AppKey(M-2 不取消 AppKey 脱敏)")
  117. }
  118. // TC-0854: 超管查任何产品详情都能看到 AppKey。
  119. func TestProductDetail_SuperAdmin_SeesAppKey(t *testing.T) {
  120. ctrl := gomock.NewController(t)
  121. t.Cleanup(ctrl.Finish)
  122. mockProd := mocks.NewMockSysProductModel(ctrl)
  123. mockProd.EXPECT().FindOne(gomock.Any(), int64(22)).
  124. Return(&productModel.SysProduct{Id: 22, Code: "pB", Name: "ProdB", AppKey: "SECRET-B"}, nil)
  125. svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Product: mockProd})
  126. resp, err := NewProductDetailLogic(superAdminCtx(), svcCtx).
  127. ProductDetail(&types.ProductDetailReq{Id: 22})
  128. require.NoError(t, err)
  129. require.NotNil(t, resp)
  130. assert.Equal(t, "pB", resp.Code)
  131. assert.Equal(t, "SECRET-B", resp.AppKey,
  132. "超管路径下 AppKey 必须可见,否则超管无法管理集成")
  133. }
  134. // TC-0871: 超管调 ProductList 走完整分页路径,FindList 被调用且 AppKey 原样返回。
  135. // 覆盖清理掉的 TestProductList_Normal / TestProductList_SuperAdminAppKeyVisible —— 这些旧用例
  136. // 的"默认分页值 / pageSize 上限"等边界已由 util.TestNormalizePage 单元测试独立覆盖,
  137. // 这里只做一条最小契约保留:超管路径确实从 FindList 拉全站列表且 AppKey 不被脱敏。
  138. func TestProductList_SuperAdmin_AppKeyVisibleAndFindListCalled(t *testing.T) {
  139. ctrl := gomock.NewController(t)
  140. t.Cleanup(ctrl.Finish)
  141. mockProd := mocks.NewMockSysProductModel(ctrl)
  142. // 关键 1:必须走 FindList(分页路径),而不是 FindOneByCode(非超管路径)。
  143. mockProd.EXPECT().FindList(gomock.Any(), int64(1), int64(20)).
  144. Return([]*productModel.SysProduct{
  145. {Id: 11, Code: "pA", Name: "ProdA", AppKey: "AK-A"},
  146. {Id: 12, Code: "pB", Name: "ProdB", AppKey: "AK-B"},
  147. }, int64(2), nil)
  148. svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Product: mockProd})
  149. resp, err := NewProductListLogic(superAdminCtx(), svcCtx).
  150. ProductList(&types.ProductListReq{Page: 1, PageSize: 20})
  151. require.NoError(t, err)
  152. require.NotNil(t, resp)
  153. assert.Equal(t, int64(2), resp.Total)
  154. items, ok := resp.List.([]types.ProductItem)
  155. require.True(t, ok)
  156. require.Len(t, items, 2)
  157. // 关键 2:超管路径下 AppKey 不脱敏(与 TC-0850/TC-0853 的 MEMBER 路径形成互为镜像的契约)。
  158. assert.Equal(t, "AK-A", items[0].AppKey, "M-2:超管必须看到 AppKey 以便管理产品集成")
  159. assert.Equal(t, "AK-B", items[1].AppKey)
  160. }
  161. // TC-0872: ProductDetail 路径上 FindOne 抛 err(例如 NotFound)必须映射成 404 "产品不存在"。
  162. // 覆盖清理掉的 TestProductDetail_NotFound —— 这条路径与 TC-0852 的"别人的产品 → 404"不同:
  163. // 这里是 DB 层直接报"行不存在",logic 必须同样返回 404 以保持对枚举攻击的无差别响应。
  164. func TestProductDetail_FindOneError_MapsTo404(t *testing.T) {
  165. ctrl := gomock.NewController(t)
  166. t.Cleanup(ctrl.Finish)
  167. mockProd := mocks.NewMockSysProductModel(ctrl)
  168. // FindOne 返回 sqlx.ErrNotFound(或任何 err)都应在 logic 层被映射成 404。
  169. mockProd.EXPECT().FindOne(gomock.Any(), int64(999999)).
  170. Return(nil, errors.New("sql: no rows in result set"))
  171. svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Product: mockProd})
  172. resp, err := NewProductDetailLogic(superAdminCtx(), svcCtx).
  173. ProductDetail(&types.ProductDetailReq{Id: 999999})
  174. assert.Nil(t, resp)
  175. require.Error(t, err)
  176. ce, ok := err.(*response.CodeError)
  177. require.True(t, ok, "必须是 response.CodeError")
  178. assert.Equal(t, 404, ce.Code())
  179. assert.Equal(t, "产品不存在", ce.Error(),
  180. "M-2:FindOne 失败与'别人的产品'都返回同一份 404 文案,避免被用作存在性 oracle")
  181. }