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") }