| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215 |
- package product
- import (
- "context"
- "errors"
- "testing"
- "perms-system-server/internal/consts"
- "perms-system-server/internal/loaders"
- "perms-system-server/internal/middleware"
- productModel "perms-system-server/internal/model/product"
- "perms-system-server/internal/response"
- "perms-system-server/internal/testutil/mocks"
- "perms-system-server/internal/types"
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
- "go.uber.org/mock/gomock"
- )
- // ---------------------------------------------------------------------------
- // 覆盖目标:非超管调用 /api/product/list、/api/product/detail
- // 时必须做 "行/资源级" 访问控制:
- // * List:只返回 caller.ProductCode 对应的那一条;caller 无 productCode 时返回空列表。
- // * Detail:目标 product.Code != caller.ProductCode 时 404(不披露 "存在但无权")。
- // 同时保留字段级脱敏:非超管看不到 AppKey。
- // ---------------------------------------------------------------------------
- // — 工具:各种身份的 ctx —
- func memberWithProduct(productCode string) context.Context {
- return middleware.WithUserDetails(context.Background(), &loaders.UserDetails{
- UserId: 42, Username: "m",
- IsSuperAdmin: false, MemberType: consts.MemberTypeMember,
- Status: consts.StatusEnabled, ProductCode: productCode,
- })
- }
- func orphanMember() context.Context {
- return middleware.WithUserDetails(context.Background(), &loaders.UserDetails{
- UserId: 43, Username: "orphan",
- IsSuperAdmin: false, MemberType: consts.MemberTypeMember,
- Status: consts.StatusEnabled, ProductCode: "", // 无 product
- })
- }
- func superAdminCtx() context.Context {
- return middleware.WithUserDetails(context.Background(), &loaders.UserDetails{
- UserId: 1, Username: "su",
- IsSuperAdmin: true, MemberType: consts.MemberTypeSuperAdmin,
- Status: consts.StatusEnabled,
- })
- }
- // TC-0850: MEMBER 调 ProductList —— 即使 DB 里有多个产品,也只看到自己的一个。
- func TestProductList_Member_OnlySeesOwnProduct(t *testing.T) {
- ctrl := gomock.NewController(t)
- t.Cleanup(ctrl.Finish)
- mockProd := mocks.NewMockSysProductModel(ctrl)
- // 关键:非超管路径不应调用 FindList(全站列表),而应精确调用 FindOneByCode(ownCode)。
- mockProd.EXPECT().FindOneByCode(gomock.Any(), "pA").
- Return(&productModel.SysProduct{Id: 11, Code: "pA", Name: "ProdA", AppKey: "SECRET-A"}, nil)
- svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Product: mockProd})
- resp, err := NewProductListLogic(memberWithProduct("pA"), svcCtx).
- ProductList(&types.ProductListReq{Page: 1, PageSize: 50})
- require.NoError(t, err)
- require.NotNil(t, resp)
- assert.Equal(t, int64(1), resp.Total, "MEMBER 只能看到自己一个产品")
- items, ok := resp.List.([]types.ProductItem)
- require.True(t, ok, "resp.List 必须是 []types.ProductItem")
- require.Len(t, items, 1)
- item := items[0]
- assert.Equal(t, "pA", item.Code)
- assert.Empty(t, item.AppKey,
- "非超管路径下 AppKey 必须保持脱敏,不得泄露")
- }
- // TC-0851: MEMBER 调 ProductList 但 ProductCode=="" —— 返回空列表,不访问 DB。
- func TestProductList_OrphanMember_ReturnsEmpty(t *testing.T) {
- ctrl := gomock.NewController(t)
- t.Cleanup(ctrl.Finish)
- mockProd := mocks.NewMockSysProductModel(ctrl)
- // 断言:任何 DB 查询都不应发生。不登记任何 EXPECT 即隐含 Times(0)。
- svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Product: mockProd})
- resp, err := NewProductListLogic(orphanMember(), svcCtx).
- ProductList(&types.ProductListReq{Page: 1, PageSize: 50})
- require.NoError(t, err)
- require.NotNil(t, resp)
- assert.Equal(t, int64(0), resp.Total)
- items, ok := resp.List.([]types.ProductItem)
- require.True(t, ok, "resp.List 必须是 []types.ProductItem")
- assert.Len(t, items, 0)
- }
- // TC-0852: MEMBER 请求查询 "别人的产品" 详情 —— 必须 404(而非 403 或 200)。
- func TestProductDetail_Member_OtherProduct_Returns404(t *testing.T) {
- ctrl := gomock.NewController(t)
- t.Cleanup(ctrl.Finish)
- mockProd := mocks.NewMockSysProductModel(ctrl)
- // DB 里 id=22 属于产品 pB,调用方属于 pA → 应当 404。
- mockProd.EXPECT().FindOne(gomock.Any(), int64(22)).
- Return(&productModel.SysProduct{Id: 22, Code: "pB", Name: "ProdB", AppKey: "SECRET-B"}, nil)
- svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Product: mockProd})
- resp, err := NewProductDetailLogic(memberWithProduct("pA"), svcCtx).
- ProductDetail(&types.ProductDetailReq{Id: 22})
- assert.Nil(t, resp)
- require.Error(t, err)
- ce, ok := err.(*response.CodeError)
- require.True(t, ok, "应返回 response.CodeError")
- assert.Equal(t, 404, ce.Code(),
- "非超管查他产品必须 404,而不是 403/200,避免被用作存在性 oracle")
- }
- // TC-0853: MEMBER 查自己产品详情 —— 200 OK,但 AppKey 必须为空。
- func TestProductDetail_Member_OwnProduct_AppKeyHidden(t *testing.T) {
- ctrl := gomock.NewController(t)
- t.Cleanup(ctrl.Finish)
- mockProd := mocks.NewMockSysProductModel(ctrl)
- mockProd.EXPECT().FindOne(gomock.Any(), int64(11)).
- Return(&productModel.SysProduct{Id: 11, Code: "pA", Name: "ProdA", AppKey: "SECRET-A"}, nil)
- svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Product: mockProd})
- resp, err := NewProductDetailLogic(memberWithProduct("pA"), svcCtx).
- ProductDetail(&types.ProductDetailReq{Id: 11})
- require.NoError(t, err)
- require.NotNil(t, resp)
- assert.Equal(t, "pA", resp.Code)
- assert.Empty(t, resp.AppKey,
- "自己产品也不应看到 AppKey(不取消 AppKey 脱敏)")
- }
- // TC-0854: 超管查任何产品详情都能看到 AppKey。
- func TestProductDetail_SuperAdmin_SeesAppKey(t *testing.T) {
- ctrl := gomock.NewController(t)
- t.Cleanup(ctrl.Finish)
- mockProd := mocks.NewMockSysProductModel(ctrl)
- mockProd.EXPECT().FindOne(gomock.Any(), int64(22)).
- Return(&productModel.SysProduct{Id: 22, Code: "pB", Name: "ProdB", AppKey: "SECRET-B"}, nil)
- svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Product: mockProd})
- resp, err := NewProductDetailLogic(superAdminCtx(), svcCtx).
- ProductDetail(&types.ProductDetailReq{Id: 22})
- require.NoError(t, err)
- require.NotNil(t, resp)
- assert.Equal(t, "pB", resp.Code)
- assert.Equal(t, "SECRET-B", resp.AppKey,
- "超管路径下 AppKey 必须可见,否则超管无法管理集成")
- }
- // TC-0871: 超管调 ProductList 走完整分页路径,FindList 被调用且 AppKey 原样返回。
- // 覆盖清理掉的 TestProductList_Normal / TestProductList_SuperAdminAppKeyVisible —— 这些旧用例
- // 的"默认分页值 / pageSize 上限"等边界已由 util.TestNormalizePage 单元测试独立覆盖,
- // 这里只做一条最小契约保留:超管路径确实从 FindList 拉全站列表且 AppKey 不被脱敏。
- func TestProductList_SuperAdmin_AppKeyVisibleAndFindListCalled(t *testing.T) {
- ctrl := gomock.NewController(t)
- t.Cleanup(ctrl.Finish)
- mockProd := mocks.NewMockSysProductModel(ctrl)
- // 关键 1:必须走 FindList(分页路径),而不是 FindOneByCode(非超管路径)。
- mockProd.EXPECT().FindList(gomock.Any(), int64(1), int64(20)).
- Return([]*productModel.SysProduct{
- {Id: 11, Code: "pA", Name: "ProdA", AppKey: "AK-A"},
- {Id: 12, Code: "pB", Name: "ProdB", AppKey: "AK-B"},
- }, int64(2), nil)
- svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Product: mockProd})
- resp, err := NewProductListLogic(superAdminCtx(), svcCtx).
- ProductList(&types.ProductListReq{Page: 1, PageSize: 20})
- require.NoError(t, err)
- require.NotNil(t, resp)
- assert.Equal(t, int64(2), resp.Total)
- items, ok := resp.List.([]types.ProductItem)
- require.True(t, ok)
- require.Len(t, items, 2)
- // 关键 2:超管路径下 AppKey 不脱敏(与 TC-0850/TC-0853 的 MEMBER 路径形成互为镜像的契约)。
- assert.Equal(t, "AK-A", items[0].AppKey, "超管必须看到 AppKey 以便管理产品集成")
- assert.Equal(t, "AK-B", items[1].AppKey)
- }
- // TC-0872: ProductDetail 路径上 FindOne 抛 err(例如 NotFound)必须映射成 404 "产品不存在"。
- // 覆盖清理掉的 TestProductDetail_NotFound —— 这条路径与 TC-0852 的"别人的产品 → 404"不同:
- // 这里是 DB 层直接报"行不存在",logic 必须同样返回 404 以保持对枚举攻击的无差别响应。
- func TestProductDetail_FindOneError_MapsTo404(t *testing.T) {
- ctrl := gomock.NewController(t)
- t.Cleanup(ctrl.Finish)
- mockProd := mocks.NewMockSysProductModel(ctrl)
- // FindOne 返回 sqlx.ErrNotFound(或任何 err)都应在 logic 层被映射成 404。
- mockProd.EXPECT().FindOne(gomock.Any(), int64(999999)).
- Return(nil, errors.New("sql: no rows in result set"))
- svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Product: mockProd})
- resp, err := NewProductDetailLogic(superAdminCtx(), svcCtx).
- ProductDetail(&types.ProductDetailReq{Id: 999999})
- assert.Nil(t, resp)
- require.Error(t, err)
- ce, ok := err.(*response.CodeError)
- require.True(t, ok, "必须是 response.CodeError")
- assert.Equal(t, 404, ce.Code())
- assert.Equal(t, "产品不存在", ce.Error(),
- "FindOne 失败与'别人的产品'都返回同一份 404 文案,避免被用作存在性 oracle")
- }
|