diff --git a/src/Tests/Grand.Business.Cms.Tests/Services/BlogServiceTests.cs b/src/Tests/Grand.Business.Cms.Tests/Services/BlogServiceTests.cs index 37e8018c9..afd6a51ba 100644 --- a/src/Tests/Grand.Business.Cms.Tests/Services/BlogServiceTests.cs +++ b/src/Tests/Grand.Business.Cms.Tests/Services/BlogServiceTests.cs @@ -238,9 +238,12 @@ public async Task GetAllBlogPostsByTag_LoadsOnlyTheRequestedPageNewestFirst() _blogCategoryRepositoryMock.Object, _blogProductRepositoryMock.Object, _mediatorMock.Object, new AccessControlConfig()); for (var i = 0; i < 5; i++) - await repository.InsertAsync(new BlogPost { - Title = $"post {i}", Tags = "news", CreatedOnUtc = new DateTime(2026, 1, 1 + i, 0, 0, 0, DateTimeKind.Utc) - }); + { + //InsertAsync stamps CreatedOnUtc with the current time, so set the date afterwards + var post = await repository.InsertAsync(new BlogPost { Title = $"post {i}", Tags = "news" }); + post.CreatedOnUtc = new DateTime(2026, 1, 1 + i, 0, 0, 0, DateTimeKind.Utc); + await repository.UpdateAsync(post); + } var result = await blogService.GetAllBlogPostsByTag("", "news", 1, 2); diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductAssociatedControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductAssociatedControllerTests.cs new file mode 100644 index 000000000..911787717 --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductAssociatedControllerTests.cs @@ -0,0 +1,309 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Utilities.Catalog; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.AdminShared.Models.Orders; +using Grand.Web.Common.DataSource; +using Grand.Web.Common.Localization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using System.Linq.Expressions; + +namespace Grand.Web.Admin.Tests.Controllers; + +[TestClass] +public class BaseProductAssociatedControllerTests : ProductControllerTestsBase +{ + // BaseProductAssociatedController is abstract. This minimal subclass exists only so the actions under test + // can be invoked directly. + public class TestProductAssociatedController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseProductAssociatedController(productViewModelService, productService, translationService, scope); + + protected override TestProductAssociatedController CreateController() => new( + _productViewModelServiceMock.Object, + _productServiceMock.Object, + _translationServiceMock.Object, + _scopeMock.Object); + + // --- AssociatedProductList ----------------------------------------------------------------------- + // HasAccess (strict), not CanView: same shape as RelatedProductList above. Admin's original had no + // check at all. + + [TestMethod] + public async Task AssociatedProductList_ScopeDeniesAccess_ReturnsErrorJson() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); + + var result = await _controller.AssociatedProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + } + + [TestMethod] + public async Task AssociatedProductList_ScopeGrantsAccess_ReturnsGrid() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); + _productServiceMock.Setup(p => p.GetAssociatedProducts("p1", CatalogAccess.IncludingHidden(), "")) + .ReturnsAsync(new List { new() { Id = "a1", Name = "Assoc", DisplayOrder = 1 } }); + + var result = await _controller.AssociatedProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(1, gridModel.Total); + } + + // --- AssociatedProductUpdate --------------------------------------------------------------------- + + [TestMethod] + public async Task AssociatedProductUpdate_NotFound_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("a1", false)).ReturnsAsync((Product)null); + var model = new ProductModel.AssociatedProductModel { Id = "a1" }; + + await Assert.ThrowsExactlyAsync(() => _controller.AssociatedProductUpdate(model)); + } + + [TestMethod] + public async Task AssociatedProductUpdate_ScopeDeniesAccess_ReturnsErrorJson_DoesNotUpdate() + { + var associatedProduct = new Product { Id = "a1" }; + _productServiceMock.Setup(p => p.GetProductById("a1", false)).ReturnsAsync(associatedProduct); + _scopeMock.Setup(s => s.HasAccess(associatedProduct)).ReturnsAsync(false); + var model = new ProductModel.AssociatedProductModel { Id = "a1" }; + + var result = await _controller.AssociatedProductUpdate(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productServiceMock.Verify(p => p.UpdateAssociatedProduct(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task AssociatedProductUpdate_ScopeGrantsAccess_ValidModel_Updates() + { + var associatedProduct = new Product { Id = "a1" }; + _productServiceMock.Setup(p => p.GetProductById("a1", false)).ReturnsAsync(associatedProduct); + _scopeMock.Setup(s => s.HasAccess(associatedProduct)).ReturnsAsync(true); + var model = new ProductModel.AssociatedProductModel { Id = "a1", DisplayOrder = 5 }; + + var result = await _controller.AssociatedProductUpdate(model); + + Assert.IsInstanceOfType(result); + Assert.AreEqual(5, associatedProduct.DisplayOrder); + _productServiceMock.Verify(p => p.UpdateAssociatedProduct(associatedProduct), Times.Once); + } + + // --- AssociatedProductDelete --------------------------------------------------------------------- + + [TestMethod] + public async Task AssociatedProductDelete_NotFound_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("a1", false)).ReturnsAsync((Product)null); + var model = new ProductModel.AssociatedProductModel { Id = "a1" }; + + await Assert.ThrowsExactlyAsync(() => _controller.AssociatedProductDelete(model)); + } + + [TestMethod] + public async Task AssociatedProductDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() + { + var product = new Product { Id = "a1" }; + _productServiceMock.Setup(p => p.GetProductById("a1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.AssociatedProductModel { Id = "a1" }; + + var result = await _controller.AssociatedProductDelete(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.DeleteAssociatedProduct(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task AssociatedProductDelete_ScopeGrantsAccess_ValidModel_Deletes() + { + var product = new Product { Id = "a1" }; + _productServiceMock.Setup(p => p.GetProductById("a1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.AssociatedProductModel { Id = "a1" }; + + var result = await _controller.AssociatedProductDelete(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.DeleteAssociatedProduct(product), Times.Once); + } + + // --- AssociatedProductAddPopup (GET) ------------------------------------------------------------- + + [TestMethod] + public async Task AssociatedProductAddPopupGet_CallsPrepareAssociatedProductModel() + { + // PrepareAssociatedProductModel() now reads scope internally (Task 9). + _productViewModelServiceMock.Setup(s => s.PrepareAssociatedProductModel()) + .ReturnsAsync(new ProductModel.AddAssociatedProductModel()); + + var result = await _controller.AssociatedProductAddPopup("p1") as ViewResult; + + Assert.IsNotNull(result); + var model = result.Model as ProductModel.AddAssociatedProductModel; + Assert.IsNotNull(model); + Assert.AreEqual("p1", model.ProductId); + _productViewModelServiceMock.Verify(s => s.PrepareAssociatedProductModel(), Times.Once); + } + + // --- AssociatedProductAddPopupList --------------------------------------------------------------- + + [TestMethod] + public async Task AssociatedProductAddPopupList_UsesScopeDefaultStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + _productViewModelServiceMock + .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) + .ReturnsAsync((new List(), 0)); + + var model = new ProductModel.AddAssociatedProductModel(); + var result = await _controller.AssociatedProductAddPopupList( + new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); + + Assert.IsInstanceOfType(result); + Assert.AreEqual("store-1", model.SearchStoreId); + } + + [TestMethod] + public async Task AssociatedProductAddPopupList_NoDefaultStoreId_DoesNotOverrideModelSearchStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + _productViewModelServiceMock + .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) + .ReturnsAsync((new List(), 0)); + + var model = new ProductModel.AddAssociatedProductModel { SearchStoreId = "explicit" }; + var result = await _controller.AssociatedProductAddPopupList( + new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); + + Assert.IsInstanceOfType(result); + Assert.AreEqual("explicit", model.SearchStoreId); + } + + // --- AssociatedProductAddPopup (POST) ------------------------------------------------------------ + // HasAccess (strict) on the parent product: closes a real gap - Vendor's controller had no ownership + // check on the parent product (model.ProductId) at all, in either layer. Vendor's own (host-specific) + // service already filtered each selected product via HasAccessToProduct before reparenting it + // (Grand.Web.Vendor/Services/ProductViewModelService.cs InsertAssociatedProductModel), but the parent + // was never checked anywhere, letting a vendor attach their own products under another vendor's + // grouped product. This controller-level fix (parent HasAccess + per-selected-id HasAccess, matching + // Store's original controller-level filtering) is necessary regardless of that pre-existing severity, + // since BaseProductController uses AdminShared's unfiltered IProductViewModelService - once Vendor is + // subclassed onto this base (Task 11), it loses its own service's per-id filter entirely, so the + // controller must enforce both checks itself. + + [TestMethod] + public async Task AssociatedProductAddPopupPost_ScopeDeniesAccessToParent_ReturnsContentMessage_DoesNotInsert() + { + var parent = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(parent); + _scopeMock.Setup(s => s.HasAccess(parent)).ReturnsAsync(false); + var model = new ProductModel.AddAssociatedProductModel { ProductId = "p1", SelectedProductIds = ["a1"] }; + + var result = await _controller.AssociatedProductAddPopup(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.InsertAssociatedProductModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task AssociatedProductAddPopupPost_ScopeGrantsAccess_AllSelectedIdsAllowed_InsertsAll() + { + var parent = new Product { Id = "p1" }; + var selected = new Product { Id = "a1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(parent); + _productServiceMock.Setup(p => p.GetProductById("a1", false)).ReturnsAsync(selected); + _scopeMock.Setup(s => s.HasAccess(parent)).ReturnsAsync(true); + _scopeMock.Setup(s => s.HasAccess(selected)).ReturnsAsync(true); + var model = new ProductModel.AddAssociatedProductModel { ProductId = "p1", SelectedProductIds = ["a1"] }; + + var result = await _controller.AssociatedProductAddPopup(model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("", content.Content); + CollectionAssert.AreEqual(new[] { "a1" }, model.SelectedProductIds); + _productViewModelServiceMock.Verify(s => s.InsertAssociatedProductModel(model), Times.Once); + } + + [TestMethod] + public async Task AssociatedProductAddPopupPost_ScopeGrantsAccessToParent_FiltersOutDeniedSelectedIds() + { + var parent = new Product { Id = "p1" }; + var allowed = new Product { Id = "a1" }; + var denied = new Product { Id = "a2" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(parent); + _productServiceMock.Setup(p => p.GetProductById("a1", false)).ReturnsAsync(allowed); + _productServiceMock.Setup(p => p.GetProductById("a2", false)).ReturnsAsync(denied); + _scopeMock.Setup(s => s.HasAccess(parent)).ReturnsAsync(true); + _scopeMock.Setup(s => s.HasAccess(allowed)).ReturnsAsync(true); + _scopeMock.Setup(s => s.HasAccess(denied)).ReturnsAsync(false); + var model = new ProductModel.AddAssociatedProductModel { ProductId = "p1", SelectedProductIds = ["a1", "a2"] }; + + var result = await _controller.AssociatedProductAddPopup(model); + + Assert.IsInstanceOfType(result); + CollectionAssert.AreEqual(new[] { "a1" }, model.SelectedProductIds); + _productViewModelServiceMock.Verify(s => s.InsertAssociatedProductModel(model), Times.Once); + } + + [TestMethod] + public async Task AssociatedProductAddPopupPost_AllSelectedIdsDenied_DoesNotInsert() + { + var parent = new Product { Id = "p1" }; + var denied = new Product { Id = "a1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(parent); + _productServiceMock.Setup(p => p.GetProductById("a1", false)).ReturnsAsync(denied); + _scopeMock.Setup(s => s.HasAccess(parent)).ReturnsAsync(true); + _scopeMock.Setup(s => s.HasAccess(denied)).ReturnsAsync(false); + var model = new ProductModel.AddAssociatedProductModel { ProductId = "p1", SelectedProductIds = ["a1"] }; + + var result = await _controller.AssociatedProductAddPopup(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.InsertAssociatedProductModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task AssociatedProductAddPopupPost_ScopeGrantsAccess_InvalidModel_ReturnsViewWithRepreparedModel() + { + var parent = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(parent); + _scopeMock.Setup(s => s.HasAccess(parent)).ReturnsAsync(true); + var reprepared = new ProductModel.AddAssociatedProductModel(); + _productViewModelServiceMock.Setup(s => s.PrepareAssociatedProductModel()).ReturnsAsync(reprepared); + var model = new ProductModel.AddAssociatedProductModel { ProductId = "p1" }; + _controller.ModelState.AddModelError("x", "error"); + + var result = await _controller.AssociatedProductAddPopup(model); + + var view = result as ViewResult; + Assert.IsNotNull(view); + Assert.AreSame(reprepared, view.Model); + _productViewModelServiceMock.Verify(s => s.InsertAssociatedProductModel(It.IsAny()), Times.Never); + } +} diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductAttributeCombinationControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductAttributeCombinationControllerTests.cs new file mode 100644 index 000000000..1addc05ff --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductAttributeCombinationControllerTests.cs @@ -0,0 +1,542 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.AdminShared.Models.Orders; +using Grand.Web.Common.DataSource; +using Grand.Web.Common.Localization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using System.Linq.Expressions; + +namespace Grand.Web.Admin.Tests.Controllers; + +[TestClass] +public class BaseProductAttributeCombinationControllerTests : ProductControllerTestsBase +{ + // BaseProductAttributeCombinationController is abstract. This minimal subclass exists only so the actions under test + // can be invoked directly. + public class TestProductAttributeCombinationController( + IProductViewModelService productViewModelService, + IProductService productService, + IInventoryManageService inventoryManageService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseProductAttributeCombinationController(productViewModelService, productService, inventoryManageService, translationService, scope); + + protected override TestProductAttributeCombinationController CreateController() => new( + _productViewModelServiceMock.Object, + _productServiceMock.Object, + _inventoryManageServiceMock.Object, + _translationServiceMock.Object, + _scopeMock.Object); + + // --- ProductAttributeCombinationList (POST) ------------------------------------------------------- + + [TestMethod] + public async Task ProductAttributeCombinationList_ScopeDeniesAccess_ReturnsErrorJson() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); + + var result = await _controller.ProductAttributeCombinationList(new DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.PrepareProductAttributeCombinationModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductAttributeCombinationList_ScopeGrantsAccess_ReturnsGrid() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); + _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeCombinationModel(product)) + .ReturnsAsync(new List { new() }); + + var result = await _controller.ProductAttributeCombinationList(new DataSourceRequest(), "p1"); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as DataSourceResult; + Assert.AreEqual(1, gridModel.Total); + } + + // --- ProductAttributeCombinationDelete ------------------------------------------------------------- + + [TestMethod] + public async Task ProductAttributeCombinationDelete_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); + var attrServiceMock = new Mock(); + + await Assert.ThrowsExactlyAsync( + () => _controller.ProductAttributeCombinationDelete("c1", "missing", attrServiceMock.Object)); + } + + [TestMethod] + public async Task ProductAttributeCombinationDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + product.ProductAttributeCombinations.Add(new ProductAttributeCombination { Id = "c1" }); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var attrServiceMock = new Mock(); + + var result = await _controller.ProductAttributeCombinationDelete("c1", "p1", attrServiceMock.Object); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + attrServiceMock.Verify( + s => s.DeleteProductAttributeCombination(It.IsAny(), It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task ProductAttributeCombinationDelete_ScopeGrantsAccess_CombinationMissing_Throws() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var attrServiceMock = new Mock(); + + await Assert.ThrowsExactlyAsync( + () => _controller.ProductAttributeCombinationDelete("missing-c", "p1", attrServiceMock.Object)); + } + + [TestMethod] + public async Task ProductAttributeCombinationDelete_ScopeGrantsAccess_Deletes() + { + var product = new Product { Id = "p1", ManageInventoryMethodId = ManageInventoryMethod.DontManageStock }; + var combination = new ProductAttributeCombination { Id = "c1" }; + product.ProductAttributeCombinations.Add(combination); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var attrServiceMock = new Mock(); + + var result = await _controller.ProductAttributeCombinationDelete("c1", "p1", attrServiceMock.Object); + + Assert.IsInstanceOfType(result); + attrServiceMock.Verify(s => s.DeleteProductAttributeCombination(combination, "p1"), Times.Once); + } + + // --- AttributeCombinationPopup (GET) --------------------------------------------------------------- + + [TestMethod] + public async Task AttributeCombinationPopupGet_ScopeDeniesAccess_ReturnsPermissionsContent() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.AttributeCombinationPopup("p1", "c1"); + + var content = result as ContentResult; + Assert.IsNotNull(content); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.PrepareProductAttributeCombinationModel(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task AttributeCombinationPopupGet_ScopeGrantsAccess_ReturnsView() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductAttributeCombinationModel { Id = "c1" }; + _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeCombinationModel(product, "c1")) + .ReturnsAsync(model); + + var result = await _controller.AttributeCombinationPopup("p1", "c1"); + + var view = result as ViewResult; + Assert.IsNotNull(view); + Assert.AreSame(model, view.Model); + _productViewModelServiceMock.Verify(s => s.PrepareAddProductAttributeCombinationModel(model, product), + Times.Once); + } + + // --- AttributeCombinationPopup (POST) -------------------------------------------------------------- + + [TestMethod] + public async Task AttributeCombinationPopupPost_MissingProduct_RedirectsToList() + { + _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); + var model = new ProductAttributeCombinationModel(); + + var result = await _controller.AttributeCombinationPopup("missing", model); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("List", redirect.ActionName); + _scopeMock.Verify(s => s.HasAccess(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task AttributeCombinationPopupPost_ScopeDeniesAccess_ReturnsPermissionsContent() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductAttributeCombinationModel(); + + var result = await _controller.AttributeCombinationPopup("p1", model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.InsertOrUpdateProductAttributeCombinationPopup(It.IsAny(), + It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task AttributeCombinationPopupPost_ScopeGrantsAccess_NoWarnings_ReturnsEmptyContent() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductAttributeCombinationModel(); + _productViewModelServiceMock.Setup(s => s.InsertOrUpdateProductAttributeCombinationPopup(product, model)) + .ReturnsAsync(new List()); + + var result = await _controller.AttributeCombinationPopup("p1", model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("", content.Content); + } + + [TestMethod] + public async Task AttributeCombinationPopupPost_ScopeGrantsAccess_WithWarnings_ReturnsViewWithWarnings() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductAttributeCombinationModel(); + _productViewModelServiceMock.Setup(s => s.InsertOrUpdateProductAttributeCombinationPopup(product, model)) + .ReturnsAsync(new List { "warning" }); + + var result = await _controller.AttributeCombinationPopup("p1", model); + + var view = result as ViewResult; + Assert.IsNotNull(view); + Assert.AreSame(model, view.Model); + CollectionAssert.Contains(model.Warnings.ToList(), "warning"); + _productViewModelServiceMock.Verify(s => s.PrepareAddProductAttributeCombinationModel(model, product), + Times.Once); + } + + // --- GenerateAllAttributeCombinations --------------------------------------------------------------- + + [TestMethod] + public async Task GenerateAllAttributeCombinations_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); + + await Assert.ThrowsExactlyAsync( + () => _controller.GenerateAllAttributeCombinations("missing")); + } + + [TestMethod] + public async Task GenerateAllAttributeCombinations_ScopeDeniesAccess_ReturnsErrorJson_DoesNotGenerate() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.GenerateAllAttributeCombinations("p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.GenerateAllAttributeCombinations(It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task GenerateAllAttributeCombinations_ScopeGrantsAccess_Generates() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + + var result = await _controller.GenerateAllAttributeCombinations("p1"); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.GenerateAllAttributeCombinations(product), Times.Once); + } + + // --- ClearAllAttributeCombinations ----------------------------------------------------------------- + + [TestMethod] + public async Task ClearAllAttributeCombinations_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); + + await Assert.ThrowsExactlyAsync( + () => _controller.ClearAllAttributeCombinations("missing")); + } + + [TestMethod] + public async Task ClearAllAttributeCombinations_ScopeDeniesAccess_ReturnsErrorJson_DoesNotClear() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ClearAllAttributeCombinations("p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.ClearAllAttributeCombinations(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ClearAllAttributeCombinations_ScopeGrantsAccess_ValidModel_Clears() + { + var product = new Product + { Id = "p1", ManageInventoryMethodId = ManageInventoryMethod.DontManageStock }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + + var result = await _controller.ClearAllAttributeCombinations("p1"); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.ClearAllAttributeCombinations(product), Times.Once); + } + + [TestMethod] + public async Task ClearAllAttributeCombinations_ScopeGrantsAccess_InvalidModelState_ReturnsErrorJson_DoesNotClear() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _controller.ModelState.AddModelError("x", "err"); + + var result = await _controller.ClearAllAttributeCombinations("p1"); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.ClearAllAttributeCombinations(It.IsAny()), Times.Never); + } + + // --- ProductAttributeCombinationTierPriceList (POST) ----------------------------------------------- + + [TestMethod] + public async Task ProductAttributeCombinationTierPriceList_ScopeDeniesAccess_ReturnsErrorJson() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ProductAttributeCombinationTierPriceList(new DataSourceRequest(), "p1", "c1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.PrepareProductAttributeCombinationTierPricesModel(It.IsAny(), It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task ProductAttributeCombinationTierPriceList_ScopeGrantsAccess_ReturnsGrid() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeCombinationTierPricesModel(product, "c1")) + .ReturnsAsync(new List { new() }); + + var result = await _controller.ProductAttributeCombinationTierPriceList(new DataSourceRequest(), "p1", "c1"); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as DataSourceResult; + Assert.AreEqual(1, gridModel.Total); + } + + // --- ProductAttributeCombinationTierPriceInsert ----------------------------------------------------- + + [TestMethod] + public async Task ProductAttributeCombinationTierPriceInsert_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); + + await Assert.ThrowsExactlyAsync( + () => _controller.ProductAttributeCombinationTierPriceInsert("missing", "c1", + new ProductModel.ProductAttributeCombinationTierPricesModel())); + } + + [TestMethod] + public async Task ProductAttributeCombinationTierPriceInsert_ScopeDeniesAccess_ReturnsContent_DoesNotInsert() + { + var product = new Product { Id = "p1" }; + product.ProductAttributeCombinations.Add(new ProductAttributeCombination { Id = "c1" }); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ProductAttributeCombinationTierPriceInsert("p1", "c1", + new ProductModel.ProductAttributeCombinationTierPricesModel()); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.InsertProductAttributeCombinationTierPricesModel(It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductAttributeCombinationTierPriceInsert_ScopeGrantsAccess_CombinationMissing_DoesNotInsert() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + + var result = await _controller.ProductAttributeCombinationTierPriceInsert("p1", "missing-c", + new ProductModel.ProductAttributeCombinationTierPricesModel()); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify( + s => s.InsertProductAttributeCombinationTierPricesModel(It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductAttributeCombinationTierPriceInsert_ScopeGrantsAccess_Inserts() + { + var product = new Product { Id = "p1" }; + var combination = new ProductAttributeCombination { Id = "c1" }; + product.ProductAttributeCombinations.Add(combination); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductAttributeCombinationTierPricesModel(); + + var result = await _controller.ProductAttributeCombinationTierPriceInsert("p1", "c1", model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify( + s => s.InsertProductAttributeCombinationTierPricesModel(product, combination, model), Times.Once); + } + + // --- ProductAttributeCombinationTierPriceUpdate ----------------------------------------------------- + + [TestMethod] + public async Task ProductAttributeCombinationTierPriceUpdate_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); + + await Assert.ThrowsExactlyAsync( + () => _controller.ProductAttributeCombinationTierPriceUpdate("missing", "c1", + new ProductModel.ProductAttributeCombinationTierPricesModel())); + } + + [TestMethod] + public async Task ProductAttributeCombinationTierPriceUpdate_ScopeDeniesAccess_ReturnsContent_DoesNotUpdate() + { + var product = new Product { Id = "p1" }; + product.ProductAttributeCombinations.Add(new ProductAttributeCombination { Id = "c1" }); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ProductAttributeCombinationTierPriceUpdate("p1", "c1", + new ProductModel.ProductAttributeCombinationTierPricesModel()); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.UpdateProductAttributeCombinationTierPricesModel(It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductAttributeCombinationTierPriceUpdate_ScopeGrantsAccess_Updates() + { + var product = new Product { Id = "p1" }; + var combination = new ProductAttributeCombination { Id = "c1" }; + product.ProductAttributeCombinations.Add(combination); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductAttributeCombinationTierPricesModel(); + + var result = await _controller.ProductAttributeCombinationTierPriceUpdate("p1", "c1", model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify( + s => s.UpdateProductAttributeCombinationTierPricesModel(product, combination, model), Times.Once); + } + + // --- ProductAttributeCombinationTierPriceDelete ----------------------------------------------------- + + [TestMethod] + public async Task ProductAttributeCombinationTierPriceDelete_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); + + await Assert.ThrowsExactlyAsync( + () => _controller.ProductAttributeCombinationTierPriceDelete("missing", "c1", "tp1")); + } + + [TestMethod] + public async Task ProductAttributeCombinationTierPriceDelete_ScopeDeniesAccess_ReturnsContent_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + product.ProductAttributeCombinations.Add(new ProductAttributeCombination { Id = "c1" }); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ProductAttributeCombinationTierPriceDelete("p1", "c1", "tp1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.DeleteProductAttributeCombinationTierPrices(It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductAttributeCombinationTierPriceDelete_ScopeGrantsAccess_TierPriceMissing_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + product.ProductAttributeCombinations.Add(new ProductAttributeCombination { Id = "c1" }); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + + var result = await _controller.ProductAttributeCombinationTierPriceDelete("p1", "c1", "missing-tp"); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify( + s => s.DeleteProductAttributeCombinationTierPrices(It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductAttributeCombinationTierPriceDelete_ScopeGrantsAccess_Deletes() + { + var product = new Product { Id = "p1" }; + var combination = new ProductAttributeCombination { Id = "c1" }; + var tierPrice = new ProductCombinationTierPrices { Id = "tp1" }; + combination.TierPrices.Add(tierPrice); + product.ProductAttributeCombinations.Add(combination); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + + var result = await _controller.ProductAttributeCombinationTierPriceDelete("p1", "c1", "tp1"); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify( + s => s.DeleteProductAttributeCombinationTierPrices(product, combination, tierPrice), Times.Once); + } +} diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductAttributeMappingControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductAttributeMappingControllerTests.cs new file mode 100644 index 000000000..5f44fcffa --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductAttributeMappingControllerTests.cs @@ -0,0 +1,1157 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Domain; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.AdminShared.Models.Orders; +using Grand.Web.Common.DataSource; +using Grand.Web.Common.Localization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using System.Linq.Expressions; + +namespace Grand.Web.Admin.Tests.Controllers; + +[TestClass] +public class BaseProductAttributeMappingControllerTests : ProductControllerTestsBase +{ + // BaseProductAttributeMappingController is abstract. This minimal subclass exists only so the actions under test + // can be invoked directly. + public class TestProductAttributeMappingController( + IProductViewModelService productViewModelService, + IProductService productService, + ILanguageService languageService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseProductAttributeMappingController(productViewModelService, productService, languageService, translationService, scope); + + protected override TestProductAttributeMappingController CreateController() => new( + _productViewModelServiceMock.Object, + _productServiceMock.Object, + _languageServiceMock.Object, + _translationServiceMock.Object, + _scopeMock.Object); + + // --- ProductAttributeMappingList ------------------------------------------------------------- + // HasAccess applied uniformly on every action in this region. Store's original checked ownership + // (CanAccessProduct) on List/PopupGET/PopupPOST/Delete/ValidationRulesPopupGET but not on + // ValidationRulesPopup(POST). Vendor's original checked (CheckAccessToProduct/HasAccessToProduct) on + // List/PopupGET/Delete/ValidationRulesPopupGET, but NOT on ProductAttributeMappingPopup(POST) or + // ValidationRulesPopup(POST) - letting a vendor edit an attribute mapping (or its validation rules) on + // any product, not just their own, by posting a known productId. + + [TestMethod] + public async Task ProductAttributeMappingList_ScopeDeniesAccess_ReturnsErrorJson() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); + + var result = await _controller.ProductAttributeMappingList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.PrepareProductAttributeMappingModels(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductAttributeMappingList_ScopeDeniesAccess_UsesScopeResourceKeyPrefix() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); + _scopeMock.Setup(s => s.ResourceKeyPrefix).Returns("Vendor"); + + var result = await _controller.ProductAttributeMappingList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Vendor.Catalog.Products.Permissions"), Times.Once); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Never); + } + + [TestMethod] + public async Task ProductAttributeMappingList_ScopeGrantsAccess_ReturnsGrid() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); + var attributes = new List { new() { Id = "pam1" } }; + _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeMappingModels(product)) + .ReturnsAsync(attributes); + + var result = await _controller.ProductAttributeMappingList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(1, gridModel.Total); + } + + // --- ProductAttributeMappingPopup (GET) ------------------------------------------------------ + + [TestMethod] + public async Task ProductAttributeMappingPopup_Get_ScopeDeniesAccess_ReturnsPermissionsContent() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ProductAttributeMappingPopup("p1", null); + + var content = result as ContentResult; + Assert.IsNotNull(content); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.PrepareProductAttributeMappingModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductAttributeMappingPopup_Get_ScopeGrantsAccess_NoMappingId_PreparesNewModel() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeMappingModel(product)) + .ReturnsAsync(new ProductModel.ProductAttributeMappingModel()); + + var result = await _controller.ProductAttributeMappingPopup("p1", null); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.PrepareProductAttributeMappingModel(product), Times.Once); + } + + [TestMethod] + public async Task ProductAttributeMappingPopup_Get_ScopeGrantsAccess_MappingId_PreparesEditModel() + { + var mapping = new ProductAttributeMapping { Id = "pam1" }; + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeMappingModel(product, mapping)) + .ReturnsAsync(new ProductModel.ProductAttributeMappingModel()); + + var result = await _controller.ProductAttributeMappingPopup("p1", "pam1"); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.PrepareProductAttributeMappingModel(product, mapping), Times.Once); + } + + // --- ProductAttributeMappingPopup (POST) ----------------------------------------------------- + // HasAccess added here: Vendor's original had no ownership check on this POST at all. + + [TestMethod] + public async Task ProductAttributeMappingPopup_Post_ScopeDeniesAccess_DoesNotInsertOrUpdate() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.ProductAttributeMappingModel { ProductId = "p1" }; + + var result = await _controller.ProductAttributeMappingPopup(model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.InsertProductAttributeMappingModel(It.IsAny()), + Times.Never); + _productViewModelServiceMock.Verify( + s => s.UpdateProductAttributeMappingModel(It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task ProductAttributeMappingPopup_Post_ScopeGrantsAccess_NoId_Inserts() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductAttributeMappingModel { ProductId = "p1" }; + + var result = await _controller.ProductAttributeMappingPopup(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.InsertProductAttributeMappingModel(model), Times.Once); + _productViewModelServiceMock.Verify( + s => s.UpdateProductAttributeMappingModel(It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task ProductAttributeMappingPopup_Post_ScopeGrantsAccess_WithId_Updates() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductAttributeMappingModel { Id = "pam1", ProductId = "p1" }; + + var result = await _controller.ProductAttributeMappingPopup(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.UpdateProductAttributeMappingModel(model), Times.Once); + } + + [TestMethod] + public async Task ProductAttributeMappingPopup_Post_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); + var model = new ProductModel.ProductAttributeMappingModel { ProductId = "missing" }; + + await Assert.ThrowsExactlyAsync(() => _controller.ProductAttributeMappingPopup(model)); + } + + [TestMethod] + public async Task ProductAttributeMappingPopup_Post_InvalidModelState_ReturnsView_DoesNotCheckAccess() + { + _controller.ModelState.AddModelError("x", "err"); + var model = new ProductModel.ProductAttributeMappingModel { ProductId = "p1" }; + _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeMappingModel(model)).ReturnsAsync(model); + + var result = await _controller.ProductAttributeMappingPopup(model); + + Assert.IsInstanceOfType(result); + _productServiceMock.Verify(p => p.GetProductById(It.IsAny(), It.IsAny()), Times.Never); + _scopeMock.Verify(s => s.HasAccess(It.IsAny()), Times.Never); + } + + // --- ProductAttributeMappingDelete ----------------------------------------------------------- + + [TestMethod] + public async Task ProductAttributeMappingDelete_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); + var attrServiceMock = new Mock(); + + await Assert.ThrowsExactlyAsync( + () => _controller.ProductAttributeMappingDelete("pam1", "missing", attrServiceMock.Object)); + } + + [TestMethod] + public async Task ProductAttributeMappingDelete_MappingNotFound_Throws() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + var attrServiceMock = new Mock(); + + await Assert.ThrowsExactlyAsync( + () => _controller.ProductAttributeMappingDelete("missing-pam", "p1", attrServiceMock.Object)); + } + + [TestMethod] + public async Task ProductAttributeMappingDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() + { + var mapping = new ProductAttributeMapping { Id = "pam1" }; + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var attrServiceMock = new Mock(); + + var result = await _controller.ProductAttributeMappingDelete("pam1", "p1", attrServiceMock.Object); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + attrServiceMock.Verify( + s => s.DeleteProductAttributeMapping(It.IsAny(), It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task ProductAttributeMappingDelete_ScopeGrantsAccess_Deletes() + { + var mapping = new ProductAttributeMapping { Id = "pam1" }; + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var attrServiceMock = new Mock(); + + var result = await _controller.ProductAttributeMappingDelete("pam1", "p1", attrServiceMock.Object); + + Assert.IsInstanceOfType(result); + attrServiceMock.Verify(s => s.DeleteProductAttributeMapping(mapping, "p1"), Times.Once); + } + + // --- ProductAttributeValidationRulesPopup (GET) ----------------------------------------------- + + [TestMethod] + public async Task ProductAttributeValidationRulesPopup_Get_ScopeDeniesAccess_ReturnsPermissionsContent() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ProductAttributeValidationRulesPopup("pam1", "p1"); + + var content = result as ContentResult; + Assert.IsNotNull(content); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + } + + [TestMethod] + public async Task ProductAttributeValidationRulesPopup_Get_ScopeGrantsAccess_MappingMissing_ReturnsContent() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + + var result = await _controller.ProductAttributeValidationRulesPopup("missing-pam", "p1"); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("No attribute value found with the specified id", content.Content); + } + + [TestMethod] + public async Task ProductAttributeValidationRulesPopup_Get_ScopeGrantsAccess_PreparesModel() + { + var mapping = new ProductAttributeMapping { Id = "pam1" }; + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeMappingModel(mapping)) + .ReturnsAsync(new ProductModel.ProductAttributeMappingModel()); + + var result = await _controller.ProductAttributeValidationRulesPopup("pam1", "p1"); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.PrepareProductAttributeMappingModel(mapping), Times.Once); + } + + // --- ProductAttributeValidationRulesPopup (POST) ---------------------------------------------- + // HasAccess added here: none of the three original hosts checked ownership on this POST at all - a + // store/vendor user could update an attribute mapping's validation rules on any product by posting a + // known productId/model.Id. + + [TestMethod] + public async Task ProductAttributeValidationRulesPopup_Post_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); + var model = new ProductModel.ProductAttributeMappingModel { ProductId = "missing" }; + + await Assert.ThrowsExactlyAsync( + () => _controller.ProductAttributeValidationRulesPopup(model)); + } + + [TestMethod] + public async Task ProductAttributeValidationRulesPopup_Post_MappingNotFound_Throws() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + var model = new ProductModel.ProductAttributeMappingModel { Id = "missing-pam", ProductId = "p1" }; + + await Assert.ThrowsExactlyAsync( + () => _controller.ProductAttributeValidationRulesPopup(model)); + } + + [TestMethod] + public async Task ProductAttributeValidationRulesPopup_Post_ScopeDeniesAccess_ReturnsPermissionsContent_DoesNotUpdate() + { + var mapping = new ProductAttributeMapping { Id = "pam1" }; + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.ProductAttributeMappingModel { Id = "pam1", ProductId = "p1" }; + + var result = await _controller.ProductAttributeValidationRulesPopup(model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.UpdateProductAttributeValidationRulesModel(It.IsAny(), + It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductAttributeValidationRulesPopup_Post_ScopeGrantsAccess_ValidModel_Updates() + { + var mapping = new ProductAttributeMapping { Id = "pam1" }; + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductAttributeMappingModel { Id = "pam1", ProductId = "p1" }; + + var result = await _controller.ProductAttributeValidationRulesPopup(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify( + s => s.UpdateProductAttributeValidationRulesModel(mapping, model), Times.Once); + } + + [TestMethod] + public async Task ProductAttributeValidationRulesPopup_Post_ScopeGrantsAccess_InvalidModelState_ReturnsView() + { + var mapping = new ProductAttributeMapping { Id = "pam1" }; + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _controller.ModelState.AddModelError("x", "err"); + var model = new ProductModel.ProductAttributeMappingModel { Id = "pam1", ProductId = "p1" }; + _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeMappingModel(mapping)).ReturnsAsync(model); + + var result = await _controller.ProductAttributeValidationRulesPopup(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify( + s => s.UpdateProductAttributeValidationRulesModel(It.IsAny(), + It.IsAny()), Times.Never); + } + + // --- ProductAttributeConditionPopup (GET) ---------------------------------------------------- + // HasAccess added uniformly. Admin's original had no ownership check on either action of this + // region at all. + + [TestMethod] + public async Task ProductAttributeConditionPopup_Get_ScopeDeniesAccess_ReturnsPermissionsContent() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ProductAttributeConditionPopup("p1", "pam1"); + + var content = result as ContentResult; + Assert.IsNotNull(content); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.PrepareProductAttributeConditionModel(It.IsAny(), It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task ProductAttributeConditionPopup_Get_ScopeDeniesAccess_UsesScopeResourceKeyPrefix() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + _scopeMock.Setup(s => s.ResourceKeyPrefix).Returns("Vendor"); + + var result = await _controller.ProductAttributeConditionPopup("p1", "pam1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Vendor.Catalog.Products.Permissions"), Times.Once); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Never); + } + + [TestMethod] + public async Task ProductAttributeConditionPopup_Get_ScopeGrantsAccess_MappingMissing_ReturnsContent() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + + var result = await _controller.ProductAttributeConditionPopup("p1", "missing-pam"); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("No attribute value found with the specified id", content.Content); + _productViewModelServiceMock.Verify( + s => s.PrepareProductAttributeConditionModel(It.IsAny(), It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task ProductAttributeConditionPopup_Get_ScopeGrantsAccess_PreparesModel() + { + var mapping = new ProductAttributeMapping { Id = "pam1" }; + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeConditionModel(product, mapping)) + .ReturnsAsync(new ProductAttributeConditionModel()); + + var result = await _controller.ProductAttributeConditionPopup("p1", "pam1"); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.PrepareProductAttributeConditionModel(product, mapping), + Times.Once); + } + + // --- ProductAttributeConditionPopup (POST) --------------------------------------------------- + // HasAccess added here: Vendor's original never checked ownership on this POST at all (only its + // GET sibling did, via CheckAccessToProduct), letting a vendor update an attribute condition on + // any product by posting a known productId/productAttributeMappingId. Admin's original had no + // check on either action. + + [TestMethod] + public async Task ProductAttributeConditionPopup_Post_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); + var model = new ProductAttributeConditionModel { ProductId = "missing" }; + + await Assert.ThrowsExactlyAsync(() => _controller.ProductAttributeConditionPopup(model)); + } + + [TestMethod] + public async Task ProductAttributeConditionPopup_Post_MappingNotFound_ReturnsContent_DoesNotCheckAccess() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + var model = new ProductAttributeConditionModel { ProductId = "p1", ProductAttributeMappingId = "missing-pam" }; + + var result = await _controller.ProductAttributeConditionPopup(model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("No attribute value found with the specified id", content.Content); + _scopeMock.Verify(s => s.HasAccess(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductAttributeConditionPopup_Post_ScopeDeniesAccess_ReturnsPermissionsContent_DoesNotUpdate() + { + var mapping = new ProductAttributeMapping { Id = "pam1" }; + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductAttributeConditionModel { ProductId = "p1", ProductAttributeMappingId = "pam1" }; + + var result = await _controller.ProductAttributeConditionPopup(model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.UpdateProductAttributeConditionModel(It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductAttributeConditionPopup_Post_ScopeGrantsAccess_Updates() + { + var mapping = new ProductAttributeMapping { Id = "pam1" }; + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductAttributeConditionModel { ProductId = "p1", ProductAttributeMappingId = "pam1" }; + + var result = await _controller.ProductAttributeConditionPopup(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.UpdateProductAttributeConditionModel(product, mapping, model), + Times.Once); + } + + // --- EditAttributeValues (GET) ------------------------------------------------------------------ + // HasAccess applied uniformly. Admin's original had no ownership check on this action at all. + + [TestMethod] + public async Task EditAttributeValues_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); + var attrServiceMock = new Mock(); + + await Assert.ThrowsExactlyAsync( + () => _controller.EditAttributeValues("pam1", "missing", attrServiceMock.Object)); + } + + [TestMethod] + public async Task EditAttributeValues_ScopeDeniesAccess_ReturnsPermissionsContent() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var attrServiceMock = new Mock(); + + var result = await _controller.EditAttributeValues("pam1", "p1", attrServiceMock.Object); + + var content = result as ContentResult; + Assert.IsNotNull(content); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + } + + [TestMethod] + public async Task EditAttributeValues_ScopeGrantsAccess_MappingMissing_Throws() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var attrServiceMock = new Mock(); + + await Assert.ThrowsExactlyAsync( + () => _controller.EditAttributeValues("missing-pam", "p1", attrServiceMock.Object)); + } + + [TestMethod] + public async Task EditAttributeValues_ScopeGrantsAccess_PreparesListModel() + { + var mapping = new ProductAttributeMapping { Id = "pam1", ProductAttributeId = "pa1" }; + var product = new Product { Id = "p1", Name = "Product 1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var attrServiceMock = new Mock(); + attrServiceMock.Setup(s => s.GetProductAttributeById("pa1")) + .ReturnsAsync(new ProductAttribute { Id = "pa1", Name = "Color" }); + + var result = await _controller.EditAttributeValues("pam1", "p1", attrServiceMock.Object); + + var view = result as ViewResult; + Assert.IsNotNull(view); + var model = view.Model as ProductModel.ProductAttributeValueListModel; + Assert.IsNotNull(model); + Assert.AreEqual("p1", model.ProductId); + Assert.AreEqual("Product 1", model.ProductName); + Assert.AreEqual("Color", model.ProductAttributeName); + Assert.AreEqual("pam1", model.ProductAttributeMappingId); + } + + // --- ProductAttributeValueList (POST) ----------------------------------------------------------- + + [TestMethod] + public async Task ProductAttributeValueList_ScopeDeniesAccess_ReturnsErrorJson() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ProductAttributeValueList("pam1", "p1", new Grand.Web.Common.DataSource.DataSourceRequest()); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.PrepareProductAttributeValueModels(It.IsAny(), It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task ProductAttributeValueList_ScopeGrantsAccess_MappingMissing_Throws() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + + await Assert.ThrowsExactlyAsync( + () => _controller.ProductAttributeValueList("missing-pam", "p1", new Grand.Web.Common.DataSource.DataSourceRequest())); + } + + [TestMethod] + public async Task ProductAttributeValueList_ScopeGrantsAccess_ReturnsGrid() + { + var mapping = new ProductAttributeMapping { Id = "pam1" }; + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var values = new List { new() { Id = "pav1" } }; + _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeValueModels(product, mapping)) + .ReturnsAsync(values); + + var result = await _controller.ProductAttributeValueList("pam1", "p1", new Grand.Web.Common.DataSource.DataSourceRequest()); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(1, gridModel.Total); + } + + // --- ProductAttributeValueCreatePopup (GET) ----------------------------------------------------- + + [TestMethod] + public async Task ProductAttributeValueCreatePopup_Get_ScopeDeniesAccess_ReturnsPermissionsContent() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ProductAttributeValueCreatePopup("pam1", "p1"); + + var content = result as ContentResult; + Assert.IsNotNull(content); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.PrepareProductAttributeValueModel(It.IsAny(), It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task ProductAttributeValueCreatePopup_Get_ScopeGrantsAccess_MappingMissing_Throws() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + + await Assert.ThrowsExactlyAsync( + () => _controller.ProductAttributeValueCreatePopup("missing-pam", "p1")); + } + + [TestMethod] + public async Task ProductAttributeValueCreatePopup_Get_ScopeGrantsAccess_PreparesModel() + { + var mapping = new ProductAttributeMapping { Id = "pam1" }; + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeValueModel(product, mapping)) + .ReturnsAsync(new ProductModel.ProductAttributeValueModel()); + + var result = await _controller.ProductAttributeValueCreatePopup("pam1", "p1"); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.PrepareProductAttributeValueModel(product, mapping), Times.Once); + } + + // --- ProductAttributeValueCreatePopup (POST) ---------------------------------------------------- + // HasAccess added explicitly. Admin has no ownership concept at all (GlobalAdminDataScope.HasAccess is + // always true), so Admin was never at risk here. The real risk is to VENDOR: Vendor's original relied + // solely on ProductAttributeValueModel : IProductValidVendor triggering ValidationFilter's + // ProductValidVendor check + Vendor's own `if (ModelState.IsValid)` gate - no explicit action-level + // check. Moving to the shared AdminShared model (no IProductValidVendor) would have silently dropped + // that guard for Vendor; scope.HasAccess replaces it explicitly and uniformly. + + [TestMethod] + public async Task ProductAttributeValueCreatePopup_Post_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); + var model = new ProductModel.ProductAttributeValueModel { ProductId = "missing" }; + + await Assert.ThrowsExactlyAsync( + () => _controller.ProductAttributeValueCreatePopup(model)); + } + + [TestMethod] + public async Task ProductAttributeValueCreatePopup_Post_ScopeDeniesAccess_ReturnsPermissionsContent_DoesNotInsert() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.ProductAttributeValueModel { ProductId = "p1" }; + + var result = await _controller.ProductAttributeValueCreatePopup(model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.InsertProductAttributeValueModel(It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task ProductAttributeValueCreatePopup_Post_ScopeDeniesAccess_UsesVendorResourceKeyPrefix() + { + // The host/action combination this row's fix actually protects: Vendor losing its + // IProductValidVendor-driven guard in the merge. See the comment above the HasAccess check in + // BaseProductController.ProductAttributeValueCreatePopup(POST). + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + _scopeMock.Setup(s => s.ResourceKeyPrefix).Returns("Vendor"); + var model = new ProductModel.ProductAttributeValueModel { ProductId = "p1" }; + + var result = await _controller.ProductAttributeValueCreatePopup(model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + _translationServiceMock.Verify(t => t.GetResource("Vendor.Catalog.Products.Permissions"), Times.Once); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Never); + _productViewModelServiceMock.Verify( + s => s.InsertProductAttributeValueModel(It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task ProductAttributeValueCreatePopup_Post_ScopeGrantsAccess_MappingMissing_RedirectsToList() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductAttributeValueModel { ProductId = "p1", ProductAttributeMappingId = "missing-pam" }; + + var result = await _controller.ProductAttributeValueCreatePopup(model); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("List", redirect.ActionName); + Assert.AreEqual("Product", redirect.ControllerName); + } + + [TestMethod] + public async Task ProductAttributeValueCreatePopup_Post_ScopeGrantsAccess_ValidModel_Inserts() + { + var mapping = new ProductAttributeMapping { Id = "pam1" }; + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductAttributeValueModel { ProductId = "p1", ProductAttributeMappingId = "pam1" }; + + var result = await _controller.ProductAttributeValueCreatePopup(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.InsertProductAttributeValueModel(model), Times.Once); + } + + [TestMethod] + public async Task ProductAttributeValueCreatePopup_Post_ScopeGrantsAccess_InvalidModelState_ReturnsView() + { + var mapping = new ProductAttributeMapping { Id = "pam1" }; + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _controller.ModelState.AddModelError("x", "err"); + var model = new ProductModel.ProductAttributeValueModel { ProductId = "p1", ProductAttributeMappingId = "pam1" }; + + var result = await _controller.ProductAttributeValueCreatePopup(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify( + s => s.InsertProductAttributeValueModel(It.IsAny()), + Times.Never); + _productViewModelServiceMock.Verify(s => s.PrepareProductAttributeValueModel(product, model), Times.Once); + } + + // --- ProductAttributeValueEditPopup (GET) ------------------------------------------------------- + + [TestMethod] + public async Task ProductAttributeValueEditPopup_Get_ScopeDeniesAccess_ReturnsPermissionsContent() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ProductAttributeValueEditPopup("pav1", "p1", "pam1"); + + var content = result as ContentResult; + Assert.IsNotNull(content); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + } + + [TestMethod] + public async Task ProductAttributeValueEditPopup_Get_ScopeGrantsAccess_MappingMissing_RedirectsToList() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + + var result = await _controller.ProductAttributeValueEditPopup("pav1", "p1", "missing-pam"); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("List", redirect.ActionName); + } + + [TestMethod] + public async Task ProductAttributeValueEditPopup_Get_ScopeGrantsAccess_ValueMissing_RedirectsToList() + { + var mapping = new ProductAttributeMapping { Id = "pam1" }; + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + + var result = await _controller.ProductAttributeValueEditPopup("missing-pav", "p1", "pam1"); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("List", redirect.ActionName); + } + + [TestMethod] + public async Task ProductAttributeValueEditPopup_Get_ScopeGrantsAccess_PreparesModel() + { + var pav = new ProductAttributeValue { Id = "pav1" }; + var mapping = new ProductAttributeMapping { Id = "pam1" }; + mapping.ProductAttributeValues.Add(pav); + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductAttributeValueModel(); + _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeValueModel(mapping, pav)) + .ReturnsAsync(model); + + var result = await _controller.ProductAttributeValueEditPopup("pav1", "p1", "pam1"); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.PrepareProductAttributeValueModel(mapping, pav), Times.Once); + _productViewModelServiceMock.Verify(s => s.PrepareProductAttributeValueModel(product, model), Times.Once); + } + + // --- ProductAttributeValueEditPopup (POST) ------------------------------------------------------ + // HasAccess added here: Vendor's original never checked ownership on this POST at all (only its GET + // sibling did); Admin's original had no check on either action. + + [TestMethod] + public async Task ProductAttributeValueEditPopup_Post_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); + var model = new ProductModel.ProductAttributeValueModel(); + + await Assert.ThrowsExactlyAsync( + () => _controller.ProductAttributeValueEditPopup("missing", model)); + } + + [TestMethod] + public async Task ProductAttributeValueEditPopup_Post_ScopeDeniesAccess_ReturnsPermissionsContent_DoesNotUpdate() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.ProductAttributeValueModel { Id = "pav1", ProductAttributeMappingId = "pam1" }; + + var result = await _controller.ProductAttributeValueEditPopup("p1", model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.UpdateProductAttributeValueModel(It.IsAny(), + It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductAttributeValueEditPopup_Post_ScopeGrantsAccess_ValueMissing_RedirectsToList() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductAttributeValueModel { Id = "missing-pav", ProductAttributeMappingId = "pam1" }; + + var result = await _controller.ProductAttributeValueEditPopup("p1", model); + + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("List", redirect.ActionName); + } + + [TestMethod] + public async Task ProductAttributeValueEditPopup_Post_ScopeGrantsAccess_ValidModel_Updates() + { + var pav = new ProductAttributeValue { Id = "pav1" }; + var mapping = new ProductAttributeMapping { Id = "pam1" }; + mapping.ProductAttributeValues.Add(pav); + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductAttributeValueModel { Id = "pav1", ProductAttributeMappingId = "pam1" }; + + var result = await _controller.ProductAttributeValueEditPopup("p1", model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.UpdateProductAttributeValueModel(pav, model), Times.Once); + } + + [TestMethod] + public async Task ProductAttributeValueEditPopup_Post_ScopeGrantsAccess_InvalidModelState_ReturnsView() + { + var pav = new ProductAttributeValue { Id = "pav1" }; + var mapping = new ProductAttributeMapping { Id = "pam1" }; + mapping.ProductAttributeValues.Add(pav); + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _controller.ModelState.AddModelError("x", "err"); + var model = new ProductModel.ProductAttributeValueModel { Id = "pav1", ProductAttributeMappingId = "pam1" }; + + var result = await _controller.ProductAttributeValueEditPopup("p1", model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify( + s => s.UpdateProductAttributeValueModel(It.IsAny(), + It.IsAny()), Times.Never); + _productViewModelServiceMock.Verify(s => s.PrepareProductAttributeValueModel(product, model), Times.Once); + } + + // --- ProductAttributeValueDelete ----------------------------------------------------------------- + // HasAccess added here: this action takes only simple string parameters, so no model-level validator + // ever runs for it. Admin's original had no ownership check at all. + + [TestMethod] + public async Task ProductAttributeValueDelete_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); + var attrServiceMock = new Mock(); + + await Assert.ThrowsExactlyAsync( + () => _controller.ProductAttributeValueDelete("pav1", "pam1", "missing", attrServiceMock.Object)); + } + + [TestMethod] + public async Task ProductAttributeValueDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var attrServiceMock = new Mock(); + + var result = await _controller.ProductAttributeValueDelete("pav1", "pam1", "p1", attrServiceMock.Object); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + attrServiceMock.Verify( + s => s.DeleteProductAttributeValue(It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductAttributeValueDelete_ScopeGrantsAccess_ValueMissing_Throws() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var attrServiceMock = new Mock(); + + await Assert.ThrowsExactlyAsync( + () => _controller.ProductAttributeValueDelete("missing-pav", "pam1", "p1", attrServiceMock.Object)); + } + + [TestMethod] + public async Task ProductAttributeValueDelete_ScopeGrantsAccess_ValidModel_Deletes() + { + var pav = new ProductAttributeValue { Id = "pav1" }; + var mapping = new ProductAttributeMapping { Id = "pam1" }; + mapping.ProductAttributeValues.Add(pav); + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var attrServiceMock = new Mock(); + + var result = await _controller.ProductAttributeValueDelete("pav1", "pam1", "p1", attrServiceMock.Object); + + Assert.IsInstanceOfType(result); + attrServiceMock.Verify(s => s.DeleteProductAttributeValue(pav, "p1", "pam1"), Times.Once); + } + + [TestMethod] + public async Task ProductAttributeValueDelete_ScopeGrantsAccess_InvalidModelState_ReturnsErrorJson_DoesNotDelete() + { + var pav = new ProductAttributeValue { Id = "pav1" }; + var mapping = new ProductAttributeMapping { Id = "pam1" }; + mapping.ProductAttributeValues.Add(pav); + var product = new Product { Id = "p1" }; + product.ProductAttributeMappings.Add(mapping); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _controller.ModelState.AddModelError("x", "err"); + var attrServiceMock = new Mock(); + + var result = await _controller.ProductAttributeValueDelete("pav1", "pam1", "p1", attrServiceMock.Object); + + Assert.IsInstanceOfType(result); + attrServiceMock.Verify( + s => s.DeleteProductAttributeValue(It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + } + + // --- AssociateProductToAttributeValuePopup (GET) ------------------------------------------------ + + [TestMethod] + public async Task AssociateProductToAttributeValuePopup_Get_CallsPrepareAssociateProductToAttributeValueModel() + { + // PrepareAssociateProductToAttributeValueModel() now reads scope internally (Task 9). + _productViewModelServiceMock.Setup(s => s.PrepareAssociateProductToAttributeValueModel()) + .ReturnsAsync(new ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel()); + + var result = await _controller.AssociateProductToAttributeValuePopup(); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.PrepareAssociateProductToAttributeValueModel(), + Times.Once); + } + + // --- AssociateProductToAttributeValuePopupList (POST) ------------------------------------------- + + [TestMethod] + public async Task AssociateProductToAttributeValuePopupList_DefaultStoreIdSet_AppliesSearchStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store1"); + var model = new ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel(); + _productViewModelServiceMock.Setup(s => s.PrepareProductModel(model, 1, 10)) + .ReturnsAsync((new List(), 0)); + + var result = await _controller.AssociateProductToAttributeValuePopupList( + new Grand.Web.Common.DataSource.DataSourceRequest { Page = 1, PageSize = 10 }, model); + + Assert.IsInstanceOfType(result); + Assert.AreEqual("store1", model.SearchStoreId); + } + + [TestMethod] + public async Task AssociateProductToAttributeValuePopupList_DefaultStoreIdNull_LeavesSearchStoreIdUntouched() + { + var model = new ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel + { SearchStoreId = "preset" }; + _productViewModelServiceMock.Setup(s => s.PrepareProductModel(model, 1, 10)) + .ReturnsAsync((new List(), 0)); + + var result = await _controller.AssociateProductToAttributeValuePopupList( + new Grand.Web.Common.DataSource.DataSourceRequest { Page = 1, PageSize = 10 }, model); + + Assert.IsInstanceOfType(result); + Assert.AreEqual("preset", model.SearchStoreId); + } + + // --- AssociateProductToAttributeValuePopup (POST) ----------------------------------------------- + // HasAccess added here: Admin's original had no ownership check on the associated product at all. + + [TestMethod] + public async Task AssociateProductToAttributeValuePopup_Post_AssociatedProductMissing_ReturnsContent() + { + _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); + var model = new ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel + { AssociatedToProductId = "missing" }; + + var result = await _controller.AssociateProductToAttributeValuePopup(model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("Cannot load a product", content.Content); + _scopeMock.Verify(s => s.HasAccess(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task AssociateProductToAttributeValuePopup_Post_ScopeDeniesAccess_ReturnsPermissionsContent() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel + { AssociatedToProductId = "p1" }; + + var result = await _controller.AssociateProductToAttributeValuePopup(model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + } + + [TestMethod] + public async Task AssociateProductToAttributeValuePopup_Post_ScopeGrantsAccess_ReturnsEmptyContent() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel + { AssociatedToProductId = "p1" }; + + var result = await _controller.AssociateProductToAttributeValuePopup(model); + + Assert.IsInstanceOfType(result); + var content = result as ContentResult; + Assert.AreEqual("", content.Content); + } +} diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductBidControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductBidControllerTests.cs new file mode 100644 index 000000000..328524115 --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductBidControllerTests.cs @@ -0,0 +1,212 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.AdminShared.Models.Orders; +using Grand.Web.Common.DataSource; +using Grand.Web.Common.Localization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using System.Linq.Expressions; + +namespace Grand.Web.Admin.Tests.Controllers; + +[TestClass] +public class BaseProductBidControllerTests : ProductControllerTestsBase +{ + // BaseProductBidController is abstract. This minimal subclass exists only so the actions under test + // can be invoked directly. + public class TestProductBidController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IAuctionService auctionService, + IAdminDataScope scope) + : BaseProductBidController(productViewModelService, productService, translationService, auctionService, scope); + + protected override TestProductBidController CreateController() => new( + _productViewModelServiceMock.Object, + _productServiceMock.Object, + _translationServiceMock.Object, + _auctionServiceMock.Object, + _scopeMock.Object); + + private static Bid NewBid(string id, string productId, string orderId = "") => + new() { Id = id, ProductId = productId, OrderId = orderId, Date = DateTime.UtcNow }; + + // --- ListBids -------------------------------------------------------------------------------- + + [TestMethod] + public async Task ListBids_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing")).ReturnsAsync((Product)null); + + await Assert.ThrowsExactlyAsync( + () => _controller.ListBids(new Grand.Web.Common.DataSource.DataSourceRequest(), "missing")); + } + + [TestMethod] + public async Task ListBids_ScopeDeniesAccess_ReturnsKendoGridError_DoesNotQuery() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); + + var result = await _controller.ListBids(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.PrepareBidMode(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ListBids_ScopeGrantsAccess_ReturnsBids() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _productViewModelServiceMock.Setup(s => s.PrepareBidMode("p1", 0, 10)) + .ReturnsAsync((Enumerable.Empty(), 0)); + + var result = await _controller.ListBids( + new Grand.Web.Common.DataSource.DataSourceRequest { PageSize = 10 }, "p1"); + + Assert.IsInstanceOfType(result); + var json = result as JsonResult; + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(0, gridModel.Total); + } + + // A product shared with other stores opens read-only on Store: the tab loads, but the bids + // (other stores' customers) are not listed. + [TestMethod] + public async Task ListBids_ViewableButNotOwned_ReturnsEmptyGrid_DoesNotQuery() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ListBids(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + var gridModel = (result as JsonResult)?.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(0, gridModel.Total); + _translationServiceMock.Verify(t => t.GetResource(It.IsAny()), Times.Never); + _productViewModelServiceMock.Verify( + s => s.PrepareBidMode(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + // --- BidDelete --------------------------------------------------------------------------------- + + [TestMethod] + public async Task BidDelete_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing")).ReturnsAsync((Product)null); + var model = new ProductModel.BidModel { ProductId = "missing", BidId = "b1" }; + + await Assert.ThrowsExactlyAsync(() => _controller.BidDelete(model)); + } + + [TestMethod] + public async Task BidDelete_ScopeDeniesAccess_ReturnsKendoGridError_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.BidModel { ProductId = "p1", BidId = "b1" }; + + var result = await _controller.BidDelete(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _auctionServiceMock.Verify(s => s.DeleteBid(It.IsAny()), Times.Never); + } + + // Regression guard for the same cross-product IDOR shape as ProductReservationDelete above (found by + // the row 23 reviewer to apply identically here): the access check ran against model.ProductId while + // the delete ran against model.BidId, with no check that the two referred to the same product. A + // caller with access to *any* product could pass that product's id for the access check while + // supplying the BidId of a bid belonging to a different, unowned product, deleting it. + [TestMethod] + public async Task BidDelete_BidBelongsToDifferentProduct_ReturnsKendoGridError_DoesNotDelete() + { + var ownedProduct = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(ownedProduct); + _scopeMock.Setup(s => s.HasAccess(ownedProduct)).ReturnsAsync(true); + var foreignBid = NewBid("b1", "p2"); + _auctionServiceMock.Setup(s => s.GetBid("b1")).ReturnsAsync(foreignBid); + var model = new ProductModel.BidModel { ProductId = "p1", BidId = "b1" }; + + var result = await _controller.BidDelete(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _auctionServiceMock.Verify(s => s.DeleteBid(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task BidDelete_BidHasOrder_ReturnsErrorsJson_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var bid = NewBid("b1", "p1", "order1"); + _auctionServiceMock.Setup(s => s.GetBid("b1")).ReturnsAsync(bid); + var model = new ProductModel.BidModel { ProductId = "p1", BidId = "b1" }; + + var result = await _controller.BidDelete(model); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.IsNotNull(gridModel.Errors); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Bids.CantDeleteWithOrder"), Times.Once); + _auctionServiceMock.Verify(s => s.DeleteBid(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task BidDelete_ScopeGrantsAccess_SameProduct_Deletes() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var bid = NewBid("b1", "p1"); + _auctionServiceMock.Setup(s => s.GetBid("b1")).ReturnsAsync(bid); + var model = new ProductModel.BidModel { ProductId = "p1", BidId = "b1" }; + + var result = await _controller.BidDelete(model); + + Assert.IsInstanceOfType(result); + _auctionServiceMock.Verify(s => s.DeleteBid(bid), Times.Once); + } + + [TestMethod] + public async Task BidDelete_BidNotFound_ReturnsErrorsJson_NoOp() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _auctionServiceMock.Setup(s => s.GetBid("missing")).ReturnsAsync((Bid)null); + var model = new ProductModel.BidModel { ProductId = "p1", BidId = "missing" }; + + var result = await _controller.BidDelete(model); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual("Bid not exists", gridModel.Errors); + _auctionServiceMock.Verify(s => s.DeleteBid(It.IsAny()), Times.Never); + } +} diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductBundleControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductBundleControllerTests.cs new file mode 100644 index 000000000..e9fd3fa5d --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductBundleControllerTests.cs @@ -0,0 +1,239 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.AdminShared.Models.Orders; +using Grand.Web.Common.DataSource; +using Grand.Web.Common.Localization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using System.Linq.Expressions; + +namespace Grand.Web.Admin.Tests.Controllers; + +[TestClass] +public class BaseProductBundleControllerTests : ProductControllerTestsBase +{ + // BaseProductBundleController is abstract. This minimal subclass exists only so the actions under test + // can be invoked directly. + public class TestProductBundleController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseProductBundleController(productViewModelService, productService, translationService, scope); + + protected override TestProductBundleController CreateController() => new( + _productViewModelServiceMock.Object, + _productServiceMock.Object, + _translationServiceMock.Object, + _scopeMock.Object); + + // --- BundleProductList ------------------------------------------------------------------------ + // HasAccess (strict), not CanView: same shape as RelatedProductList/SimilarProductList above. + + [TestMethod] + public async Task BundleProductList_ScopeDeniesAccess_ReturnsErrorJson() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); + + var result = await _controller.BundleProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + } + + [TestMethod] + public async Task BundleProductList_ScopeGrantsAccess_ReturnsGrid() + { + var product = new Product { Id = "p1" }; + product.BundleProducts.Add(new BundleProduct { Id = "r1", ProductId = "p2", DisplayOrder = 0, Quantity = 3 }); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _productServiceMock.Setup(p => p.GetProductById("p2", false)).ReturnsAsync(new Product { Id = "p2", Name = "Second" }); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); + + var result = await _controller.BundleProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(1, gridModel.Total); + } + + // --- BundleProductUpdate ---------------------------------------------------------------------- + + [TestMethod] + public async Task BundleProductUpdate_ScopeDeniesAccess_ReturnsErrorJson_DoesNotUpdate() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.BundleProductModel { ProductBundleId = "p1" }; + + var result = await _controller.BundleProductUpdate(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.UpdateBundleProductModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task BundleProductUpdate_ScopeGrantsAccess_ValidModel_Updates() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.BundleProductModel { ProductBundleId = "p1" }; + + var result = await _controller.BundleProductUpdate(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.UpdateBundleProductModel(model), Times.Once); + } + + // --- BundleProductDelete ---------------------------------------------------------------------- + + [TestMethod] + public async Task BundleProductDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.BundleProductModel { ProductBundleId = "p1" }; + + var result = await _controller.BundleProductDelete(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.DeleteBundleProductModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task BundleProductDelete_ScopeGrantsAccess_ValidModel_Deletes() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.BundleProductModel { ProductBundleId = "p1" }; + + var result = await _controller.BundleProductDelete(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.DeleteBundleProductModel(model), Times.Once); + } + + // --- BundleProductAddPopup (GET) -------------------------------------------------------------- + + [TestMethod] + public async Task BundleProductAddPopupGet_CallsPrepareBundleProductModel() + { + // PrepareBundleProductModel() now reads scope internally (Task 9). + _productViewModelServiceMock.Setup(s => s.PrepareBundleProductModel()) + .ReturnsAsync(new ProductModel.AddBundleProductModel()); + + var result = await _controller.BundleProductAddPopup("p1") as ViewResult; + + Assert.IsNotNull(result); + var model = result.Model as ProductModel.AddBundleProductModel; + Assert.IsNotNull(model); + Assert.AreEqual("p1", model.ProductId); + _productViewModelServiceMock.Verify(s => s.PrepareBundleProductModel(), Times.Once); + } + + // --- BundleProductAddPopupList ----------------------------------------------------------------- + + [TestMethod] + public async Task BundleProductAddPopupList_UsesScopeDefaultStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + _productViewModelServiceMock + .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) + .ReturnsAsync((new List(), 0)); + + var model = new ProductModel.AddBundleProductModel(); + var result = await _controller.BundleProductAddPopupList( + new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); + + Assert.IsInstanceOfType(result); + Assert.AreEqual("store-1", model.SearchStoreId); + } + + [TestMethod] + public async Task BundleProductAddPopupList_NoDefaultStoreId_DoesNotOverrideModelSearchStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + _productViewModelServiceMock + .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) + .ReturnsAsync((new List(), 0)); + + var model = new ProductModel.AddBundleProductModel { SearchStoreId = "explicit" }; + var result = await _controller.BundleProductAddPopupList( + new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); + + Assert.IsInstanceOfType(result); + Assert.AreEqual("explicit", model.SearchStoreId); + } + + // --- BundleProductAddPopup (POST) -------------------------------------------------------------- + // HasAccess (strict): closes a real gap - Vendor's original BundleProductAddPopup(POST) had no + // ownership check at all, letting any vendor attach bundle-product mappings onto another vendor's + // product by posting its id. + + [TestMethod] + public async Task BundleProductAddPopupPost_ScopeDeniesAccess_ReturnsContentMessage_DoesNotInsert() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.AddBundleProductModel { ProductId = "p1", SelectedProductIds = ["p2"] }; + + var result = await _controller.BundleProductAddPopup(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.InsertBundleProductModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task BundleProductAddPopupPost_ScopeGrantsAccess_ValidModel_Inserts() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.AddBundleProductModel { ProductId = "p1", SelectedProductIds = ["p2"] }; + + var result = await _controller.BundleProductAddPopup(model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("", content.Content); + _productViewModelServiceMock.Verify(s => s.InsertBundleProductModel(model), Times.Once); + } + + [TestMethod] + public async Task BundleProductAddPopupPost_ScopeGrantsAccess_InvalidModel_ReturnsViewWithRepreparedModel() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var reprepared = new ProductModel.AddBundleProductModel(); + _productViewModelServiceMock.Setup(s => s.PrepareBundleProductModel()).ReturnsAsync(reprepared); + var model = new ProductModel.AddBundleProductModel { ProductId = "p1" }; + _controller.ModelState.AddModelError("x", "error"); + + var result = await _controller.BundleProductAddPopup(model); + + var view = result as ViewResult; + Assert.IsNotNull(view); + Assert.AreSame(reprepared, view.Model); + _productViewModelServiceMock.Verify(s => s.InsertBundleProductModel(It.IsAny()), Times.Never); + } +} diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductCategoryControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductCategoryControllerTests.cs new file mode 100644 index 000000000..dbb7d145b --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductCategoryControllerTests.cs @@ -0,0 +1,224 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.AdminShared.Models.Orders; +using Grand.Web.Common.DataSource; +using Grand.Web.Common.Localization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using System.Linq.Expressions; + +namespace Grand.Web.Admin.Tests.Controllers; + +[TestClass] +public class BaseProductCategoryControllerTests : ProductControllerTestsBase +{ + // BaseProductCategoryController is abstract. This minimal subclass exists only so the actions under test + // can be invoked directly. + public class TestProductCategoryController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseProductCategoryController(productViewModelService, productService, translationService, scope); + + protected override TestProductCategoryController CreateController() => new( + _productViewModelServiceMock.Object, + _productServiceMock.Object, + _translationServiceMock.Object, + _scopeMock.Object); + + // --- ProductCategoryList ---------------------------------------------------------------------- + // HasAccess (strict), not CanView: mirrors Store's CanAccessProduct (AccessToEntityByStore) and + // Vendor's CheckAccessToProduct (VendorId equality) gating this action on both hosts. + + [TestMethod] + public async Task ProductCategoryList_ScopeDeniesAccess_ReturnsErrorJson() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); + + var result = await _controller.ProductCategoryList( + new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.PrepareProductCategoryModel(It.IsAny()), Times.Never); + } + + // Guards against a real regression found in review: the denial message must be templated via + // scope.ResourceKeyPrefix, not hardcoded to "Admin." - "Vendor.Catalog.Products.Permissions" exists + // at the XML resource layer (en_220.xml) even though Task 6's narrower file-scoped audit never saw a + // Vendor call site for it. + [TestMethod] + public async Task ProductCategoryList_ScopeDeniesAccess_UsesScopeResourceKeyPrefix() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); + _scopeMock.Setup(s => s.ResourceKeyPrefix).Returns("Vendor"); + + var result = await _controller.ProductCategoryList( + new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Vendor.Catalog.Products.Permissions"), Times.Once); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Never); + } + + [TestMethod] + public async Task ProductCategoryList_ScopeGrantsAccess_ReturnsGrid() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); + _productViewModelServiceMock.Setup(s => s.PrepareProductCategoryModel(product)) + .ReturnsAsync(new List { new() { Id = "c1", ProductId = "p1" } }); + + var result = await _controller.ProductCategoryList( + new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(1, gridModel.Total); + } + + // The tabs of a product the Store staff member can see but not edit (shared with other stores) + // must load: reads are gated by CanView, only the mutations by HasAccess. + [TestMethod] + public async Task ProductCategoryList_ViewableButNotOwned_ReturnsGrid() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + _productViewModelServiceMock.Setup(s => s.PrepareProductCategoryModel(product)) + .ReturnsAsync(new List { new() { Id = "c1", ProductId = "p1" } }); + + var result = await _controller.ProductCategoryList( + new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + var gridModel = (result as JsonResult)?.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(1, gridModel.Total); + } + + [TestMethod] + public async Task ProductCategoryInsert_ViewableButNotOwned_IsDenied() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ProductCategoryInsert(new ProductModel.ProductCategoryModel { ProductId = "p1" }); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.InsertProductCategoryModel(It.IsAny()), Times.Never); + } + + // --- ProductCategoryInsert --------------------------------------------------------------------- + + [TestMethod] + public async Task ProductCategoryInsert_ScopeDeniesAccess_ReturnsErrorJson_DoesNotInsert() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.ProductCategoryModel { ProductId = "p1" }; + + var result = await _controller.ProductCategoryInsert(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.InsertProductCategoryModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductCategoryInsert_ScopeGrantsAccess_ValidModel_Inserts() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductCategoryModel { ProductId = "p1" }; + + var result = await _controller.ProductCategoryInsert(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.InsertProductCategoryModel(model), Times.Once); + } + + // --- ProductCategoryUpdate --------------------------------------------------------------------- + + [TestMethod] + public async Task ProductCategoryUpdate_ScopeDeniesAccess_ReturnsErrorJson_DoesNotUpdate() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.ProductCategoryModel { ProductId = "p1" }; + + var result = await _controller.ProductCategoryUpdate(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.UpdateProductCategoryModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductCategoryUpdate_ScopeGrantsAccess_ValidModel_Updates() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductCategoryModel { ProductId = "p1" }; + + var result = await _controller.ProductCategoryUpdate(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.UpdateProductCategoryModel(model), Times.Once); + } + + // --- ProductCategoryDelete --------------------------------------------------------------------- + + [TestMethod] + public async Task ProductCategoryDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.ProductCategoryModel { Id = "c1", ProductId = "p1" }; + + var result = await _controller.ProductCategoryDelete(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.DeleteProductCategory(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductCategoryDelete_ScopeGrantsAccess_ValidModel_Deletes() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductCategoryModel { Id = "c1", ProductId = "p1" }; + + var result = await _controller.ProductCategoryDelete(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.DeleteProductCategory("c1", "p1"), Times.Once); + } +} diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductCollectionControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductCollectionControllerTests.cs new file mode 100644 index 000000000..e62e34398 --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductCollectionControllerTests.cs @@ -0,0 +1,186 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.AdminShared.Models.Orders; +using Grand.Web.Common.DataSource; +using Grand.Web.Common.Localization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using System.Linq.Expressions; + +namespace Grand.Web.Admin.Tests.Controllers; + +[TestClass] +public class BaseProductCollectionControllerTests : ProductControllerTestsBase +{ + // BaseProductCollectionController is abstract. This minimal subclass exists only so the actions under test + // can be invoked directly. + public class TestProductCollectionController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseProductCollectionController(productViewModelService, productService, translationService, scope); + + protected override TestProductCollectionController CreateController() => new( + _productViewModelServiceMock.Object, + _productServiceMock.Object, + _translationServiceMock.Object, + _scopeMock.Object); + + // --- ProductCollectionList ------------------------------------------------------------------ + // Same shape as ProductCategoryList above: HasAccess (strict) mirrors Store's CanAccessProduct and + // Vendor's CheckAccessToProduct gating this action on both hosts. + + [TestMethod] + public async Task ProductCollectionList_ScopeDeniesAccess_ReturnsErrorJson() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); + + var result = await _controller.ProductCollectionList( + new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.PrepareProductCollectionModel(It.IsAny()), Times.Never); + } + + // Guards against the same regression class found in "Product categories": the denial message must be + // templated via scope.ResourceKeyPrefix, not hardcoded to "Admin." - "Vendor.Catalog.Products.Permissions" + // exists at the XML resource layer (en_220.xml) even though it has no Vendor call site in this region. + [TestMethod] + public async Task ProductCollectionList_ScopeDeniesAccess_UsesScopeResourceKeyPrefix() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); + _scopeMock.Setup(s => s.ResourceKeyPrefix).Returns("Vendor"); + + var result = await _controller.ProductCollectionList( + new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Vendor.Catalog.Products.Permissions"), Times.Once); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Never); + } + + [TestMethod] + public async Task ProductCollectionList_ScopeGrantsAccess_ReturnsGrid() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); + _productViewModelServiceMock.Setup(s => s.PrepareProductCollectionModel(product)) + .ReturnsAsync(new List { new() { Id = "c1", ProductId = "p1" } }); + + var result = await _controller.ProductCollectionList( + new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(1, gridModel.Total); + } + + // --- ProductCollectionInsert ---------------------------------------------------------------- + + [TestMethod] + public async Task ProductCollectionInsert_ScopeDeniesAccess_ReturnsErrorJson_DoesNotInsert() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.ProductCollectionModel { ProductId = "p1" }; + + var result = await _controller.ProductCollectionInsert(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.InsertProductCollection(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductCollectionInsert_ScopeGrantsAccess_ValidModel_Inserts() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductCollectionModel { ProductId = "p1" }; + + var result = await _controller.ProductCollectionInsert(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.InsertProductCollection(model), Times.Once); + } + + // --- ProductCollectionUpdate ---------------------------------------------------------------- + + [TestMethod] + public async Task ProductCollectionUpdate_ScopeDeniesAccess_ReturnsErrorJson_DoesNotUpdate() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.ProductCollectionModel { ProductId = "p1" }; + + var result = await _controller.ProductCollectionUpdate(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.UpdateProductCollection(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductCollectionUpdate_ScopeGrantsAccess_ValidModel_Updates() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductCollectionModel { ProductId = "p1" }; + + var result = await _controller.ProductCollectionUpdate(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.UpdateProductCollection(model), Times.Once); + } + + // --- ProductCollectionDelete ---------------------------------------------------------------- + + [TestMethod] + public async Task ProductCollectionDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.ProductCollectionModel { Id = "c1", ProductId = "p1" }; + + var result = await _controller.ProductCollectionDelete(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.DeleteProductCollection(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductCollectionDelete_ScopeGrantsAccess_ValidModel_Deletes() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductCollectionModel { Id = "c1", ProductId = "p1" }; + + var result = await _controller.ProductCollectionDelete(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.DeleteProductCollection("c1", "p1"), Times.Once); + } +} diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs index 520a793e3..b3a388005 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductControllerTests.cs @@ -7,13 +7,9 @@ using Grand.Business.Core.Utilities.Catalog; using Grand.Domain; using Grand.Domain.Catalog; -using Grand.Domain.Media; using Grand.Domain.Permissions; -using Grand.Infrastructure.Mapper; -using Grand.Mapping; using Grand.Web.AdminShared.Controllers; using Grand.Web.AdminShared.Interfaces; -using Grand.Web.AdminShared.Mapper; using Grand.Web.AdminShared.Models.Catalog; using Grand.Web.AdminShared.Models.Orders; using Grand.Web.Common.DataSource; @@ -31,96 +27,29 @@ namespace Grand.Web.Admin.Tests.Controllers; [TestClass] -public class BaseProductControllerTests +public class BaseProductControllerTests : ProductControllerTestsBase { // BaseProductController is abstract - it has no host until Task 11 subclasses it. This // minimal subclass exists only so the actions under test can be invoked directly. - private class TestProductController( + public class TestProductController( IProductViewModelService productViewModelService, IProductService productService, - IInventoryManageService inventoryManageService, ILanguageService languageService, IRequestTranslationService translationService, - IProductReservationService productReservationService, - IAuctionService auctionService, IDateTimeService dateTimeService, IRequestPermissionService permissionService, - IEnumTranslationService enumTranslationService, IAdminDataScope scope) - : BaseProductController(productViewModelService, productService, inventoryManageService, - languageService, translationService, productReservationService, auctionService, - dateTimeService, permissionService, enumTranslationService, scope); - - private TestProductController _controller; - private Mock _productServiceMock; - private Mock _productViewModelServiceMock; - private Mock _translationServiceMock; - private Mock> _scopeMock; - private Mock _permissionServiceMock; - private Mock _productReservationServiceMock; - private Mock _auctionServiceMock; - - [TestInitialize] - public void Setup() - { - var mapperConfig = new MapperConfiguration(cfg => - { - cfg.AddProfile(); - cfg.AddProfile(); - }); - AutoMapperConfig.Init(mapperConfig); - - _productServiceMock = new Mock(); - _productViewModelServiceMock = new Mock(); - _translationServiceMock = new Mock(); - _translationServiceMock.Setup(t => t.GetResource(It.IsAny())).Returns("resource"); - - _scopeMock = new Mock>(); - _scopeMock.Setup(s => s.ResourceKeyPrefix).Returns("Admin"); - _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); - - _permissionServiceMock = new Mock(); - _permissionServiceMock.Setup(p => p.Authorize(It.IsAny())).ReturnsAsync(true); - - var languageServiceMock = new Mock(); - languageServiceMock.Setup(l => l.GetAllLanguages(true, It.IsAny())).ReturnsAsync(new List()); - - _productReservationServiceMock = new Mock(); - _auctionServiceMock = new Mock(); - - _controller = new TestProductController( - _productViewModelServiceMock.Object, - _productServiceMock.Object, - new Mock().Object, - languageServiceMock.Object, - _translationServiceMock.Object, - _productReservationServiceMock.Object, - _auctionServiceMock.Object, - new Mock().Object, - _permissionServiceMock.Object, - new Mock().Object, - _scopeMock.Object); - - var httpContext = new DefaultHttpContext(); - // Needed for actions whose catch (Exception) block calls Error(exc), which logs via - // HttpContext.RequestServices.GetRequiredService() (see Export / Import below). - // Once RequestServices is non-null, ControllerBase.Url's own - // HttpContext.RequestServices.GetRequiredService() call (used by every - // RedirectToAction(action, controller) - e.g. GoToSku above) stops being short-circuited by the - // null-conditional it uses when RequestServices itself is null, so IUrlHelperFactory must resolve - // too or those pre-existing tests start throwing. - var loggerFactoryMock = new Mock(); - loggerFactoryMock.Setup(l => l.CreateLogger(It.IsAny())).Returns(new Mock().Object); - var urlHelperFactoryMock = new Mock(); - urlHelperFactoryMock.Setup(f => f.GetUrlHelper(It.IsAny())) - .Returns(new Mock().Object); - var requestServicesMock = new Mock(); - requestServicesMock.Setup(s => s.GetService(typeof(ILoggerFactory))).Returns(loggerFactoryMock.Object); - requestServicesMock.Setup(s => s.GetService(typeof(IUrlHelperFactory))).Returns(urlHelperFactoryMock.Object); - httpContext.RequestServices = requestServicesMock.Object; - _controller.ControllerContext = new ControllerContext { HttpContext = httpContext }; - _controller.TempData = new TempDataDictionary(httpContext, new Mock().Object); - } + : BaseProductController(productViewModelService, productService, languageService, translationService, + dateTimeService, permissionService, scope); + + protected override TestProductController CreateController() => new( + _productViewModelServiceMock.Object, + _productServiceMock.Object, + _languageServiceMock.Object, + _translationServiceMock.Object, + _dateTimeServiceMock.Object, + _permissionServiceMock.Object, + _scopeMock.Object); // --- Edit (GET) -------------------------------------------------------------------------------- @@ -618,56 +547,75 @@ public async Task RequiredProductAddPopupList_NoDefaultStoreId_DoesNotOverrideMo Assert.AreEqual("explicit", model.SearchStoreId); } - // --- ProductCategoryList ---------------------------------------------------------------------- - // HasAccess (strict), not CanView: mirrors Store's CanAccessProduct (AccessToEntityByStore) and - // Vendor's CheckAccessToProduct (VendorId equality) gating this action on both hosts. + // --- Product pictures --------------------------------------------------------------------------- + // ProductPictureAdd is deliberately not covered here (same rationale as Store's original + // ProductControllerTests): reaching its HasAccess check requires a non-empty IFormFileCollection and + // a prior Pictures-permission check via IRequestPermissionService, disproportionate setup for what is + // otherwise the same one-line HasAccess condition covered everywhere else in this region. + + // --- Product specification attributes ----------------------------------------------------------- + // GetOptionsByAttributeId is deliberately not covered here (same rationale as Product pictures' + // ProductPictureAdd note): identical, unscoped across all three hosts, no access check involved. + + // --- Purchased with order ------------------------------------------------------------------------ + // Covers Admin and Store only - Vendor cannot bind to this signature (see the region comment in + // BaseProductController.cs: Vendor has its own, structurally different IOrderViewModelService and + // OrderListModel types). [TestMethod] - public async Task ProductCategoryList_ScopeDeniesAccess_ReturnsErrorJson() + public async Task PurchasedWithOrders_PermissionDenied_ReturnsEmptyGrid_DoesNotLoadProduct() { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); + _permissionServiceMock.Setup(p => p.Authorize(StandardPermission.ManageOrders)).ReturnsAsync(false); + var orderViewModelServiceMock = new Mock(); - var result = await _controller.ProductCategoryList( - new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + var result = await _controller.PurchasedWithOrders( + new Grand.Web.Common.DataSource.DataSourceRequest(), "p1", orderViewModelServiceMock.Object); - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.PrepareProductCategoryModel(It.IsAny()), Times.Never); + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(0, gridModel.Total); + Assert.IsNull(gridModel.Data); + _productServiceMock.Verify(p => p.GetProductById(It.IsAny(), It.IsAny()), Times.Never); } - // Guards against a real regression found in review: the denial message must be templated via - // scope.ResourceKeyPrefix, not hardcoded to "Admin." - "Vendor.Catalog.Products.Permissions" exists - // at the XML resource layer (en_220.xml) even though Task 6's narrower file-scoped audit never saw a - // Vendor call site for it. [TestMethod] - public async Task ProductCategoryList_ScopeDeniesAccess_UsesScopeResourceKeyPrefix() + public async Task PurchasedWithOrders_ScopeDeniesAccess_ReturnsErrorJson() { var product = new Product { Id = "p1" }; _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); - _scopeMock.Setup(s => s.ResourceKeyPrefix).Returns("Vendor"); + var orderViewModelServiceMock = new Mock(); - var result = await _controller.ProductCategoryList( - new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + var result = await _controller.PurchasedWithOrders( + new Grand.Web.Common.DataSource.DataSourceRequest(), "p1", orderViewModelServiceMock.Object); Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Vendor.Catalog.Products.Permissions"), Times.Once); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Never); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + orderViewModelServiceMock.Verify( + s => s.PrepareOrderModel(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [TestMethod] - public async Task ProductCategoryList_ScopeGrantsAccess_ReturnsGrid() + public async Task PurchasedWithOrders_ScopeGrantsAccess_UsesDefaultStoreIdAndReturnsGrid() { var product = new Product { Id = "p1" }; _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); - _productViewModelServiceMock.Setup(s => s.PrepareProductCategoryModel(product)) - .ReturnsAsync(new List { new() { Id = "c1", ProductId = "p1" } }); + // DefaultStoreId stands in for Store's original model.StoreId = StaffStoreId (and for Admin's + // original, which left StoreId unset/null - GlobalAdminDataScope.DefaultStoreId is null). + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + var orders = new List { new() { Id = "order1" } }; + var orderViewModelServiceMock = new Mock(); + orderViewModelServiceMock + .Setup(s => s.PrepareOrderModel( + It.Is(m => m.ProductId == "p1" && m.StoreId == "store-1"), 1, 10)) + .ReturnsAsync((orders, orders.Count)); - var result = await _controller.ProductCategoryList( - new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + var result = await _controller.PurchasedWithOrders( + new Grand.Web.Common.DataSource.DataSourceRequest { Page = 1, PageSize = 10 }, "p1", + orderViewModelServiceMock.Object); var json = result as JsonResult; Assert.IsNotNull(json); @@ -676,5080 +624,462 @@ public async Task ProductCategoryList_ScopeGrantsAccess_ReturnsGrid() Assert.AreEqual(1, gridModel.Total); } - // The tabs of a product the Store staff member can see but not edit (shared with other stores) - // must load: reads are gated by CanView, only the mutations by HasAccess. + // --- Reviews --------------------------------------------------------------------------------- + [TestMethod] - public async Task ProductCategoryList_ViewableButNotOwned_ReturnsGrid() + public async Task Reviews_ScopeDeniesAccess_ReturnsErrorJson_DoesNotLoadReviews() { var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - _productViewModelServiceMock.Setup(s => s.PrepareProductCategoryModel(product)) - .ReturnsAsync(new List { new() { Id = "c1", ProductId = "p1" } }); + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); + var productReviewServiceMock = new Mock(); - var result = await _controller.ProductCategoryList( - new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + var result = await _controller.Reviews( + new Grand.Web.Common.DataSource.DataSourceRequest(), "p1", productReviewServiceMock.Object); - var gridModel = (result as JsonResult)?.Value as Grand.Web.Common.DataSource.DataSourceResult; - Assert.IsNotNull(gridModel); - Assert.AreEqual(1, gridModel.Total); + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + productReviewServiceMock.Verify( + s => s.GetAllProductReviews(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); } [TestMethod] - public async Task ProductCategoryInsert_ViewableButNotOwned_IsDenied() + public async Task Reviews_ScopeGrantsAccess_WithDefaultStoreId_FiltersReviewsByStore() { var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + // DefaultStoreId stands in for Store's original storeId argument (the staff member's + // StaffStoreId, used to filter reviews to that store). + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + var review = new ProductReview { Id = "r1", ProductId = "p1" }; + var reviews = new PagedList(new List { review }, 0, int.MaxValue); + var productReviewServiceMock = new Mock(); + productReviewServiceMock + .Setup(s => s.GetAllProductReviews("", null, null, null, "", "store-1", "p1", 0, int.MaxValue)) + .ReturnsAsync(reviews); - var result = await _controller.ProductCategoryInsert(new ProductModel.ProductCategoryModel { ProductId = "p1" }); + var result = await _controller.Reviews( + new Grand.Web.Common.DataSource.DataSourceRequest(), "p1", productReviewServiceMock.Object); - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(1, gridModel.Total); _productViewModelServiceMock.Verify( - s => s.InsertProductCategoryModel(It.IsAny()), Times.Never); + s => s.PrepareProductReviewModel(It.IsAny(), review, false, true), Times.Once); } - // --- ProductCategoryInsert --------------------------------------------------------------------- - [TestMethod] - public async Task ProductCategoryInsert_ScopeDeniesAccess_ReturnsErrorJson_DoesNotInsert() + public async Task Reviews_ScopeGrantsAccess_NullDefaultStoreId_PassesEmptyStoreId() { + // Matches both Admin's and Vendor's originals, which both passed "" literally (Vendor scopes by + // VendorId via the HasAccess check above, not by store - VendorProductDataScope.DefaultStoreId + // is null). var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.ProductCategoryModel { ProductId = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); + _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + var reviews = new PagedList(new List(), 0, int.MaxValue); + var productReviewServiceMock = new Mock(); + productReviewServiceMock + .Setup(s => s.GetAllProductReviews("", null, null, null, "", "", "p1", 0, int.MaxValue)) + .ReturnsAsync(reviews); - var result = await _controller.ProductCategoryInsert(model); + var result = await _controller.Reviews( + new Grand.Web.Common.DataSource.DataSourceRequest(), "p1", productReviewServiceMock.Object); - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.InsertProductCategoryModel(It.IsAny()), Times.Never); + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(0, gridModel.Total); + productReviewServiceMock.Verify( + s => s.GetAllProductReviews("", null, null, null, "", "", "p1", 0, int.MaxValue), Times.Once); } + // --- Export / Import --------------------------------------------------------------------------- + [TestMethod] - public async Task ProductCategoryInsert_ScopeGrantsAccess_ValidModel_Inserts() + public async Task ExportExcelAll_Success_ReturnsXlsxFile() { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.ProductCategoryModel { ProductId = "p1" }; + var model = new ProductListModel(); + var products = new List { new() { Id = "p1" } }; + // vendorId is forced inside PrepareProducts itself; storeId is forced at the call site below, + // same as every other PrepareProductsModel/PrepareProducts caller in this controller. + _productViewModelServiceMock.Setup(s => s.PrepareProducts(model)).ReturnsAsync(products); + var exportManagerMock = new Mock>(); + exportManagerMock.Setup(e => e.Export(products)).ReturnsAsync([1, 2, 3]); - var result = await _controller.ProductCategoryInsert(model); + var result = await _controller.ExportExcelAll(model, exportManagerMock.Object); - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.InsertProductCategoryModel(model), Times.Once); + var file = result as FileContentResult; + Assert.IsNotNull(file); + Assert.AreEqual("text/xls", file.ContentType); + Assert.AreEqual("products.xlsx", file.FileDownloadName); + CollectionAssert.AreEqual(new byte[] { 1, 2, 3 }, file.FileContents); } - // --- ProductCategoryUpdate --------------------------------------------------------------------- - [TestMethod] - public async Task ProductCategoryUpdate_ScopeDeniesAccess_ReturnsErrorJson_DoesNotUpdate() + public async Task ExportExcelAll_UsesScopeDefaultStoreId() { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.ProductCategoryModel { ProductId = "p1" }; + // Regression test: found during a post-Phase-2 tenant-isolation audit that this action was the + // one PrepareProducts/PrepareProductsModel caller in the controller NOT forcing + // model.SearchStoreId = scope.DefaultStoreId, letting a Store-scoped user export other stores' + // products by posting an arbitrary SearchStoreId. + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + var model = new ProductListModel(); + _productViewModelServiceMock.Setup(s => s.PrepareProducts(model)).ReturnsAsync([]); + var exportManagerMock = new Mock>(); + exportManagerMock.Setup(e => e.Export(It.IsAny>(), It.IsAny())).ReturnsAsync([1, 2, 3]); - var result = await _controller.ProductCategoryUpdate(model); + await _controller.ExportExcelAll(model, exportManagerMock.Object); - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.UpdateProductCategoryModel(It.IsAny()), Times.Never); + Assert.AreEqual("store-1", model.SearchStoreId); } [TestMethod] - public async Task ProductCategoryUpdate_ScopeGrantsAccess_ValidModel_Updates() + public async Task ExportExcelAll_NoDefaultStoreId_DoesNotOverrideModelSearchStoreId() { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.ProductCategoryModel { ProductId = "p1" }; + _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + var model = new ProductListModel { SearchStoreId = "explicit" }; + _productViewModelServiceMock.Setup(s => s.PrepareProducts(model)).ReturnsAsync([]); + var exportManagerMock = new Mock>(); + exportManagerMock.Setup(e => e.Export(It.IsAny>(), It.IsAny())).ReturnsAsync([1, 2, 3]); - var result = await _controller.ProductCategoryUpdate(model); + await _controller.ExportExcelAll(model, exportManagerMock.Object); - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.UpdateProductCategoryModel(model), Times.Once); + Assert.AreEqual("explicit", model.SearchStoreId); } - // --- ProductCategoryDelete --------------------------------------------------------------------- - [TestMethod] - public async Task ProductCategoryDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() + public async Task ExportExcelAll_ExportThrows_ReturnsRedirectToList() { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.ProductCategoryModel { Id = "c1", ProductId = "p1" }; + var model = new ProductListModel(); + _productViewModelServiceMock.Setup(s => s.PrepareProducts(model)).ReturnsAsync([]); + var exportManagerMock = new Mock>(); + exportManagerMock.Setup(e => e.Export(It.IsAny>(), It.IsAny())) + .ThrowsAsync(new Exception("boom")); - var result = await _controller.ProductCategoryDelete(model); + var result = await _controller.ExportExcelAll(model, exportManagerMock.Object); - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.DeleteProductCategory(It.IsAny(), It.IsAny()), Times.Never); + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("List", redirect.ActionName); } [TestMethod] - public async Task ProductCategoryDelete_ScopeGrantsAccess_ValidModel_Deletes() + public async Task ExportExcelSelected_NullSelectedIds_ExportsEmptyList() { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.ProductCategoryModel { Id = "c1", ProductId = "p1" }; + var exportManagerMock = new Mock>(); + exportManagerMock.Setup(e => e.Export(It.Is>(p => !p.Any()), It.IsAny())) + .ReturnsAsync([9]); - var result = await _controller.ProductCategoryDelete(model); + var result = await _controller.ExportExcelSelected(null, exportManagerMock.Object); - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.DeleteProductCategory("c1", "p1"), Times.Once); + var file = result as FileContentResult; + Assert.IsNotNull(file); + _productServiceMock.Verify(p => p.GetProductsByIds(It.IsAny(), + It.IsAny()), Times.Never); } - // --- ProductCollectionList ------------------------------------------------------------------ - // Same shape as ProductCategoryList above: HasAccess (strict) mirrors Store's CanAccessProduct and - // Vendor's CheckAccessToProduct gating this action on both hosts. - [TestMethod] - public async Task ProductCollectionList_ScopeDeniesAccess_ReturnsErrorJson() + public async Task ExportExcelSelected_FiltersOutProductsScopeDenies() { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); + // Mirrors Vendor's original explicit HasAccessToProduct re-check on selectedIds (caller-supplied, + // not derived from a scoped search, unlike ExportExcelAll) - applied unconditionally here so + // Admin (where the original had no check at all) gets the same protection. + var owned = new Product { Id = "owned" }; + var foreign = new Product { Id = "foreign" }; + _productServiceMock.Setup(p => p.GetProductsByIds(new[] { "owned", "foreign" }, + CatalogAccess.IncludingHidden())) + .ReturnsAsync([owned, foreign]); + _scopeMock.Setup(s => s.HasAccess(owned)).ReturnsAsync(true); + _scopeMock.Setup(s => s.HasAccess(foreign)).ReturnsAsync(false); + var exportManagerMock = new Mock>(); + exportManagerMock + .Setup(e => e.Export(It.Is>(p => p.Single() == owned), It.IsAny())) + .ReturnsAsync([7]); - var result = await _controller.ProductCollectionList( - new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + var result = await _controller.ExportExcelSelected("owned,foreign", exportManagerMock.Object); - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.PrepareProductCollectionModel(It.IsAny()), Times.Never); + var file = result as FileContentResult; + Assert.IsNotNull(file); + exportManagerMock.Verify( + e => e.Export(It.Is>(p => p.Single() == owned), It.IsAny()), + Times.Once); } - // Guards against the same regression class found in "Product categories": the denial message must be - // templated via scope.ResourceKeyPrefix, not hardcoded to "Admin." - "Vendor.Catalog.Products.Permissions" - // exists at the XML resource layer (en_220.xml) even though it has no Vendor call site in this region. [TestMethod] - public async Task ProductCollectionList_ScopeDeniesAccess_UsesScopeResourceKeyPrefix() + public async Task ImportExcel_EmptyFile_DoesNotImport_RedirectsToListWithError() { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); - _scopeMock.Setup(s => s.ResourceKeyPrefix).Returns("Vendor"); + var fileMock = new Mock(); + fileMock.Setup(f => f.Length).Returns(0); + var importManagerMock = new Mock>(); - var result = await _controller.ProductCollectionList( - new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + var result = await _controller.ImportExcel(fileMock.Object, importManagerMock.Object); - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Vendor.Catalog.Products.Permissions"), Times.Once); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Never); + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("List", redirect.ActionName); + importManagerMock.Verify(i => i.Import(It.IsAny(), It.IsAny()), Times.Never); + _translationServiceMock.Verify(t => t.GetResource("Admin.Common.UploadFile"), Times.Once); } + // --- ImportExcel: global scope only --- + [TestMethod] - public async Task ProductCollectionList_ScopeGrantsAccess_ReturnsGrid() + public async Task ImportExcel_StoreScope_DeniesAndNeverCallsImportManager() { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); - _productViewModelServiceMock.Setup(s => s.PrepareProductCollectionModel(product)) - .ReturnsAsync(new List { new() { Id = "c1", ProductId = "p1" } }); + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + var fileMock = new Mock(); + fileMock.Setup(f => f.Length).Returns(100); + var importManagerMock = new Mock>(); - var result = await _controller.ProductCollectionList( - new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); - - var json = result as JsonResult; - Assert.IsNotNull(json); - var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; - Assert.IsNotNull(gridModel); - Assert.AreEqual(1, gridModel.Total); - } - - // --- ProductCollectionInsert ---------------------------------------------------------------- - - [TestMethod] - public async Task ProductCollectionInsert_ScopeDeniesAccess_ReturnsErrorJson_DoesNotInsert() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.ProductCollectionModel { ProductId = "p1" }; - - var result = await _controller.ProductCollectionInsert(model); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.InsertProductCollection(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ProductCollectionInsert_ScopeGrantsAccess_ValidModel_Inserts() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.ProductCollectionModel { ProductId = "p1" }; - - var result = await _controller.ProductCollectionInsert(model); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.InsertProductCollection(model), Times.Once); - } - - // --- ProductCollectionUpdate ---------------------------------------------------------------- - - [TestMethod] - public async Task ProductCollectionUpdate_ScopeDeniesAccess_ReturnsErrorJson_DoesNotUpdate() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.ProductCollectionModel { ProductId = "p1" }; - - var result = await _controller.ProductCollectionUpdate(model); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.UpdateProductCollection(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ProductCollectionUpdate_ScopeGrantsAccess_ValidModel_Updates() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.ProductCollectionModel { ProductId = "p1" }; - - var result = await _controller.ProductCollectionUpdate(model); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.UpdateProductCollection(model), Times.Once); - } - - // --- ProductCollectionDelete ---------------------------------------------------------------- - - [TestMethod] - public async Task ProductCollectionDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.ProductCollectionModel { Id = "c1", ProductId = "p1" }; - - var result = await _controller.ProductCollectionDelete(model); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.DeleteProductCollection(It.IsAny(), It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ProductCollectionDelete_ScopeGrantsAccess_ValidModel_Deletes() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.ProductCollectionModel { Id = "c1", ProductId = "p1" }; - - var result = await _controller.ProductCollectionDelete(model); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.DeleteProductCollection("c1", "p1"), Times.Once); - } - - // --- RelatedProductList ------------------------------------------------------------------------ - // HasAccess (strict), not CanView: same shape as ProductCategoryList/ProductCollectionList above. - - [TestMethod] - public async Task RelatedProductList_ScopeDeniesAccess_ReturnsErrorJson() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); - - var result = await _controller.RelatedProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - } - - [TestMethod] - public async Task RelatedProductList_ScopeGrantsAccess_ReturnsGrid() - { - var product = new Product { Id = "p1" }; - product.RelatedProducts.Add(new RelatedProduct { Id = "r1", ProductId2 = "p2", DisplayOrder = 0 }); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _productServiceMock.Setup(p => p.GetProductById("p2", false)).ReturnsAsync(new Product { Id = "p2", Name = "Second" }); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); - - var result = await _controller.RelatedProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); - - var json = result as JsonResult; - Assert.IsNotNull(json); - var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; - Assert.IsNotNull(gridModel); - Assert.AreEqual(1, gridModel.Total); - } - - // --- RelatedProductUpdate ---------------------------------------------------------------------- - - [TestMethod] - public async Task RelatedProductUpdate_ScopeDeniesAccess_ReturnsErrorJson_DoesNotUpdate() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.RelatedProductModel { ProductId1 = "p1" }; - - var result = await _controller.RelatedProductUpdate(model); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.UpdateRelatedProductModel(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task RelatedProductUpdate_ScopeGrantsAccess_ValidModel_Updates() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.RelatedProductModel { ProductId1 = "p1" }; - - var result = await _controller.RelatedProductUpdate(model); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.UpdateRelatedProductModel(model), Times.Once); - } - - // --- RelatedProductDelete ---------------------------------------------------------------------- - - [TestMethod] - public async Task RelatedProductDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.RelatedProductModel { ProductId1 = "p1" }; - - var result = await _controller.RelatedProductDelete(model); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.DeleteRelatedProductModel(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task RelatedProductDelete_ScopeGrantsAccess_ValidModel_Deletes() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.RelatedProductModel { ProductId1 = "p1" }; - - var result = await _controller.RelatedProductDelete(model); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.DeleteRelatedProductModel(model), Times.Once); - } - - // --- RelatedProductAddPopup (GET) -------------------------------------------------------------- - - [TestMethod] - public async Task RelatedProductAddPopupGet_CallsPrepareRelatedProductModel() - { - // PrepareRelatedProductModel() now reads scope internally (Task 9). - _productViewModelServiceMock.Setup(s => s.PrepareRelatedProductModel()) - .ReturnsAsync(new ProductModel.AddRelatedProductModel()); - - var result = await _controller.RelatedProductAddPopup("p1") as ViewResult; - - Assert.IsNotNull(result); - var model = result.Model as ProductModel.AddRelatedProductModel; - Assert.IsNotNull(model); - Assert.AreEqual("p1", model.ProductId); - _productViewModelServiceMock.Verify(s => s.PrepareRelatedProductModel(), Times.Once); - } - - // --- RelatedProductAddPopupList ----------------------------------------------------------------- - - [TestMethod] - public async Task RelatedProductAddPopupList_UsesScopeDefaultStoreId() - { - _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); - _productViewModelServiceMock - .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) - .ReturnsAsync((new List(), 0)); - - var model = new ProductModel.AddRelatedProductModel(); - var result = await _controller.RelatedProductAddPopupList( - new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); - - Assert.IsInstanceOfType(result); - Assert.AreEqual("store-1", model.SearchStoreId); - } - - [TestMethod] - public async Task RelatedProductAddPopupList_NoDefaultStoreId_DoesNotOverrideModelSearchStoreId() - { - _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); - _productViewModelServiceMock - .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) - .ReturnsAsync((new List(), 0)); - - var model = new ProductModel.AddRelatedProductModel { SearchStoreId = "explicit" }; - var result = await _controller.RelatedProductAddPopupList( - new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); - - Assert.IsInstanceOfType(result); - Assert.AreEqual("explicit", model.SearchStoreId); - } - - // --- RelatedProductAddPopup (POST) -------------------------------------------------------------- - // HasAccess (strict): closes a real gap - Vendor's original RelatedProductAddPopup(POST) had no - // ownership check at all, letting any vendor attach related-product mappings onto another vendor's - // product by posting its id. - - [TestMethod] - public async Task RelatedProductAddPopupPost_ScopeDeniesAccess_ReturnsContentMessage_DoesNotInsert() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.AddRelatedProductModel { ProductId = "p1", SelectedProductIds = ["p2"] }; - - var result = await _controller.RelatedProductAddPopup(model); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.InsertRelatedProductModel(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task RelatedProductAddPopupPost_ScopeGrantsAccess_ValidModel_Inserts() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.AddRelatedProductModel { ProductId = "p1", SelectedProductIds = ["p2"] }; - - var result = await _controller.RelatedProductAddPopup(model); - - var content = result as ContentResult; - Assert.IsNotNull(content); - Assert.AreEqual("", content.Content); - _productViewModelServiceMock.Verify(s => s.InsertRelatedProductModel(model), Times.Once); - } - - [TestMethod] - public async Task RelatedProductAddPopupPost_ScopeGrantsAccess_InvalidModel_ReturnsViewWithRepreparedModel() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var reprepared = new ProductModel.AddRelatedProductModel(); - _productViewModelServiceMock.Setup(s => s.PrepareRelatedProductModel()).ReturnsAsync(reprepared); - var model = new ProductModel.AddRelatedProductModel { ProductId = "p1" }; - _controller.ModelState.AddModelError("x", "error"); - - var result = await _controller.RelatedProductAddPopup(model); - - var view = result as ViewResult; - Assert.IsNotNull(view); - Assert.AreSame(reprepared, view.Model); - _productViewModelServiceMock.Verify(s => s.InsertRelatedProductModel(It.IsAny()), Times.Never); - } - - // --- SimilarProductList ------------------------------------------------------------------------ - // HasAccess (strict), not CanView: same shape as RelatedProductList above. - - [TestMethod] - public async Task SimilarProductList_ScopeDeniesAccess_ReturnsErrorJson() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); - - var result = await _controller.SimilarProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - } - - [TestMethod] - public async Task SimilarProductList_ScopeGrantsAccess_ReturnsGrid() - { - var product = new Product { Id = "p1" }; - product.SimilarProducts.Add(new SimilarProduct { Id = "r1", ProductId2 = "p2", DisplayOrder = 0 }); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _productServiceMock.Setup(p => p.GetProductById("p2", false)).ReturnsAsync(new Product { Id = "p2", Name = "Second" }); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); - - var result = await _controller.SimilarProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); - - var json = result as JsonResult; - Assert.IsNotNull(json); - var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; - Assert.IsNotNull(gridModel); - Assert.AreEqual(1, gridModel.Total); - } - - // --- SimilarProductUpdate ---------------------------------------------------------------------- - - [TestMethod] - public async Task SimilarProductUpdate_ScopeDeniesAccess_ReturnsErrorJson_DoesNotUpdate() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.SimilarProductModel { ProductId1 = "p1" }; - - var result = await _controller.SimilarProductUpdate(model); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.UpdateSimilarProductModel(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task SimilarProductUpdate_ScopeGrantsAccess_ValidModel_Updates() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.SimilarProductModel { ProductId1 = "p1" }; - - var result = await _controller.SimilarProductUpdate(model); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.UpdateSimilarProductModel(model), Times.Once); - } - - // --- SimilarProductDelete ---------------------------------------------------------------------- - - [TestMethod] - public async Task SimilarProductDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.SimilarProductModel { ProductId1 = "p1" }; - - var result = await _controller.SimilarProductDelete(model); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.DeleteSimilarProductModel(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task SimilarProductDelete_ScopeGrantsAccess_ValidModel_Deletes() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.SimilarProductModel { ProductId1 = "p1" }; - - var result = await _controller.SimilarProductDelete(model); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.DeleteSimilarProductModel(model), Times.Once); - } - - // --- SimilarProductAddPopup (GET) -------------------------------------------------------------- - - [TestMethod] - public async Task SimilarProductAddPopupGet_CallsPrepareSimilarProductModel() - { - // PrepareSimilarProductModel() now reads scope internally (Task 9). - _productViewModelServiceMock.Setup(s => s.PrepareSimilarProductModel()) - .ReturnsAsync(new ProductModel.AddSimilarProductModel()); - - var result = await _controller.SimilarProductAddPopup("p1") as ViewResult; - - Assert.IsNotNull(result); - var model = result.Model as ProductModel.AddSimilarProductModel; - Assert.IsNotNull(model); - Assert.AreEqual("p1", model.ProductId); - _productViewModelServiceMock.Verify(s => s.PrepareSimilarProductModel(), Times.Once); - } - - // --- SimilarProductAddPopupList ----------------------------------------------------------------- - - [TestMethod] - public async Task SimilarProductAddPopupList_UsesScopeDefaultStoreId() - { - _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); - _productViewModelServiceMock - .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) - .ReturnsAsync((new List(), 0)); - - var model = new ProductModel.AddSimilarProductModel(); - var result = await _controller.SimilarProductAddPopupList( - new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); - - Assert.IsInstanceOfType(result); - Assert.AreEqual("store-1", model.SearchStoreId); - } - - [TestMethod] - public async Task SimilarProductAddPopupList_NoDefaultStoreId_DoesNotOverrideModelSearchStoreId() - { - _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); - _productViewModelServiceMock - .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) - .ReturnsAsync((new List(), 0)); - - var model = new ProductModel.AddSimilarProductModel { SearchStoreId = "explicit" }; - var result = await _controller.SimilarProductAddPopupList( - new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); - - Assert.IsInstanceOfType(result); - Assert.AreEqual("explicit", model.SearchStoreId); - } - - // --- SimilarProductAddPopup (POST) -------------------------------------------------------------- - // HasAccess (strict): closes a real gap - Vendor's original SimilarProductAddPopup(POST) had no - // ownership check at all, letting any vendor attach similar-product mappings onto another vendor's - // product by posting its id. - - [TestMethod] - public async Task SimilarProductAddPopupPost_ScopeDeniesAccess_ReturnsContentMessage_DoesNotInsert() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.AddSimilarProductModel { ProductId = "p1", SelectedProductIds = ["p2"] }; - - var result = await _controller.SimilarProductAddPopup(model); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.InsertSimilarProductModel(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task SimilarProductAddPopupPost_ScopeGrantsAccess_ValidModel_Inserts() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.AddSimilarProductModel { ProductId = "p1", SelectedProductIds = ["p2"] }; - - var result = await _controller.SimilarProductAddPopup(model); - - var content = result as ContentResult; - Assert.IsNotNull(content); - Assert.AreEqual("", content.Content); - _productViewModelServiceMock.Verify(s => s.InsertSimilarProductModel(model), Times.Once); - } - - [TestMethod] - public async Task SimilarProductAddPopupPost_ScopeGrantsAccess_InvalidModel_ReturnsViewWithRepreparedModel() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var reprepared = new ProductModel.AddSimilarProductModel(); - _productViewModelServiceMock.Setup(s => s.PrepareSimilarProductModel()).ReturnsAsync(reprepared); - var model = new ProductModel.AddSimilarProductModel { ProductId = "p1" }; - _controller.ModelState.AddModelError("x", "error"); - - var result = await _controller.SimilarProductAddPopup(model); - - var view = result as ViewResult; - Assert.IsNotNull(view); - Assert.AreSame(reprepared, view.Model); - _productViewModelServiceMock.Verify(s => s.InsertSimilarProductModel(It.IsAny()), Times.Never); - } - - // --- BundleProductList ------------------------------------------------------------------------ - // HasAccess (strict), not CanView: same shape as RelatedProductList/SimilarProductList above. - - [TestMethod] - public async Task BundleProductList_ScopeDeniesAccess_ReturnsErrorJson() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); - - var result = await _controller.BundleProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - } - - [TestMethod] - public async Task BundleProductList_ScopeGrantsAccess_ReturnsGrid() - { - var product = new Product { Id = "p1" }; - product.BundleProducts.Add(new BundleProduct { Id = "r1", ProductId = "p2", DisplayOrder = 0, Quantity = 3 }); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _productServiceMock.Setup(p => p.GetProductById("p2", false)).ReturnsAsync(new Product { Id = "p2", Name = "Second" }); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); - - var result = await _controller.BundleProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); - - var json = result as JsonResult; - Assert.IsNotNull(json); - var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; - Assert.IsNotNull(gridModel); - Assert.AreEqual(1, gridModel.Total); - } - - // --- BundleProductUpdate ---------------------------------------------------------------------- - - [TestMethod] - public async Task BundleProductUpdate_ScopeDeniesAccess_ReturnsErrorJson_DoesNotUpdate() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.BundleProductModel { ProductBundleId = "p1" }; - - var result = await _controller.BundleProductUpdate(model); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.UpdateBundleProductModel(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task BundleProductUpdate_ScopeGrantsAccess_ValidModel_Updates() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.BundleProductModel { ProductBundleId = "p1" }; - - var result = await _controller.BundleProductUpdate(model); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.UpdateBundleProductModel(model), Times.Once); - } - - // --- BundleProductDelete ---------------------------------------------------------------------- - - [TestMethod] - public async Task BundleProductDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.BundleProductModel { ProductBundleId = "p1" }; - - var result = await _controller.BundleProductDelete(model); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.DeleteBundleProductModel(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task BundleProductDelete_ScopeGrantsAccess_ValidModel_Deletes() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.BundleProductModel { ProductBundleId = "p1" }; - - var result = await _controller.BundleProductDelete(model); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.DeleteBundleProductModel(model), Times.Once); - } - - // --- BundleProductAddPopup (GET) -------------------------------------------------------------- - - [TestMethod] - public async Task BundleProductAddPopupGet_CallsPrepareBundleProductModel() - { - // PrepareBundleProductModel() now reads scope internally (Task 9). - _productViewModelServiceMock.Setup(s => s.PrepareBundleProductModel()) - .ReturnsAsync(new ProductModel.AddBundleProductModel()); - - var result = await _controller.BundleProductAddPopup("p1") as ViewResult; - - Assert.IsNotNull(result); - var model = result.Model as ProductModel.AddBundleProductModel; - Assert.IsNotNull(model); - Assert.AreEqual("p1", model.ProductId); - _productViewModelServiceMock.Verify(s => s.PrepareBundleProductModel(), Times.Once); - } - - // --- BundleProductAddPopupList ----------------------------------------------------------------- - - [TestMethod] - public async Task BundleProductAddPopupList_UsesScopeDefaultStoreId() - { - _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); - _productViewModelServiceMock - .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) - .ReturnsAsync((new List(), 0)); - - var model = new ProductModel.AddBundleProductModel(); - var result = await _controller.BundleProductAddPopupList( - new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); - - Assert.IsInstanceOfType(result); - Assert.AreEqual("store-1", model.SearchStoreId); - } - - [TestMethod] - public async Task BundleProductAddPopupList_NoDefaultStoreId_DoesNotOverrideModelSearchStoreId() - { - _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); - _productViewModelServiceMock - .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) - .ReturnsAsync((new List(), 0)); - - var model = new ProductModel.AddBundleProductModel { SearchStoreId = "explicit" }; - var result = await _controller.BundleProductAddPopupList( - new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); - - Assert.IsInstanceOfType(result); - Assert.AreEqual("explicit", model.SearchStoreId); - } - - // --- BundleProductAddPopup (POST) -------------------------------------------------------------- - // HasAccess (strict): closes a real gap - Vendor's original BundleProductAddPopup(POST) had no - // ownership check at all, letting any vendor attach bundle-product mappings onto another vendor's - // product by posting its id. - - [TestMethod] - public async Task BundleProductAddPopupPost_ScopeDeniesAccess_ReturnsContentMessage_DoesNotInsert() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.AddBundleProductModel { ProductId = "p1", SelectedProductIds = ["p2"] }; - - var result = await _controller.BundleProductAddPopup(model); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.InsertBundleProductModel(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task BundleProductAddPopupPost_ScopeGrantsAccess_ValidModel_Inserts() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.AddBundleProductModel { ProductId = "p1", SelectedProductIds = ["p2"] }; - - var result = await _controller.BundleProductAddPopup(model); - - var content = result as ContentResult; - Assert.IsNotNull(content); - Assert.AreEqual("", content.Content); - _productViewModelServiceMock.Verify(s => s.InsertBundleProductModel(model), Times.Once); - } - - [TestMethod] - public async Task BundleProductAddPopupPost_ScopeGrantsAccess_InvalidModel_ReturnsViewWithRepreparedModel() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var reprepared = new ProductModel.AddBundleProductModel(); - _productViewModelServiceMock.Setup(s => s.PrepareBundleProductModel()).ReturnsAsync(reprepared); - var model = new ProductModel.AddBundleProductModel { ProductId = "p1" }; - _controller.ModelState.AddModelError("x", "error"); - - var result = await _controller.BundleProductAddPopup(model); - - var view = result as ViewResult; - Assert.IsNotNull(view); - Assert.AreSame(reprepared, view.Model); - _productViewModelServiceMock.Verify(s => s.InsertBundleProductModel(It.IsAny()), Times.Never); - } - - // --- CrossSellProductList --------------------------------------------------------------------- - // HasAccess (strict), not CanView: same shape as RelatedProductList/BundleProductList above. Admin's - // original CrossSellProductList had no check at all. - - [TestMethod] - public async Task CrossSellProductList_ScopeDeniesAccess_ReturnsErrorJson() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); - - var result = await _controller.CrossSellProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - } - - [TestMethod] - public async Task CrossSellProductList_ScopeGrantsAccess_ReturnsGrid() - { - var product = new Product { Id = "p1" }; - product.CrossSellProduct.Add("p2"); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _productServiceMock.Setup(p => p.GetProductById("p2", false)).ReturnsAsync(new Product { Id = "p2", Name = "Second" }); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); - - var result = await _controller.CrossSellProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); - - var json = result as JsonResult; - Assert.IsNotNull(json); - var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; - Assert.IsNotNull(gridModel); - Assert.AreEqual(1, gridModel.Total); - } - - // --- CrossSellProductDelete ------------------------------------------------------------------- - // Admin/Store both throw ArgumentException when the product does not exist; Vendor's original - // CrossSellProductDelete had no ownership check at all - closed here the same way as List above. - - [TestMethod] - public async Task CrossSellProductDelete_ProductNotFound_Throws() - { - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync((Product)null); - var model = new ProductModel.CrossSellProductModel { ProductId = "p1", Id = "p2" }; - - await Assert.ThrowsExactlyAsync(() => _controller.CrossSellProductDelete(model)); - } - - [TestMethod] - public async Task CrossSellProductDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() - { - var product = new Product { Id = "p1" }; - product.CrossSellProduct.Add("p2"); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.CrossSellProductModel { ProductId = "p1", Id = "p2" }; - - var result = await _controller.CrossSellProductDelete(model); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.DeleteCrossSellProduct(It.IsAny(), It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task CrossSellProductDelete_NoMatchingCrossSellProduct_Throws() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.CrossSellProductModel { ProductId = "p1", Id = "p2" }; - - await Assert.ThrowsExactlyAsync(() => _controller.CrossSellProductDelete(model)); - } - - [TestMethod] - public async Task CrossSellProductDelete_ScopeGrantsAccess_ValidModel_Deletes() - { - var product = new Product { Id = "p1" }; - product.CrossSellProduct.Add("p2"); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.CrossSellProductModel { ProductId = "p1", Id = "p2" }; - - var result = await _controller.CrossSellProductDelete(model); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.DeleteCrossSellProduct("p1", "p2"), Times.Once); - } - - // --- CrossSellProductAddPopup (GET) ------------------------------------------------------------- - - [TestMethod] - public async Task CrossSellProductAddPopupGet_CallsPrepareCrossSellProductModel() - { - // PrepareCrossSellProductModel() now reads scope internally (Task 9). - _productViewModelServiceMock.Setup(s => s.PrepareCrossSellProductModel()) - .ReturnsAsync(new ProductModel.AddCrossSellProductModel()); - - var result = await _controller.CrossSellProductAddPopup("p1") as ViewResult; - - Assert.IsNotNull(result); - var model = result.Model as ProductModel.AddCrossSellProductModel; - Assert.IsNotNull(model); - Assert.AreEqual("p1", model.ProductId); - _productViewModelServiceMock.Verify(s => s.PrepareCrossSellProductModel(), Times.Once); - } - - // --- CrossSellProductAddPopupList --------------------------------------------------------------- - - [TestMethod] - public async Task CrossSellProductAddPopupList_UsesScopeDefaultStoreId() - { - _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); - _productViewModelServiceMock - .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) - .ReturnsAsync((new List(), 0)); - - var model = new ProductModel.AddCrossSellProductModel(); - var result = await _controller.CrossSellProductAddPopupList( - new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); - - Assert.IsInstanceOfType(result); - Assert.AreEqual("store-1", model.SearchStoreId); - } - - [TestMethod] - public async Task CrossSellProductAddPopupList_NoDefaultStoreId_DoesNotOverrideModelSearchStoreId() - { - _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); - _productViewModelServiceMock - .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) - .ReturnsAsync((new List(), 0)); - - var model = new ProductModel.AddCrossSellProductModel { SearchStoreId = "explicit" }; - var result = await _controller.CrossSellProductAddPopupList( - new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); - - Assert.IsInstanceOfType(result); - Assert.AreEqual("explicit", model.SearchStoreId); - } - - // --- CrossSellProductAddPopup (POST) -------------------------------------------------------------- - // HasAccess (strict): closes a real gap - Vendor's original CrossSellProductAddPopup(POST) had no - // ownership check at all, letting any vendor attach cross-sell-product mappings onto another - // vendor's product by posting its id. - - [TestMethod] - public async Task CrossSellProductAddPopupPost_ScopeDeniesAccess_ReturnsContentMessage_DoesNotInsert() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.AddCrossSellProductModel { ProductId = "p1", SelectedProductIds = ["p2"] }; - - var result = await _controller.CrossSellProductAddPopup(model); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.InsertCrossSellProductModel(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task CrossSellProductAddPopupPost_ScopeGrantsAccess_ValidModel_Inserts() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.AddCrossSellProductModel { ProductId = "p1", SelectedProductIds = ["p2"] }; - - var result = await _controller.CrossSellProductAddPopup(model); - - var content = result as ContentResult; - Assert.IsNotNull(content); - Assert.AreEqual("", content.Content); - _productViewModelServiceMock.Verify(s => s.InsertCrossSellProductModel(model), Times.Once); - } - - [TestMethod] - public async Task CrossSellProductAddPopupPost_ScopeGrantsAccess_InvalidModel_ReturnsViewWithRepreparedModel() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var reprepared = new ProductModel.AddCrossSellProductModel(); - _productViewModelServiceMock.Setup(s => s.PrepareCrossSellProductModel()).ReturnsAsync(reprepared); - var model = new ProductModel.AddCrossSellProductModel { ProductId = "p1" }; - _controller.ModelState.AddModelError("x", "error"); - - var result = await _controller.CrossSellProductAddPopup(model); - - var view = result as ViewResult; - Assert.IsNotNull(view); - Assert.AreSame(reprepared, view.Model); - _productViewModelServiceMock.Verify(s => s.InsertCrossSellProductModel(It.IsAny()), Times.Never); - } - - // --- RecommendedProductList --------------------------------------------------------------------- - // HasAccess (strict), not CanView: same shape as CrossSellProductList above. Admin's original - // RecommendedProductList had no check at all. Vendor's original signature also dropped the - // DataSourceRequest command parameter entirely - kept here for parity with Admin/Store. - - [TestMethod] - public async Task RecommendedProductList_ScopeDeniesAccess_ReturnsErrorJson() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); - - var result = await _controller.RecommendedProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - } - - [TestMethod] - public async Task RecommendedProductList_ScopeGrantsAccess_ReturnsGrid() - { - var product = new Product { Id = "p1" }; - product.RecommendedProduct.Add("p2"); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _productServiceMock.Setup(p => p.GetProductById("p2", false)).ReturnsAsync(new Product { Id = "p2", Name = "Second" }); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); - - var result = await _controller.RecommendedProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); - - var json = result as JsonResult; - Assert.IsNotNull(json); - var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; - Assert.IsNotNull(gridModel); - Assert.AreEqual(1, gridModel.Total); - } - - // --- RecommendedProductDelete ------------------------------------------------------------------- - // Admin/Store/Vendor all throw ArgumentException when the product does not exist; Vendor's original - // RecommendedProductDelete had no ownership check at all - closed here the same way as List above. - - [TestMethod] - public async Task RecommendedProductDelete_ProductNotFound_Throws() - { - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync((Product)null); - var model = new ProductModel.RecommendedProductModel { ProductId = "p1", Id = "p2" }; - - await Assert.ThrowsExactlyAsync(() => _controller.RecommendedProductDelete(model)); - } - - [TestMethod] - public async Task RecommendedProductDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() - { - var product = new Product { Id = "p1" }; - product.RecommendedProduct.Add("p2"); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.RecommendedProductModel { ProductId = "p1", Id = "p2" }; - - var result = await _controller.RecommendedProductDelete(model); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.DeleteRecommendedProduct(It.IsAny(), It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task RecommendedProductDelete_NoMatchingRecommendedProduct_Throws() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.RecommendedProductModel { ProductId = "p1", Id = "p2" }; - - await Assert.ThrowsExactlyAsync(() => _controller.RecommendedProductDelete(model)); - } - - [TestMethod] - public async Task RecommendedProductDelete_ScopeGrantsAccess_ValidModel_Deletes() - { - var product = new Product { Id = "p1" }; - product.RecommendedProduct.Add("p2"); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.RecommendedProductModel { ProductId = "p1", Id = "p2" }; - - var result = await _controller.RecommendedProductDelete(model); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.DeleteRecommendedProduct("p1", "p2"), Times.Once); - } - - // --- RecommendedProductAddPopup (GET) ------------------------------------------------------------- - - [TestMethod] - public async Task RecommendedProductAddPopupGet_CallsPrepareRecommendedProductModel() - { - // PrepareRecommendedProductModel() now reads scope internally (Task 9). - _productViewModelServiceMock.Setup(s => s.PrepareRecommendedProductModel()) - .ReturnsAsync(new ProductModel.AddRecommendedProductModel()); - - var result = await _controller.RecommendedProductAddPopup("p1") as ViewResult; - - Assert.IsNotNull(result); - var model = result.Model as ProductModel.AddRecommendedProductModel; - Assert.IsNotNull(model); - Assert.AreEqual("p1", model.ProductId); - _productViewModelServiceMock.Verify(s => s.PrepareRecommendedProductModel(), Times.Once); - } - - // --- RecommendedProductAddPopupList --------------------------------------------------------------- - - [TestMethod] - public async Task RecommendedProductAddPopupList_UsesScopeDefaultStoreId() - { - _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); - _productViewModelServiceMock - .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) - .ReturnsAsync((new List(), 0)); - - var model = new ProductModel.AddRecommendedProductModel(); - var result = await _controller.RecommendedProductAddPopupList( - new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); - - Assert.IsInstanceOfType(result); - Assert.AreEqual("store-1", model.SearchStoreId); - } - - [TestMethod] - public async Task RecommendedProductAddPopupList_NoDefaultStoreId_DoesNotOverrideModelSearchStoreId() - { - _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); - _productViewModelServiceMock - .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) - .ReturnsAsync((new List(), 0)); - - var model = new ProductModel.AddRecommendedProductModel { SearchStoreId = "explicit" }; - var result = await _controller.RecommendedProductAddPopupList( - new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); - - Assert.IsInstanceOfType(result); - Assert.AreEqual("explicit", model.SearchStoreId); - } - - // --- RecommendedProductAddPopup (POST) -------------------------------------------------------------- - // HasAccess (strict): closes a real gap - Vendor's original RecommendedProductAddPopup(POST) had no - // ownership check at all, letting any vendor attach recommended-product mappings onto another - // vendor's product by posting its id. - - [TestMethod] - public async Task RecommendedProductAddPopupPost_ScopeDeniesAccess_ReturnsContentMessage_DoesNotInsert() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.AddRecommendedProductModel { ProductId = "p1", SelectedProductIds = ["p2"] }; - - var result = await _controller.RecommendedProductAddPopup(model); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.InsertRecommendedProductModel(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task RecommendedProductAddPopupPost_ScopeGrantsAccess_ValidModel_Inserts() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.AddRecommendedProductModel { ProductId = "p1", SelectedProductIds = ["p2"] }; - - var result = await _controller.RecommendedProductAddPopup(model); - - var content = result as ContentResult; - Assert.IsNotNull(content); - Assert.AreEqual("", content.Content); - _productViewModelServiceMock.Verify(s => s.InsertRecommendedProductModel(model), Times.Once); - } - - [TestMethod] - public async Task RecommendedProductAddPopupPost_ScopeGrantsAccess_InvalidModel_ReturnsViewWithRepreparedModel() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var reprepared = new ProductModel.AddRecommendedProductModel(); - _productViewModelServiceMock.Setup(s => s.PrepareRecommendedProductModel()).ReturnsAsync(reprepared); - var model = new ProductModel.AddRecommendedProductModel { ProductId = "p1" }; - _controller.ModelState.AddModelError("x", "error"); - - var result = await _controller.RecommendedProductAddPopup(model); - - var view = result as ViewResult; - Assert.IsNotNull(view); - Assert.AreSame(reprepared, view.Model); - _productViewModelServiceMock.Verify(s => s.InsertRecommendedProductModel(It.IsAny()), Times.Never); - } - - // --- AssociatedProductList ----------------------------------------------------------------------- - // HasAccess (strict), not CanView: same shape as RelatedProductList above. Admin's original had no - // check at all. - - [TestMethod] - public async Task AssociatedProductList_ScopeDeniesAccess_ReturnsErrorJson() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); - - var result = await _controller.AssociatedProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - } - - [TestMethod] - public async Task AssociatedProductList_ScopeGrantsAccess_ReturnsGrid() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); - _productServiceMock.Setup(p => p.GetAssociatedProducts("p1", CatalogAccess.IncludingHidden(), "")) - .ReturnsAsync(new List { new() { Id = "a1", Name = "Assoc", DisplayOrder = 1 } }); - - var result = await _controller.AssociatedProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); - - var json = result as JsonResult; - Assert.IsNotNull(json); - var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; - Assert.IsNotNull(gridModel); - Assert.AreEqual(1, gridModel.Total); - } - - // --- AssociatedProductUpdate --------------------------------------------------------------------- - - [TestMethod] - public async Task AssociatedProductUpdate_NotFound_Throws() - { - _productServiceMock.Setup(p => p.GetProductById("a1", false)).ReturnsAsync((Product)null); - var model = new ProductModel.AssociatedProductModel { Id = "a1" }; - - await Assert.ThrowsExactlyAsync(() => _controller.AssociatedProductUpdate(model)); - } - - [TestMethod] - public async Task AssociatedProductUpdate_ScopeDeniesAccess_ReturnsErrorJson_DoesNotUpdate() - { - var associatedProduct = new Product { Id = "a1" }; - _productServiceMock.Setup(p => p.GetProductById("a1", false)).ReturnsAsync(associatedProduct); - _scopeMock.Setup(s => s.HasAccess(associatedProduct)).ReturnsAsync(false); - var model = new ProductModel.AssociatedProductModel { Id = "a1" }; - - var result = await _controller.AssociatedProductUpdate(model); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productServiceMock.Verify(p => p.UpdateAssociatedProduct(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task AssociatedProductUpdate_ScopeGrantsAccess_ValidModel_Updates() - { - var associatedProduct = new Product { Id = "a1" }; - _productServiceMock.Setup(p => p.GetProductById("a1", false)).ReturnsAsync(associatedProduct); - _scopeMock.Setup(s => s.HasAccess(associatedProduct)).ReturnsAsync(true); - var model = new ProductModel.AssociatedProductModel { Id = "a1", DisplayOrder = 5 }; - - var result = await _controller.AssociatedProductUpdate(model); - - Assert.IsInstanceOfType(result); - Assert.AreEqual(5, associatedProduct.DisplayOrder); - _productServiceMock.Verify(p => p.UpdateAssociatedProduct(associatedProduct), Times.Once); - } - - // --- AssociatedProductDelete --------------------------------------------------------------------- - - [TestMethod] - public async Task AssociatedProductDelete_NotFound_Throws() - { - _productServiceMock.Setup(p => p.GetProductById("a1", false)).ReturnsAsync((Product)null); - var model = new ProductModel.AssociatedProductModel { Id = "a1" }; - - await Assert.ThrowsExactlyAsync(() => _controller.AssociatedProductDelete(model)); - } - - [TestMethod] - public async Task AssociatedProductDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() - { - var product = new Product { Id = "a1" }; - _productServiceMock.Setup(p => p.GetProductById("a1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.AssociatedProductModel { Id = "a1" }; - - var result = await _controller.AssociatedProductDelete(model); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.DeleteAssociatedProduct(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task AssociatedProductDelete_ScopeGrantsAccess_ValidModel_Deletes() - { - var product = new Product { Id = "a1" }; - _productServiceMock.Setup(p => p.GetProductById("a1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.AssociatedProductModel { Id = "a1" }; - - var result = await _controller.AssociatedProductDelete(model); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.DeleteAssociatedProduct(product), Times.Once); - } - - // --- AssociatedProductAddPopup (GET) ------------------------------------------------------------- - - [TestMethod] - public async Task AssociatedProductAddPopupGet_CallsPrepareAssociatedProductModel() - { - // PrepareAssociatedProductModel() now reads scope internally (Task 9). - _productViewModelServiceMock.Setup(s => s.PrepareAssociatedProductModel()) - .ReturnsAsync(new ProductModel.AddAssociatedProductModel()); - - var result = await _controller.AssociatedProductAddPopup("p1") as ViewResult; - - Assert.IsNotNull(result); - var model = result.Model as ProductModel.AddAssociatedProductModel; - Assert.IsNotNull(model); - Assert.AreEqual("p1", model.ProductId); - _productViewModelServiceMock.Verify(s => s.PrepareAssociatedProductModel(), Times.Once); - } - - // --- AssociatedProductAddPopupList --------------------------------------------------------------- - - [TestMethod] - public async Task AssociatedProductAddPopupList_UsesScopeDefaultStoreId() - { - _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); - _productViewModelServiceMock - .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) - .ReturnsAsync((new List(), 0)); - - var model = new ProductModel.AddAssociatedProductModel(); - var result = await _controller.AssociatedProductAddPopupList( - new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); - - Assert.IsInstanceOfType(result); - Assert.AreEqual("store-1", model.SearchStoreId); - } - - [TestMethod] - public async Task AssociatedProductAddPopupList_NoDefaultStoreId_DoesNotOverrideModelSearchStoreId() - { - _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); - _productViewModelServiceMock - .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) - .ReturnsAsync((new List(), 0)); - - var model = new ProductModel.AddAssociatedProductModel { SearchStoreId = "explicit" }; - var result = await _controller.AssociatedProductAddPopupList( - new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); - - Assert.IsInstanceOfType(result); - Assert.AreEqual("explicit", model.SearchStoreId); - } - - // --- AssociatedProductAddPopup (POST) ------------------------------------------------------------ - // HasAccess (strict) on the parent product: closes a real gap - Vendor's controller had no ownership - // check on the parent product (model.ProductId) at all, in either layer. Vendor's own (host-specific) - // service already filtered each selected product via HasAccessToProduct before reparenting it - // (Grand.Web.Vendor/Services/ProductViewModelService.cs InsertAssociatedProductModel), but the parent - // was never checked anywhere, letting a vendor attach their own products under another vendor's - // grouped product. This controller-level fix (parent HasAccess + per-selected-id HasAccess, matching - // Store's original controller-level filtering) is necessary regardless of that pre-existing severity, - // since BaseProductController uses AdminShared's unfiltered IProductViewModelService - once Vendor is - // subclassed onto this base (Task 11), it loses its own service's per-id filter entirely, so the - // controller must enforce both checks itself. - - [TestMethod] - public async Task AssociatedProductAddPopupPost_ScopeDeniesAccessToParent_ReturnsContentMessage_DoesNotInsert() - { - var parent = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(parent); - _scopeMock.Setup(s => s.HasAccess(parent)).ReturnsAsync(false); - var model = new ProductModel.AddAssociatedProductModel { ProductId = "p1", SelectedProductIds = ["a1"] }; - - var result = await _controller.AssociatedProductAddPopup(model); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.InsertAssociatedProductModel(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task AssociatedProductAddPopupPost_ScopeGrantsAccess_AllSelectedIdsAllowed_InsertsAll() - { - var parent = new Product { Id = "p1" }; - var selected = new Product { Id = "a1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(parent); - _productServiceMock.Setup(p => p.GetProductById("a1", false)).ReturnsAsync(selected); - _scopeMock.Setup(s => s.HasAccess(parent)).ReturnsAsync(true); - _scopeMock.Setup(s => s.HasAccess(selected)).ReturnsAsync(true); - var model = new ProductModel.AddAssociatedProductModel { ProductId = "p1", SelectedProductIds = ["a1"] }; - - var result = await _controller.AssociatedProductAddPopup(model); - - var content = result as ContentResult; - Assert.IsNotNull(content); - Assert.AreEqual("", content.Content); - CollectionAssert.AreEqual(new[] { "a1" }, model.SelectedProductIds); - _productViewModelServiceMock.Verify(s => s.InsertAssociatedProductModel(model), Times.Once); - } - - [TestMethod] - public async Task AssociatedProductAddPopupPost_ScopeGrantsAccessToParent_FiltersOutDeniedSelectedIds() - { - var parent = new Product { Id = "p1" }; - var allowed = new Product { Id = "a1" }; - var denied = new Product { Id = "a2" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(parent); - _productServiceMock.Setup(p => p.GetProductById("a1", false)).ReturnsAsync(allowed); - _productServiceMock.Setup(p => p.GetProductById("a2", false)).ReturnsAsync(denied); - _scopeMock.Setup(s => s.HasAccess(parent)).ReturnsAsync(true); - _scopeMock.Setup(s => s.HasAccess(allowed)).ReturnsAsync(true); - _scopeMock.Setup(s => s.HasAccess(denied)).ReturnsAsync(false); - var model = new ProductModel.AddAssociatedProductModel { ProductId = "p1", SelectedProductIds = ["a1", "a2"] }; - - var result = await _controller.AssociatedProductAddPopup(model); - - Assert.IsInstanceOfType(result); - CollectionAssert.AreEqual(new[] { "a1" }, model.SelectedProductIds); - _productViewModelServiceMock.Verify(s => s.InsertAssociatedProductModel(model), Times.Once); - } - - [TestMethod] - public async Task AssociatedProductAddPopupPost_AllSelectedIdsDenied_DoesNotInsert() - { - var parent = new Product { Id = "p1" }; - var denied = new Product { Id = "a1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(parent); - _productServiceMock.Setup(p => p.GetProductById("a1", false)).ReturnsAsync(denied); - _scopeMock.Setup(s => s.HasAccess(parent)).ReturnsAsync(true); - _scopeMock.Setup(s => s.HasAccess(denied)).ReturnsAsync(false); - var model = new ProductModel.AddAssociatedProductModel { ProductId = "p1", SelectedProductIds = ["a1"] }; - - var result = await _controller.AssociatedProductAddPopup(model); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.InsertAssociatedProductModel(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task AssociatedProductAddPopupPost_ScopeGrantsAccess_InvalidModel_ReturnsViewWithRepreparedModel() - { - var parent = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(parent); - _scopeMock.Setup(s => s.HasAccess(parent)).ReturnsAsync(true); - var reprepared = new ProductModel.AddAssociatedProductModel(); - _productViewModelServiceMock.Setup(s => s.PrepareAssociatedProductModel()).ReturnsAsync(reprepared); - var model = new ProductModel.AddAssociatedProductModel { ProductId = "p1" }; - _controller.ModelState.AddModelError("x", "error"); - - var result = await _controller.AssociatedProductAddPopup(model); - - var view = result as ViewResult; - Assert.IsNotNull(view); - Assert.AreSame(reprepared, view.Model); - _productViewModelServiceMock.Verify(s => s.InsertAssociatedProductModel(It.IsAny()), Times.Never); - } - - // --- Product pictures --------------------------------------------------------------------------- - // ProductPictureAdd is deliberately not covered here (same rationale as Store's original - // ProductControllerTests): reaching its HasAccess check requires a non-empty IFormFileCollection and - // a prior Pictures-permission check via IRequestPermissionService, disproportionate setup for what is - // otherwise the same one-line HasAccess condition covered everywhere else in this region. - - // --- ProductPictureList -------------------------------------------------------------------------- - - [TestMethod] - public async Task ProductPictureList_ScopeDeniesAccess_ReturnsErrorJson() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); - - var result = await _controller.ProductPictureList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.PrepareProductPicturesModel(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ProductPictureList_ScopeGrantsAccess_ReturnsGrid() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); - _productViewModelServiceMock.Setup(s => s.PrepareProductPicturesModel(product)) - .ReturnsAsync(new List { new() { Id = "pic1" } }); - - var result = await _controller.ProductPictureList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); - - var json = result as JsonResult; - Assert.IsNotNull(json); - var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; - Assert.IsNotNull(gridModel); - Assert.AreEqual(1, gridModel.Total); - } - - // --- ProductPicturePopup (GET) ------------------------------------------------------------------- - - [TestMethod] - public async Task ProductPicturePopupGet_ProductNotFound_ReturnsContent() - { - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync((Product)null); - - var result = await _controller.ProductPicturePopup("p1", "pic1"); - - var content = result as ContentResult; - Assert.IsNotNull(content); - Assert.AreEqual("Product not exist", content.Content); - _scopeMock.Verify(s => s.HasAccess(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ProductPicturePopupGet_ScopeDeniesAccess_ReturnsErrorJson() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - - var result = await _controller.ProductPicturePopup("p1", "pic1"); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - } - - [TestMethod] - public async Task ProductPicturePopupGet_ScopeGrantsAccess_PictureNotFound_ReturnsContent() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - - var result = await _controller.ProductPicturePopup("p1", "pic1"); - - var content = result as ContentResult; - Assert.IsNotNull(content); - Assert.AreEqual("Product picture not exist", content.Content); - } - - [TestMethod] - public async Task ProductPicturePopupGet_ScopeGrantsAccess_PictureFound_ReturnsView() - { - var pp = new ProductPicture { Id = "pic1" }; - var product = new Product { Id = "p1" }; - product.ProductPictures.Add(pp); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.ProductPictureModel { Id = "pic1" }; - _productViewModelServiceMock.Setup(s => s.PrepareProductPictureModel(product, pp)) - .ReturnsAsync((model, (Picture)null)); - - var result = await _controller.ProductPicturePopup("p1", "pic1"); - - var view = result as ViewResult; - Assert.IsNotNull(view); - Assert.AreSame(model, view.Model); - } - - // --- ProductPicturePopup (POST) ------------------------------------------------------------------ - - [TestMethod] - public async Task ProductPicturePopupPost_ProductNotFound_Throws() - { - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync((Product)null); - var model = new ProductModel.ProductPictureModel { ProductId = "p1", Id = "pic1" }; - - await Assert.ThrowsExactlyAsync(() => _controller.ProductPicturePopup(model)); - } - - [TestMethod] - public async Task ProductPicturePopupPost_ScopeDeniesAccess_Throws_DoesNotUpdate() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.ProductPictureModel { ProductId = "p1", Id = "pic1" }; - - // Regression guard: Vendor's original ProductPicturePopup(POST) had no access check at all, - // letting any vendor rename/re-alt-text another vendor's product picture by posting its - // productId/model.Id. - await Assert.ThrowsExactlyAsync(() => _controller.ProductPicturePopup(model)); - _productViewModelServiceMock.Verify(s => s.UpdateProductPicture(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ProductPicturePopupPost_ScopeGrantsAccess_PictureNotFound_Throws() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.ProductPictureModel { ProductId = "p1", Id = "pic1" }; - - await Assert.ThrowsExactlyAsync(() => _controller.ProductPicturePopup(model)); - } - - [TestMethod] - public async Task ProductPicturePopupPost_ScopeGrantsAccess_ValidModel_Updates() - { - var pp = new ProductPicture { Id = "pic1" }; - var product = new Product { Id = "p1" }; - product.ProductPictures.Add(pp); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.ProductPictureModel { ProductId = "p1", Id = "pic1" }; - - var result = await _controller.ProductPicturePopup(model); - - var content = result as ContentResult; - Assert.IsNotNull(content); - Assert.AreEqual("", content.Content); - _productViewModelServiceMock.Verify(s => s.UpdateProductPicture(model), Times.Once); - } - - [TestMethod] - public async Task ProductPicturePopupPost_InvalidModelState_ReturnsView() - { - var model = new ProductModel.ProductPictureModel { ProductId = "p1", Id = "pic1" }; - _controller.ModelState.AddModelError("x", "error"); - - var result = await _controller.ProductPicturePopup(model); - - var view = result as ViewResult; - Assert.IsNotNull(view); - Assert.AreSame(model, view.Model); - _productServiceMock.Verify(p => p.GetProductById(It.IsAny(), It.IsAny()), Times.Never); - } - - // --- ProductPictureDelete ------------------------------------------------------------------------- - - [TestMethod] - public async Task ProductPictureDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.ProductPictureModel { ProductId = "p1", Id = "pic1" }; - - // Regression guard: Vendor's original ProductPictureDelete had no access check at all, letting - // any vendor delete another vendor's product picture by posting its productId/model.Id. - var result = await _controller.ProductPictureDelete(model); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.DeleteProductPicture(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ProductPictureDelete_ScopeGrantsAccess_ValidModel_Deletes() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.ProductPictureModel { ProductId = "p1", Id = "pic1" }; - - var result = await _controller.ProductPictureDelete(model); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.DeleteProductPicture(model), Times.Once); - } - - [TestMethod] - public async Task ProductPictureDelete_ScopeGrantsAccess_InvalidModelState_ReturnsKendoGridError_DoesNotDelete() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.ProductPictureModel { ProductId = "p1", Id = "pic1" }; - _controller.ModelState.AddModelError("x", "error"); - - var result = await _controller.ProductPictureDelete(model); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.DeleteProductPicture(It.IsAny()), Times.Never); - } - - // --- Product specification attributes ----------------------------------------------------------- - // GetOptionsByAttributeId is deliberately not covered here (same rationale as Product pictures' - // ProductPictureAdd note): identical, unscoped across all three hosts, no access check involved. - - // --- ProductSpecAttrList -------------------------------------------------------------------------- - - [TestMethod] - public async Task ProductSpecAttrList_ScopeDeniesAccess_ReturnsErrorJson() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); - - var result = await _controller.ProductSpecAttrList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.PrepareProductSpecificationAttributeModel(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ProductSpecAttrList_ScopeGrantsAccess_ReturnsGrid() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); - _productViewModelServiceMock.Setup(s => s.PrepareProductSpecificationAttributeModel(product)) - .ReturnsAsync(new List { new() { Id = "psa1" } }); - - var result = await _controller.ProductSpecAttrList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); - - Assert.IsInstanceOfType(result); - var json = (JsonResult)result; - var grid = (Grand.Web.Common.DataSource.DataSourceResult)json.Value; - Assert.AreEqual(1, grid.Total); - } - - // --- ProductSpecAttrPopup (GET) ----------------------------------------------------------------- - - [TestMethod] - public async Task ProductSpecAttrPopupGet_ScopeDeniesAccess_ReturnsContent() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - - var result = await _controller.ProductSpecAttrPopup(new Mock().Object, "p1", "psa1"); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify( - s => s.PrepareProductSpecificationAttributeModel(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ProductSpecAttrPopupGet_ScopeGrantsAccess_NewAttribute_ReturnsView() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var specAttrServiceMock = new Mock(); - specAttrServiceMock.Setup(s => s.GetSpecificationAttributes(It.IsAny(), 0, int.MaxValue)) - .ReturnsAsync(new PagedList(new List(), 0, int.MaxValue)); - - var result = await _controller.ProductSpecAttrPopup(specAttrServiceMock.Object, "p1", ""); - - var view = result as ViewResult; - Assert.IsNotNull(view); - var model = view.Model as ProductModel.AddProductSpecificationAttributeModel; - Assert.IsNotNull(model); - Assert.IsTrue(model.ShowOnProductPage); - } - - [TestMethod] - public async Task ProductSpecAttrPopupGet_ScopeGrantsAccess_ExistingAttribute_ReturnsPopulatedView() - { - var psa = new ProductSpecificationAttribute { Id = "psa1", SpecificationAttributeOptionId = "opt1" }; - var product = new Product { Id = "p1" }; - product.ProductSpecificationAttributes.Add(psa); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var specAttrServiceMock = new Mock(); - specAttrServiceMock.Setup(s => s.GetSpecificationAttributes(It.IsAny(), 0, int.MaxValue)) - .ReturnsAsync(new PagedList(new List(), 0, int.MaxValue)); - - var result = await _controller.ProductSpecAttrPopup(specAttrServiceMock.Object, "p1", "psa1"); - - var view = result as ViewResult; - Assert.IsNotNull(view); - var model = view.Model as ProductModel.AddProductSpecificationAttributeModel; - Assert.IsNotNull(model); - Assert.AreEqual("psa1", model.Id); - } - - // --- ProductSpecAttrPopup (POST) ---------------------------------------------------------------- - - [TestMethod] - public async Task ProductSpecAttrPopupPost_ProductNotFound_ReturnsContent() - { - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync((Product)null); - var model = new ProductModel.AddProductSpecificationAttributeModel { ProductId = "p1", Id = "psa1" }; - - var result = await _controller.ProductSpecAttrPopup(new Mock().Object, model); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify( - s => s.UpdateProductSpecificationAttributeModel(It.IsAny(), - It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ProductSpecAttrPopupPost_ScopeDeniesAccess_ReturnsContent_DoesNotInsertOrUpdate() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.AddProductSpecificationAttributeModel { ProductId = "p1", Id = "psa1" }; - - // Regression guard: Vendor's original ProductSpecAttrPopup(POST) had no access check at all, - // letting any vendor add/edit specification attributes on another vendor's product by posting - // its id. - var result = await _controller.ProductSpecAttrPopup(new Mock().Object, model); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify( - s => s.InsertProductSpecificationAttributeModel(It.IsAny(), - It.IsAny()), Times.Never); - _productViewModelServiceMock.Verify( - s => s.UpdateProductSpecificationAttributeModel(It.IsAny(), - It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ProductSpecAttrPopupPost_ScopeGrantsAccess_NewAttribute_Inserts() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.AddProductSpecificationAttributeModel { ProductId = "p1", Id = "psa-new" }; - - var result = await _controller.ProductSpecAttrPopup(new Mock().Object, model); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.InsertProductSpecificationAttributeModel(model, product), Times.Once); - } - - [TestMethod] - public async Task ProductSpecAttrPopupPost_ScopeGrantsAccess_ExistingAttribute_Updates() - { - var psa = new ProductSpecificationAttribute { Id = "psa1" }; - var product = new Product { Id = "p1" }; - product.ProductSpecificationAttributes.Add(psa); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.AddProductSpecificationAttributeModel { ProductId = "p1", Id = "psa1" }; - - var result = await _controller.ProductSpecAttrPopup(new Mock().Object, model); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.UpdateProductSpecificationAttributeModel(psa, model), Times.Once); - } - - [TestMethod] - public async Task ProductSpecAttrPopupPost_InvalidModelState_ReturnsView_DoesNotAccessProduct() - { - var model = new ProductModel.AddProductSpecificationAttributeModel { ProductId = "p1", Id = "psa1" }; - _controller.ModelState.AddModelError("x", "error"); - var specAttrServiceMock = new Mock(); - specAttrServiceMock.Setup(s => s.GetSpecificationAttributes(It.IsAny(), 0, int.MaxValue)) - .ReturnsAsync(new PagedList(new List(), 0, int.MaxValue)); - - var result = await _controller.ProductSpecAttrPopup(specAttrServiceMock.Object, model); - - Assert.IsInstanceOfType(result); - _productServiceMock.Verify(p => p.GetProductById(It.IsAny(), It.IsAny()), Times.Never); - } - - // --- ProductSpecAttrDelete ------------------------------------------------------------------------ - - [TestMethod] - public async Task ProductSpecAttrDelete_ProductNotFound_ReturnsContent() - { - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync((Product)null); - var model = new ProductSpecificationAttributeModel { ProductId = "p1", Id = "psa1" }; - - var result = await _controller.ProductSpecAttrDelete(model); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify( - s => s.DeleteProductSpecificationAttribute(It.IsAny(), It.IsAny()), - Times.Never); - } - - [TestMethod] - public async Task ProductSpecAttrDelete_ScopeDeniesAccess_ReturnsContent_DoesNotDelete() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductSpecificationAttributeModel { ProductId = "p1", Id = "psa1" }; - - // Regression guard: Vendor's original ProductSpecAttrDelete had no access check at all, letting - // any vendor delete another vendor's specification attribute mapping by posting its - // productId/model.Id. - var result = await _controller.ProductSpecAttrDelete(model); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify( - s => s.DeleteProductSpecificationAttribute(It.IsAny(), It.IsAny()), - Times.Never); - } - - [TestMethod] - public async Task ProductSpecAttrDelete_ScopeGrantsAccess_ValidModel_Deletes() - { - var psa = new ProductSpecificationAttribute { Id = "psa1" }; - var product = new Product { Id = "p1" }; - product.ProductSpecificationAttributes.Add(psa); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductSpecificationAttributeModel { ProductId = "p1", Id = "psa1" }; - - var result = await _controller.ProductSpecAttrDelete(model); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.DeleteProductSpecificationAttribute(product, psa), Times.Once); - } - - [TestMethod] - public async Task ProductSpecAttrDelete_ScopeGrantsAccess_AttributeNotFound_Throws() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductSpecificationAttributeModel { ProductId = "p1", Id = "missing" }; - - await Assert.ThrowsExactlyAsync(() => _controller.ProductSpecAttrDelete(model)); - } - - [TestMethod] - public async Task ProductSpecAttrDelete_InvalidModelState_ReturnsKendoGridError_DoesNotAccessProduct() - { - var model = new ProductSpecificationAttributeModel { ProductId = "p1", Id = "psa1" }; - _controller.ModelState.AddModelError("x", "error"); - - var result = await _controller.ProductSpecAttrDelete(model); - - Assert.IsInstanceOfType(result); - _productServiceMock.Verify(p => p.GetProductById(It.IsAny(), It.IsAny()), Times.Never); - } - - // --- Purchased with order ------------------------------------------------------------------------ - // Covers Admin and Store only - Vendor cannot bind to this signature (see the region comment in - // BaseProductController.cs: Vendor has its own, structurally different IOrderViewModelService and - // OrderListModel types). - - [TestMethod] - public async Task PurchasedWithOrders_PermissionDenied_ReturnsEmptyGrid_DoesNotLoadProduct() - { - _permissionServiceMock.Setup(p => p.Authorize(StandardPermission.ManageOrders)).ReturnsAsync(false); - var orderViewModelServiceMock = new Mock(); - - var result = await _controller.PurchasedWithOrders( - new Grand.Web.Common.DataSource.DataSourceRequest(), "p1", orderViewModelServiceMock.Object); - - var json = result as JsonResult; - Assert.IsNotNull(json); - var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; - Assert.IsNotNull(gridModel); - Assert.AreEqual(0, gridModel.Total); - Assert.IsNull(gridModel.Data); - _productServiceMock.Verify(p => p.GetProductById(It.IsAny(), It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task PurchasedWithOrders_ScopeDeniesAccess_ReturnsErrorJson() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); - var orderViewModelServiceMock = new Mock(); - - var result = await _controller.PurchasedWithOrders( - new Grand.Web.Common.DataSource.DataSourceRequest(), "p1", orderViewModelServiceMock.Object); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - orderViewModelServiceMock.Verify( - s => s.PrepareOrderModel(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task PurchasedWithOrders_ScopeGrantsAccess_UsesDefaultStoreIdAndReturnsGrid() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); - // DefaultStoreId stands in for Store's original model.StoreId = StaffStoreId (and for Admin's - // original, which left StoreId unset/null - GlobalAdminDataScope.DefaultStoreId is null). - _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); - var orders = new List { new() { Id = "order1" } }; - var orderViewModelServiceMock = new Mock(); - orderViewModelServiceMock - .Setup(s => s.PrepareOrderModel( - It.Is(m => m.ProductId == "p1" && m.StoreId == "store-1"), 1, 10)) - .ReturnsAsync((orders, orders.Count)); - - var result = await _controller.PurchasedWithOrders( - new Grand.Web.Common.DataSource.DataSourceRequest { Page = 1, PageSize = 10 }, "p1", - orderViewModelServiceMock.Object); - - var json = result as JsonResult; - Assert.IsNotNull(json); - var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; - Assert.IsNotNull(gridModel); - Assert.AreEqual(1, gridModel.Total); - } - - // --- Reviews --------------------------------------------------------------------------------- - - [TestMethod] - public async Task Reviews_ScopeDeniesAccess_ReturnsErrorJson_DoesNotLoadReviews() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); - var productReviewServiceMock = new Mock(); - - var result = await _controller.Reviews( - new Grand.Web.Common.DataSource.DataSourceRequest(), "p1", productReviewServiceMock.Object); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - productReviewServiceMock.Verify( - s => s.GetAllProductReviews(It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task Reviews_ScopeGrantsAccess_WithDefaultStoreId_FiltersReviewsByStore() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); - // DefaultStoreId stands in for Store's original storeId argument (the staff member's - // StaffStoreId, used to filter reviews to that store). - _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); - var review = new ProductReview { Id = "r1", ProductId = "p1" }; - var reviews = new PagedList(new List { review }, 0, int.MaxValue); - var productReviewServiceMock = new Mock(); - productReviewServiceMock - .Setup(s => s.GetAllProductReviews("", null, null, null, "", "store-1", "p1", 0, int.MaxValue)) - .ReturnsAsync(reviews); - - var result = await _controller.Reviews( - new Grand.Web.Common.DataSource.DataSourceRequest(), "p1", productReviewServiceMock.Object); - - var json = result as JsonResult; - Assert.IsNotNull(json); - var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; - Assert.IsNotNull(gridModel); - Assert.AreEqual(1, gridModel.Total); - _productViewModelServiceMock.Verify( - s => s.PrepareProductReviewModel(It.IsAny(), review, false, true), Times.Once); - } - - [TestMethod] - public async Task Reviews_ScopeGrantsAccess_NullDefaultStoreId_PassesEmptyStoreId() - { - // Matches both Admin's and Vendor's originals, which both passed "" literally (Vendor scopes by - // VendorId via the HasAccess check above, not by store - VendorProductDataScope.DefaultStoreId - // is null). - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); - _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); - var reviews = new PagedList(new List(), 0, int.MaxValue); - var productReviewServiceMock = new Mock(); - productReviewServiceMock - .Setup(s => s.GetAllProductReviews("", null, null, null, "", "", "p1", 0, int.MaxValue)) - .ReturnsAsync(reviews); - - var result = await _controller.Reviews( - new Grand.Web.Common.DataSource.DataSourceRequest(), "p1", productReviewServiceMock.Object); - - var json = result as JsonResult; - Assert.IsNotNull(json); - var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; - Assert.IsNotNull(gridModel); - Assert.AreEqual(0, gridModel.Total); - productReviewServiceMock.Verify( - s => s.GetAllProductReviews("", null, null, null, "", "", "p1", 0, int.MaxValue), Times.Once); - } - - // --- Export / Import --------------------------------------------------------------------------- - - [TestMethod] - public async Task ExportExcelAll_Success_ReturnsXlsxFile() - { - var model = new ProductListModel(); - var products = new List { new() { Id = "p1" } }; - // vendorId is forced inside PrepareProducts itself; storeId is forced at the call site below, - // same as every other PrepareProductsModel/PrepareProducts caller in this controller. - _productViewModelServiceMock.Setup(s => s.PrepareProducts(model)).ReturnsAsync(products); - var exportManagerMock = new Mock>(); - exportManagerMock.Setup(e => e.Export(products)).ReturnsAsync([1, 2, 3]); - - var result = await _controller.ExportExcelAll(model, exportManagerMock.Object); - - var file = result as FileContentResult; - Assert.IsNotNull(file); - Assert.AreEqual("text/xls", file.ContentType); - Assert.AreEqual("products.xlsx", file.FileDownloadName); - CollectionAssert.AreEqual(new byte[] { 1, 2, 3 }, file.FileContents); - } - - [TestMethod] - public async Task ExportExcelAll_UsesScopeDefaultStoreId() - { - // Regression test: found during a post-Phase-2 tenant-isolation audit that this action was the - // one PrepareProducts/PrepareProductsModel caller in the controller NOT forcing - // model.SearchStoreId = scope.DefaultStoreId, letting a Store-scoped user export other stores' - // products by posting an arbitrary SearchStoreId. - _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); - var model = new ProductListModel(); - _productViewModelServiceMock.Setup(s => s.PrepareProducts(model)).ReturnsAsync([]); - var exportManagerMock = new Mock>(); - exportManagerMock.Setup(e => e.Export(It.IsAny>(), It.IsAny())).ReturnsAsync([1, 2, 3]); - - await _controller.ExportExcelAll(model, exportManagerMock.Object); - - Assert.AreEqual("store-1", model.SearchStoreId); - } - - [TestMethod] - public async Task ExportExcelAll_NoDefaultStoreId_DoesNotOverrideModelSearchStoreId() - { - _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); - var model = new ProductListModel { SearchStoreId = "explicit" }; - _productViewModelServiceMock.Setup(s => s.PrepareProducts(model)).ReturnsAsync([]); - var exportManagerMock = new Mock>(); - exportManagerMock.Setup(e => e.Export(It.IsAny>(), It.IsAny())).ReturnsAsync([1, 2, 3]); - - await _controller.ExportExcelAll(model, exportManagerMock.Object); - - Assert.AreEqual("explicit", model.SearchStoreId); - } - - [TestMethod] - public async Task ExportExcelAll_ExportThrows_ReturnsRedirectToList() - { - var model = new ProductListModel(); - _productViewModelServiceMock.Setup(s => s.PrepareProducts(model)).ReturnsAsync([]); - var exportManagerMock = new Mock>(); - exportManagerMock.Setup(e => e.Export(It.IsAny>(), It.IsAny())) - .ThrowsAsync(new Exception("boom")); - - var result = await _controller.ExportExcelAll(model, exportManagerMock.Object); - - var redirect = result as RedirectToActionResult; - Assert.IsNotNull(redirect); - Assert.AreEqual("List", redirect.ActionName); - } - - [TestMethod] - public async Task ExportExcelSelected_NullSelectedIds_ExportsEmptyList() - { - var exportManagerMock = new Mock>(); - exportManagerMock.Setup(e => e.Export(It.Is>(p => !p.Any()), It.IsAny())) - .ReturnsAsync([9]); - - var result = await _controller.ExportExcelSelected(null, exportManagerMock.Object); - - var file = result as FileContentResult; - Assert.IsNotNull(file); - _productServiceMock.Verify(p => p.GetProductsByIds(It.IsAny(), - It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ExportExcelSelected_FiltersOutProductsScopeDenies() - { - // Mirrors Vendor's original explicit HasAccessToProduct re-check on selectedIds (caller-supplied, - // not derived from a scoped search, unlike ExportExcelAll) - applied unconditionally here so - // Admin (where the original had no check at all) gets the same protection. - var owned = new Product { Id = "owned" }; - var foreign = new Product { Id = "foreign" }; - _productServiceMock.Setup(p => p.GetProductsByIds(new[] { "owned", "foreign" }, - CatalogAccess.IncludingHidden())) - .ReturnsAsync([owned, foreign]); - _scopeMock.Setup(s => s.HasAccess(owned)).ReturnsAsync(true); - _scopeMock.Setup(s => s.HasAccess(foreign)).ReturnsAsync(false); - var exportManagerMock = new Mock>(); - exportManagerMock - .Setup(e => e.Export(It.Is>(p => p.Single() == owned), It.IsAny())) - .ReturnsAsync([7]); - - var result = await _controller.ExportExcelSelected("owned,foreign", exportManagerMock.Object); - - var file = result as FileContentResult; - Assert.IsNotNull(file); - exportManagerMock.Verify( - e => e.Export(It.Is>(p => p.Single() == owned), It.IsAny()), - Times.Once); - } - - [TestMethod] - public async Task ImportExcel_EmptyFile_DoesNotImport_RedirectsToListWithError() - { - var fileMock = new Mock(); - fileMock.Setup(f => f.Length).Returns(0); - var importManagerMock = new Mock>(); - - var result = await _controller.ImportExcel(fileMock.Object, importManagerMock.Object); - - var redirect = result as RedirectToActionResult; - Assert.IsNotNull(redirect); - Assert.AreEqual("List", redirect.ActionName); - importManagerMock.Verify(i => i.Import(It.IsAny(), It.IsAny()), Times.Never); - _translationServiceMock.Verify(t => t.GetResource("Admin.Common.UploadFile"), Times.Once); - } - - // --- ImportExcel: global scope only --- - - [TestMethod] - public async Task ImportExcel_StoreScope_DeniesAndNeverCallsImportManager() - { - _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); - var fileMock = new Mock(); - fileMock.Setup(f => f.Length).Returns(100); - var importManagerMock = new Mock>(); - - var result = await _controller.ImportExcel(fileMock.Object, importManagerMock.Object); - - var redirect = Assert.IsInstanceOfType(result); - Assert.AreEqual("List", redirect.ActionName); - importManagerMock.Verify(i => i.Import(It.IsAny(), It.IsAny()), Times.Never); - _translationServiceMock.Verify(t => t.GetResource("Admin.AccessDenied.Title"), Times.Once); - } - - [TestMethod] - public async Task ImportExcel_VendorScope_DeniesAndNeverCallsImportManager() - { - _scopeMock.Setup(s => s.DefaultVendorId).Returns("vendor-1"); - var fileMock = new Mock(); - fileMock.Setup(f => f.Length).Returns(100); - var importManagerMock = new Mock>(); - - var result = await _controller.ImportExcel(fileMock.Object, importManagerMock.Object); - - var redirect = Assert.IsInstanceOfType(result); - Assert.AreEqual("List", redirect.ActionName); - importManagerMock.Verify(i => i.Import(It.IsAny(), It.IsAny()), Times.Never); - _translationServiceMock.Verify(t => t.GetResource("Admin.AccessDenied.Title"), Times.Once); - } - - [TestMethod] - public async Task ImportExcel_NullFile_DoesNotImport_RedirectsToListWithError() - { - var importManagerMock = new Mock>(); - - var result = await _controller.ImportExcel(null, importManagerMock.Object); - - var redirect = result as RedirectToActionResult; - Assert.IsNotNull(redirect); - Assert.AreEqual("List", redirect.ActionName); - importManagerMock.Verify(i => i.Import(It.IsAny(), It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ImportExcel_ValidFile_ImportsAndRedirectsToListWithSuccess() - { - using var stream = new MemoryStream([1, 2, 3]); - var fileMock = new Mock(); - fileMock.Setup(f => f.Length).Returns(3); - fileMock.Setup(f => f.OpenReadStream()).Returns(stream); - var importManagerMock = new Mock>(); - importManagerMock.Setup(i => i.Import(stream)).Returns(Task.CompletedTask); - - var result = await _controller.ImportExcel(fileMock.Object, importManagerMock.Object); - - var redirect = result as RedirectToActionResult; - Assert.IsNotNull(redirect); - Assert.AreEqual("List", redirect.ActionName); - importManagerMock.Verify(i => i.Import(stream), Times.Once); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Imported"), Times.Once); - } - - [TestMethod] - public async Task ImportExcel_ImportThrows_RedirectsToListWithError() - { - using var stream = new MemoryStream([1]); - var fileMock = new Mock(); - fileMock.Setup(f => f.Length).Returns(1); - fileMock.Setup(f => f.OpenReadStream()).Returns(stream); - var importManagerMock = new Mock>(); - importManagerMock.Setup(i => i.Import(stream)).ThrowsAsync(new Exception("bad file")); - - var result = await _controller.ImportExcel(fileMock.Object, importManagerMock.Object); - - var redirect = result as RedirectToActionResult; - Assert.IsNotNull(redirect); - Assert.AreEqual("List", redirect.ActionName); - } - - // --- Bulk editing -------------------------------------------------------------------------------- - - [TestMethod] - public async Task BulkEdit_CallsPrepareBulkEditListModel() - { - // PrepareBulkEditListModel() now reads scope internally (Task 9). - _productViewModelServiceMock.Setup(s => s.PrepareBulkEditListModel()) - .ReturnsAsync(new BulkEditListModel()); - - var result = await _controller.BulkEdit(); - - Assert.IsInstanceOfType(result, typeof(ViewResult)); - _productViewModelServiceMock.Verify(s => s.PrepareBulkEditListModel(), Times.Once); - } - - [TestMethod] - public async Task BulkEditSelect_StoreScoped_SetsSearchStoreIdFromScope() - { - _scopeMock.Setup(s => s.DefaultStoreId).Returns("store1"); - var model = new BulkEditListModel(); - _productViewModelServiceMock - .Setup(s => s.PrepareBulkEditProductModel(It.IsAny(), 1, 10)) - .ReturnsAsync((Enumerable.Empty(), 0)); - - await _controller.BulkEditSelect(new Grand.Web.Common.DataSource.DataSourceRequest { Page = 1, PageSize = 10 }, model); - - Assert.AreEqual("store1", model.SearchStoreId); - } - - [TestMethod] - public async Task BulkEditSelect_NotStoreScoped_LeavesSearchStoreIdUntouched() - { - var model = new BulkEditListModel { SearchStoreId = "preset" }; - _productViewModelServiceMock - .Setup(s => s.PrepareBulkEditProductModel(It.IsAny(), 1, 10)) - .ReturnsAsync((Enumerable.Empty(), 0)); - - await _controller.BulkEditSelect(new Grand.Web.Common.DataSource.DataSourceRequest { Page = 1, PageSize = 10 }, model); - - Assert.AreEqual("preset", model.SearchStoreId); - } - - [TestMethod] - public async Task BulkEditCreate_NullProducts_DoesNotCallService() - { - var result = await _controller.BulkEditCreate(null); - - Assert.IsInstanceOfType(result, typeof(JsonResult)); - _productViewModelServiceMock.Verify( - s => s.InsertBulkEdit(It.IsAny>()), Times.Never); - } - - [TestMethod] - public async Task BulkEditCreate_PassesOnlyRowsWithAName() - { - await _controller.BulkEditCreate(new List { - new() { Id = "", Name = "New product" }, - new() { Id = "", Name = " " } - }); - - _productViewModelServiceMock.Verify( - s => s.InsertBulkEdit(It.Is>( - l => l.Count == 1 && l[0].Name == "New product")), Times.Once); - } - - [TestMethod] - public async Task BulkEditUpdate_NullProducts_DoesNotCallService() - { - var result = await _controller.BulkEditUpdate(null); - - Assert.IsInstanceOfType(result, typeof(JsonResult)); - _productViewModelServiceMock.Verify( - s => s.UpdateBulkEdit(It.IsAny>()), Times.Never); - } - - [TestMethod] - public async Task BulkEditUpdate_FiltersOutProductsScopeDenies() - { - // Regression guard: Admin's original had no ownership check at all on this bulk-mutate endpoint - // (any client-supplied id list was updated unconditionally). Store's original filtered via - // FilterValidProductsForStore/CanAccessProduct; Vendor's original filtered via - // HasAccessToProduct inside its own service. Routing through scope.HasAccess here reproduces - // that per-item gate uniformly. - var owned = new Product { Id = "owned" }; - var foreign = new Product { Id = "foreign" }; - _productServiceMock.Setup(p => p.GetProductsByIds(new[] { "owned", "foreign" }, - CatalogAccess.IncludingHidden())) - .ReturnsAsync(new List { owned, foreign }); - _scopeMock.Setup(s => s.HasAccess(owned)).ReturnsAsync(true); - _scopeMock.Setup(s => s.HasAccess(foreign)).ReturnsAsync(false); - - var ownedModel = new BulkEditProductModel { Id = "owned" }; - var foreignModel = new BulkEditProductModel { Id = "foreign" }; - - await _controller.BulkEditUpdate(new List { ownedModel, foreignModel }); - - _productViewModelServiceMock.Verify( - s => s.UpdateBulkEdit(It.Is>( - p => p.Count == 1 && p[0].Id == "owned")), Times.Once); - } - - [TestMethod] - public async Task BulkEditUpdate_AllProductsScopeDenies_DoesNotCallUpdateBulkEdit() - { - var foreign = new Product { Id = "foreign" }; - _productServiceMock.Setup(p => p.GetProductsByIds(new[] { "foreign" }, CatalogAccess.IncludingHidden())) - .ReturnsAsync(new List { foreign }); - _scopeMock.Setup(s => s.HasAccess(foreign)).ReturnsAsync(false); - - await _controller.BulkEditUpdate(new List { new() { Id = "foreign" } }); - - _productViewModelServiceMock.Verify( - s => s.UpdateBulkEdit(It.IsAny>()), Times.Never); - } - - [TestMethod] - public async Task BulkEditDelete_NullProducts_DoesNotCallService() - { - var result = await _controller.BulkEditDelete(null); - - Assert.IsInstanceOfType(result, typeof(JsonResult)); - _productViewModelServiceMock.Verify( - s => s.DeleteBulkEdit(It.IsAny>()), Times.Never); - } - - [TestMethod] - public async Task BulkEditDelete_FiltersOutProductsScopeDenies() - { - var owned = new Product { Id = "owned" }; - var foreign = new Product { Id = "foreign" }; - _productServiceMock.Setup(p => p.GetProductsByIds(new[] { "owned", "foreign" }, - CatalogAccess.IncludingHidden())) - .ReturnsAsync(new List { owned, foreign }); - _scopeMock.Setup(s => s.HasAccess(owned)).ReturnsAsync(true); - _scopeMock.Setup(s => s.HasAccess(foreign)).ReturnsAsync(false); - - await _controller.BulkEditDelete(new List { - new() { Id = "owned" }, new() { Id = "foreign" } - }); - - _productViewModelServiceMock.Verify( - s => s.DeleteBulkEdit(It.Is>( - p => p.Count == 1 && p[0].Id == "owned")), Times.Once); - } - - [TestMethod] - public async Task BulkEditDelete_AllProductsScopeDenies_DoesNotCallDeleteBulkEdit() - { - var foreign = new Product { Id = "foreign" }; - _productServiceMock.Setup(p => p.GetProductsByIds(new[] { "foreign" }, CatalogAccess.IncludingHidden())) - .ReturnsAsync(new List { foreign }); - _scopeMock.Setup(s => s.HasAccess(foreign)).ReturnsAsync(false); - - await _controller.BulkEditDelete(new List { new() { Id = "foreign" } }); - - _productViewModelServiceMock.Verify( - s => s.DeleteBulkEdit(It.IsAny>()), Times.Never); - } - - [TestMethod] - public async Task BulkEditUpdate_ProductsWithEmptyIds_AreIgnoredWithoutServiceCall() - { - var result = await _controller.BulkEditUpdate(new List { new() { Id = "" } }); - - Assert.IsInstanceOfType(result, typeof(JsonResult)); - _productServiceMock.Verify(p => p.GetProductsByIds(It.IsAny(), - It.IsAny()), Times.Never); - _productViewModelServiceMock.Verify( - s => s.UpdateBulkEdit(It.IsAny>()), Times.Never); - } - - // --- ProductPriceList ------------------------------------------------------------------------- - // HasAccess (strict), not CanView: mirrors Store's CanAccessProduct check on this action. Applying it - // uniformly also closes real gaps on the mutate actions below: Store's original checked access only on - // List/Insert (Update/Delete had no check at all), and Vendor's original checked access only on List - // (Insert/Update/Delete had no check at all). - - [TestMethod] - public async Task ProductPriceList_ScopeDeniesAccess_ReturnsErrorJson() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); - - var result = await _controller.ProductPriceList( - new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - } - - [TestMethod] - public async Task ProductPriceList_ScopeDeniesAccess_UsesScopeResourceKeyPrefix() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); - _scopeMock.Setup(s => s.ResourceKeyPrefix).Returns("Vendor"); - - var result = await _controller.ProductPriceList( - new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Vendor.Catalog.Products.Permissions"), Times.Once); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Never); - } - - [TestMethod] - public async Task ProductPriceList_ScopeGrantsAccess_ReturnsGrid() - { - var product = new Product { Id = "p1" }; - product.ProductPrices.Add(new ProductPrice { Id = "pp1", CurrencyCode = "EUR", Price = 9.99 }); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); - - var result = await _controller.ProductPriceList( - new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); - - var json = result as JsonResult; - Assert.IsNotNull(json); - var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; - Assert.IsNotNull(gridModel); - Assert.AreEqual(1, gridModel.Total); - } - - // --- ProductPriceInsert ----------------------------------------------------------------------- - - [TestMethod] - public async Task ProductPriceInsert_ScopeDeniesAccess_ReturnsErrorJson_DoesNotInsert() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.ProductPriceModel { ProductId = "p1", CurrencyCode = "EUR", Price = 9.99 }; - - var result = await _controller.ProductPriceInsert(model); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productServiceMock.Verify(s => s.InsertProductPrice(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ProductPriceInsert_ScopeGrantsAccess_ValidModel_Inserts() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.ProductPriceModel { ProductId = "p1", CurrencyCode = "EUR", Price = 9.99 }; - - var result = await _controller.ProductPriceInsert(model); - - Assert.IsInstanceOfType(result); - _productServiceMock.Verify(s => s.InsertProductPrice(It.Is( - pp => pp.ProductId == "p1" && pp.CurrencyCode == "EUR" && pp.Price == 9.99)), Times.Once); - } - - // --- ProductPriceUpdate ----------------------------------------------------------------------- - - [TestMethod] - public async Task ProductPriceUpdate_ScopeDeniesAccess_ReturnsErrorJson_DoesNotUpdate() - { - var product = new Product { Id = "p1" }; - product.ProductPrices.Add(new ProductPrice { Id = "pp1", CurrencyCode = "EUR", Price = 9.99 }); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.ProductPriceModel { Id = "pp1", ProductId = "p1", CurrencyCode = "USD", Price = 19.99 }; - - var result = await _controller.ProductPriceUpdate(model); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productServiceMock.Verify(s => s.UpdateProductPrice(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ProductPriceUpdate_ScopeGrantsAccess_ValidModel_Updates() - { - var product = new Product { Id = "p1" }; - product.ProductPrices.Add(new ProductPrice { Id = "pp1", CurrencyCode = "EUR", Price = 9.99 }); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.ProductPriceModel { Id = "pp1", ProductId = "p1", CurrencyCode = "USD", Price = 19.99 }; - - var result = await _controller.ProductPriceUpdate(model); - - Assert.IsInstanceOfType(result); - _productServiceMock.Verify(s => s.UpdateProductPrice(It.Is( - pp => pp.Id == "pp1" && pp.CurrencyCode == "USD" && pp.Price == 19.99 && pp.ProductId == "p1")), Times.Once); - } - - // --- ProductPriceDelete ----------------------------------------------------------------------- - - [TestMethod] - public async Task ProductPriceDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() - { - var product = new Product { Id = "p1" }; - product.ProductPrices.Add(new ProductPrice { Id = "pp1", CurrencyCode = "EUR", Price = 9.99 }); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.ProductPriceModel { Id = "pp1", ProductId = "p1" }; - - var result = await _controller.ProductPriceDelete(model); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productServiceMock.Verify(s => s.DeleteProductPrice(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ProductPriceDelete_ScopeGrantsAccess_ValidModel_Deletes() - { - var product = new Product { Id = "p1" }; - product.ProductPrices.Add(new ProductPrice { Id = "pp1", CurrencyCode = "EUR", Price = 9.99 }); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.ProductPriceModel { Id = "pp1", ProductId = "p1" }; - - var result = await _controller.ProductPriceDelete(model); - - Assert.IsInstanceOfType(result); - _productServiceMock.Verify(s => s.DeleteProductPrice(It.Is( - pp => pp.Id == "pp1" && pp.ProductId == "p1")), Times.Once); - } - - // --- TierPriceList ---------------------------------------------------------------------------- - // HasAccess applied uniformly on every action in this region. Vendor's original checked ownership - // only on List and TierPriceEditPopup(GET); TierPriceCreatePopup(POST), TierPriceEditPopup(POST) and - // TierPriceDelete had NO ownership check at all, letting a vendor create/update/delete a tier price on - // any product by id. Store's original never checked TierPriceEditPopup(GET) either. - - [TestMethod] - public async Task TierPriceList_ScopeDeniesAccess_ReturnsErrorJson() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); - - var result = await _controller.TierPriceList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify( - s => s.PrepareTierPriceModel(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task TierPriceList_ScopeDeniesAccess_UsesScopeResourceKeyPrefix() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); - _scopeMock.Setup(s => s.ResourceKeyPrefix).Returns("Vendor"); - - var result = await _controller.TierPriceList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Vendor.Catalog.Products.Permissions"), Times.Once); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Never); - } - - [TestMethod] - public async Task TierPriceList_ScopeGrantsAccess_ReturnsGrid() - { - // PrepareTierPriceModel(product) now reads scope internally (Task 9). - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); - var tierPrices = new List { new() { Id = "tp1" } }; - _productViewModelServiceMock.Setup(s => s.PrepareTierPriceModel(product)).ReturnsAsync(tierPrices); - - var result = await _controller.TierPriceList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); - - var json = result as JsonResult; - Assert.IsNotNull(json); - var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; - Assert.IsNotNull(gridModel); - Assert.AreEqual(1, gridModel.Total); - } - - // --- TierPriceCreatePopup (GET) ---------------------------------------------------------------- - - [TestMethod] - public async Task TierPriceCreatePopup_Get_PreparesModel() - { - // PrepareTierPriceModel(model) now reads scope internally (Task 9). - var result = await _controller.TierPriceCreatePopup("p1"); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify( - s => s.PrepareTierPriceModel(It.Is(m => m.ProductId == "p1")), - Times.Once); - } - - // --- TierPriceCreatePopup (POST) --------------------------------------------------------------- - - [TestMethod] - public async Task TierPriceCreatePopup_Post_ScopeDeniesAccess_DoesNotInsert() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.TierPriceModel { ProductId = "p1" }; - - var result = await _controller.TierPriceCreatePopup(model); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productServiceMock.Verify(s => s.InsertTierPrice(It.IsAny(), It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task TierPriceCreatePopup_Post_ScopeGrantsAccess_ValidModel_Inserts() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.TierPriceModel { ProductId = "p1" }; - - var result = await _controller.TierPriceCreatePopup(model); - - Assert.IsInstanceOfType(result); - _productServiceMock.Verify( - s => s.InsertTierPrice(It.IsAny(), "p1"), Times.Once); - } - - [TestMethod] - public async Task TierPriceCreatePopup_Post_MissingProduct_Throws() - { - _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); - var model = new ProductModel.TierPriceModel { ProductId = "missing" }; - - await Assert.ThrowsExactlyAsync(() => _controller.TierPriceCreatePopup(model)); - } - - // --- TierPriceEditPopup (GET) ------------------------------------------------------------------ - // HasAccess added here: Store's original had no ownership check on this GET action at all. - - [TestMethod] - public async Task TierPriceEditPopup_Get_ScopeDeniesAccess_ReturnsNotYourProductContent() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - - var result = await _controller.TierPriceEditPopup("tp1", "p1"); - - var content = result as ContentResult; - Assert.IsNotNull(content); - Assert.AreEqual("This is not your product", content.Content); - _productViewModelServiceMock.Verify( - s => s.PrepareTierPriceModel(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task TierPriceEditPopup_Get_ScopeGrantsAccess_TierPriceMissing_ReturnsEmptyContent() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - - var result = await _controller.TierPriceEditPopup("missing-tp", "p1"); - - var content = result as ContentResult; - Assert.IsNotNull(content); - Assert.AreEqual("Empty tier price", content.Content); - } - - [TestMethod] - public async Task TierPriceEditPopup_Get_ScopeGrantsAccess_PreparesModel() - { - // PrepareTierPriceModel(model) now reads scope internally (Task 9). - var product = new Product { Id = "p1" }; - product.TierPrices.Add(new TierPrice { Id = "tp1" }); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - - var result = await _controller.TierPriceEditPopup("tp1", "p1"); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify( - s => s.PrepareTierPriceModel(It.Is(m => m.ProductId == "p1")), - Times.Once); - } - - [TestMethod] - public async Task TierPriceEditPopup_Get_MissingProduct_Throws() - { - _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); - - await Assert.ThrowsExactlyAsync(() => _controller.TierPriceEditPopup("tp1", "missing")); - } - - // --- TierPriceEditPopup (POST) ----------------------------------------------------------------- - - [TestMethod] - public async Task TierPriceEditPopup_Post_ScopeDeniesAccess_DoesNotUpdate() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.TierPriceModel { Id = "tp1", ProductId = "p1" }; - - var result = await _controller.TierPriceEditPopup("p1", model); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productServiceMock.Verify(s => s.UpdateTierPrice(It.IsAny(), It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task TierPriceEditPopup_Post_ScopeGrantsAccess_ValidModel_Updates() - { - var product = new Product { Id = "p1" }; - product.TierPrices.Add(new TierPrice { Id = "tp1" }); - _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.TierPriceModel { Id = "tp1", ProductId = "p1" }; - - var result = await _controller.TierPriceEditPopup("p1", model); - - Assert.IsInstanceOfType(result); - _productServiceMock.Verify(s => s.UpdateTierPrice(It.IsAny(), "p1"), Times.Once); - } - - // --- TierPriceDelete ---------------------------------------------------------------------------- - - [TestMethod] - public async Task TierPriceDelete_ScopeDeniesAccess_DoesNotDelete() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.TierPriceDeleteModel("tp1", "p1"); - - var result = await _controller.TierPriceDelete(model); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productServiceMock.Verify(s => s.DeleteTierPrice(It.IsAny(), It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task TierPriceDelete_ScopeGrantsAccess_ValidModel_Deletes() - { - var product = new Product { Id = "p1" }; - product.TierPrices.Add(new TierPrice { Id = "tp1" }); - _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.TierPriceDeleteModel("tp1", "p1"); - - var result = await _controller.TierPriceDelete(model); - - Assert.IsInstanceOfType(result); - _productServiceMock.Verify(s => s.DeleteTierPrice(It.IsAny(), "p1"), Times.Once); - } - - [TestMethod] - public async Task TierPriceDelete_ScopeGrantsAccess_TierPriceMissing_Throws() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.TierPriceDeleteModel("missing-tp", "p1"); - - await Assert.ThrowsExactlyAsync(() => _controller.TierPriceDelete(model)); - } - - // --- ProductAttributeMappingList ------------------------------------------------------------- - // HasAccess applied uniformly on every action in this region. Store's original checked ownership - // (CanAccessProduct) on List/PopupGET/PopupPOST/Delete/ValidationRulesPopupGET but not on - // ValidationRulesPopup(POST). Vendor's original checked (CheckAccessToProduct/HasAccessToProduct) on - // List/PopupGET/Delete/ValidationRulesPopupGET, but NOT on ProductAttributeMappingPopup(POST) or - // ValidationRulesPopup(POST) - letting a vendor edit an attribute mapping (or its validation rules) on - // any product, not just their own, by posting a known productId. - - [TestMethod] - public async Task ProductAttributeMappingList_ScopeDeniesAccess_ReturnsErrorJson() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); - - var result = await _controller.ProductAttributeMappingList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify( - s => s.PrepareProductAttributeMappingModels(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ProductAttributeMappingList_ScopeDeniesAccess_UsesScopeResourceKeyPrefix() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); - _scopeMock.Setup(s => s.ResourceKeyPrefix).Returns("Vendor"); - - var result = await _controller.ProductAttributeMappingList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Vendor.Catalog.Products.Permissions"), Times.Once); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Never); - } - - [TestMethod] - public async Task ProductAttributeMappingList_ScopeGrantsAccess_ReturnsGrid() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); - var attributes = new List { new() { Id = "pam1" } }; - _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeMappingModels(product)) - .ReturnsAsync(attributes); - - var result = await _controller.ProductAttributeMappingList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); - - var json = result as JsonResult; - Assert.IsNotNull(json); - var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; - Assert.IsNotNull(gridModel); - Assert.AreEqual(1, gridModel.Total); - } - - // --- ProductAttributeMappingPopup (GET) ------------------------------------------------------ - - [TestMethod] - public async Task ProductAttributeMappingPopup_Get_ScopeDeniesAccess_ReturnsPermissionsContent() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - - var result = await _controller.ProductAttributeMappingPopup("p1", null); - - var content = result as ContentResult; - Assert.IsNotNull(content); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify( - s => s.PrepareProductAttributeMappingModel(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ProductAttributeMappingPopup_Get_ScopeGrantsAccess_NoMappingId_PreparesNewModel() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeMappingModel(product)) - .ReturnsAsync(new ProductModel.ProductAttributeMappingModel()); - - var result = await _controller.ProductAttributeMappingPopup("p1", null); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.PrepareProductAttributeMappingModel(product), Times.Once); - } - - [TestMethod] - public async Task ProductAttributeMappingPopup_Get_ScopeGrantsAccess_MappingId_PreparesEditModel() - { - var mapping = new ProductAttributeMapping { Id = "pam1" }; - var product = new Product { Id = "p1" }; - product.ProductAttributeMappings.Add(mapping); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeMappingModel(product, mapping)) - .ReturnsAsync(new ProductModel.ProductAttributeMappingModel()); - - var result = await _controller.ProductAttributeMappingPopup("p1", "pam1"); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.PrepareProductAttributeMappingModel(product, mapping), Times.Once); - } - - // --- ProductAttributeMappingPopup (POST) ----------------------------------------------------- - // HasAccess added here: Vendor's original had no ownership check on this POST at all. - - [TestMethod] - public async Task ProductAttributeMappingPopup_Post_ScopeDeniesAccess_DoesNotInsertOrUpdate() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.ProductAttributeMappingModel { ProductId = "p1" }; - - var result = await _controller.ProductAttributeMappingPopup(model); - - var content = result as ContentResult; - Assert.IsNotNull(content); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify( - s => s.InsertProductAttributeMappingModel(It.IsAny()), - Times.Never); - _productViewModelServiceMock.Verify( - s => s.UpdateProductAttributeMappingModel(It.IsAny()), - Times.Never); - } - - [TestMethod] - public async Task ProductAttributeMappingPopup_Post_ScopeGrantsAccess_NoId_Inserts() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.ProductAttributeMappingModel { ProductId = "p1" }; - - var result = await _controller.ProductAttributeMappingPopup(model); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.InsertProductAttributeMappingModel(model), Times.Once); - _productViewModelServiceMock.Verify( - s => s.UpdateProductAttributeMappingModel(It.IsAny()), - Times.Never); - } - - [TestMethod] - public async Task ProductAttributeMappingPopup_Post_ScopeGrantsAccess_WithId_Updates() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.ProductAttributeMappingModel { Id = "pam1", ProductId = "p1" }; - - var result = await _controller.ProductAttributeMappingPopup(model); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.UpdateProductAttributeMappingModel(model), Times.Once); - } - - [TestMethod] - public async Task ProductAttributeMappingPopup_Post_MissingProduct_Throws() - { - _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); - var model = new ProductModel.ProductAttributeMappingModel { ProductId = "missing" }; - - await Assert.ThrowsExactlyAsync(() => _controller.ProductAttributeMappingPopup(model)); - } - - [TestMethod] - public async Task ProductAttributeMappingPopup_Post_InvalidModelState_ReturnsView_DoesNotCheckAccess() - { - _controller.ModelState.AddModelError("x", "err"); - var model = new ProductModel.ProductAttributeMappingModel { ProductId = "p1" }; - _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeMappingModel(model)).ReturnsAsync(model); - - var result = await _controller.ProductAttributeMappingPopup(model); - - Assert.IsInstanceOfType(result); - _productServiceMock.Verify(p => p.GetProductById(It.IsAny(), It.IsAny()), Times.Never); - _scopeMock.Verify(s => s.HasAccess(It.IsAny()), Times.Never); - } - - // --- ProductAttributeMappingDelete ----------------------------------------------------------- - - [TestMethod] - public async Task ProductAttributeMappingDelete_MissingProduct_Throws() - { - _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); - var attrServiceMock = new Mock(); - - await Assert.ThrowsExactlyAsync( - () => _controller.ProductAttributeMappingDelete("pam1", "missing", attrServiceMock.Object)); - } - - [TestMethod] - public async Task ProductAttributeMappingDelete_MappingNotFound_Throws() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - var attrServiceMock = new Mock(); - - await Assert.ThrowsExactlyAsync( - () => _controller.ProductAttributeMappingDelete("missing-pam", "p1", attrServiceMock.Object)); - } - - [TestMethod] - public async Task ProductAttributeMappingDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() - { - var mapping = new ProductAttributeMapping { Id = "pam1" }; - var product = new Product { Id = "p1" }; - product.ProductAttributeMappings.Add(mapping); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var attrServiceMock = new Mock(); - - var result = await _controller.ProductAttributeMappingDelete("pam1", "p1", attrServiceMock.Object); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - attrServiceMock.Verify( - s => s.DeleteProductAttributeMapping(It.IsAny(), It.IsAny()), - Times.Never); - } - - [TestMethod] - public async Task ProductAttributeMappingDelete_ScopeGrantsAccess_Deletes() - { - var mapping = new ProductAttributeMapping { Id = "pam1" }; - var product = new Product { Id = "p1" }; - product.ProductAttributeMappings.Add(mapping); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var attrServiceMock = new Mock(); - - var result = await _controller.ProductAttributeMappingDelete("pam1", "p1", attrServiceMock.Object); - - Assert.IsInstanceOfType(result); - attrServiceMock.Verify(s => s.DeleteProductAttributeMapping(mapping, "p1"), Times.Once); - } - - // --- ProductAttributeValidationRulesPopup (GET) ----------------------------------------------- - - [TestMethod] - public async Task ProductAttributeValidationRulesPopup_Get_ScopeDeniesAccess_ReturnsPermissionsContent() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - - var result = await _controller.ProductAttributeValidationRulesPopup("pam1", "p1"); - - var content = result as ContentResult; - Assert.IsNotNull(content); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - } - - [TestMethod] - public async Task ProductAttributeValidationRulesPopup_Get_ScopeGrantsAccess_MappingMissing_ReturnsContent() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - - var result = await _controller.ProductAttributeValidationRulesPopup("missing-pam", "p1"); - - var content = result as ContentResult; - Assert.IsNotNull(content); - Assert.AreEqual("No attribute value found with the specified id", content.Content); - } - - [TestMethod] - public async Task ProductAttributeValidationRulesPopup_Get_ScopeGrantsAccess_PreparesModel() - { - var mapping = new ProductAttributeMapping { Id = "pam1" }; - var product = new Product { Id = "p1" }; - product.ProductAttributeMappings.Add(mapping); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeMappingModel(mapping)) - .ReturnsAsync(new ProductModel.ProductAttributeMappingModel()); - - var result = await _controller.ProductAttributeValidationRulesPopup("pam1", "p1"); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.PrepareProductAttributeMappingModel(mapping), Times.Once); - } - - // --- ProductAttributeValidationRulesPopup (POST) ---------------------------------------------- - // HasAccess added here: none of the three original hosts checked ownership on this POST at all - a - // store/vendor user could update an attribute mapping's validation rules on any product by posting a - // known productId/model.Id. - - [TestMethod] - public async Task ProductAttributeValidationRulesPopup_Post_MissingProduct_Throws() - { - _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); - var model = new ProductModel.ProductAttributeMappingModel { ProductId = "missing" }; - - await Assert.ThrowsExactlyAsync( - () => _controller.ProductAttributeValidationRulesPopup(model)); - } - - [TestMethod] - public async Task ProductAttributeValidationRulesPopup_Post_MappingNotFound_Throws() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - var model = new ProductModel.ProductAttributeMappingModel { Id = "missing-pam", ProductId = "p1" }; - - await Assert.ThrowsExactlyAsync( - () => _controller.ProductAttributeValidationRulesPopup(model)); - } - - [TestMethod] - public async Task ProductAttributeValidationRulesPopup_Post_ScopeDeniesAccess_ReturnsPermissionsContent_DoesNotUpdate() - { - var mapping = new ProductAttributeMapping { Id = "pam1" }; - var product = new Product { Id = "p1" }; - product.ProductAttributeMappings.Add(mapping); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.ProductAttributeMappingModel { Id = "pam1", ProductId = "p1" }; - - var result = await _controller.ProductAttributeValidationRulesPopup(model); - - var content = result as ContentResult; - Assert.IsNotNull(content); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify( - s => s.UpdateProductAttributeValidationRulesModel(It.IsAny(), - It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ProductAttributeValidationRulesPopup_Post_ScopeGrantsAccess_ValidModel_Updates() - { - var mapping = new ProductAttributeMapping { Id = "pam1" }; - var product = new Product { Id = "p1" }; - product.ProductAttributeMappings.Add(mapping); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.ProductAttributeMappingModel { Id = "pam1", ProductId = "p1" }; - - var result = await _controller.ProductAttributeValidationRulesPopup(model); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify( - s => s.UpdateProductAttributeValidationRulesModel(mapping, model), Times.Once); - } - - [TestMethod] - public async Task ProductAttributeValidationRulesPopup_Post_ScopeGrantsAccess_InvalidModelState_ReturnsView() - { - var mapping = new ProductAttributeMapping { Id = "pam1" }; - var product = new Product { Id = "p1" }; - product.ProductAttributeMappings.Add(mapping); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - _controller.ModelState.AddModelError("x", "err"); - var model = new ProductModel.ProductAttributeMappingModel { Id = "pam1", ProductId = "p1" }; - _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeMappingModel(mapping)).ReturnsAsync(model); - - var result = await _controller.ProductAttributeValidationRulesPopup(model); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify( - s => s.UpdateProductAttributeValidationRulesModel(It.IsAny(), - It.IsAny()), Times.Never); - } - - // --- ProductAttributeConditionPopup (GET) ---------------------------------------------------- - // HasAccess added uniformly. Admin's original had no ownership check on either action of this - // region at all. - - [TestMethod] - public async Task ProductAttributeConditionPopup_Get_ScopeDeniesAccess_ReturnsPermissionsContent() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - - var result = await _controller.ProductAttributeConditionPopup("p1", "pam1"); - - var content = result as ContentResult; - Assert.IsNotNull(content); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify( - s => s.PrepareProductAttributeConditionModel(It.IsAny(), It.IsAny()), - Times.Never); - } - - [TestMethod] - public async Task ProductAttributeConditionPopup_Get_ScopeDeniesAccess_UsesScopeResourceKeyPrefix() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - _scopeMock.Setup(s => s.ResourceKeyPrefix).Returns("Vendor"); - - var result = await _controller.ProductAttributeConditionPopup("p1", "pam1"); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Vendor.Catalog.Products.Permissions"), Times.Once); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Never); - } - - [TestMethod] - public async Task ProductAttributeConditionPopup_Get_ScopeGrantsAccess_MappingMissing_ReturnsContent() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - - var result = await _controller.ProductAttributeConditionPopup("p1", "missing-pam"); - - var content = result as ContentResult; - Assert.IsNotNull(content); - Assert.AreEqual("No attribute value found with the specified id", content.Content); - _productViewModelServiceMock.Verify( - s => s.PrepareProductAttributeConditionModel(It.IsAny(), It.IsAny()), - Times.Never); - } - - [TestMethod] - public async Task ProductAttributeConditionPopup_Get_ScopeGrantsAccess_PreparesModel() - { - var mapping = new ProductAttributeMapping { Id = "pam1" }; - var product = new Product { Id = "p1" }; - product.ProductAttributeMappings.Add(mapping); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeConditionModel(product, mapping)) - .ReturnsAsync(new ProductAttributeConditionModel()); - - var result = await _controller.ProductAttributeConditionPopup("p1", "pam1"); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.PrepareProductAttributeConditionModel(product, mapping), - Times.Once); - } - - // --- ProductAttributeConditionPopup (POST) --------------------------------------------------- - // HasAccess added here: Vendor's original never checked ownership on this POST at all (only its - // GET sibling did, via CheckAccessToProduct), letting a vendor update an attribute condition on - // any product by posting a known productId/productAttributeMappingId. Admin's original had no - // check on either action. - - [TestMethod] - public async Task ProductAttributeConditionPopup_Post_MissingProduct_Throws() - { - _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); - var model = new ProductAttributeConditionModel { ProductId = "missing" }; - - await Assert.ThrowsExactlyAsync(() => _controller.ProductAttributeConditionPopup(model)); - } - - [TestMethod] - public async Task ProductAttributeConditionPopup_Post_MappingNotFound_ReturnsContent_DoesNotCheckAccess() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - var model = new ProductAttributeConditionModel { ProductId = "p1", ProductAttributeMappingId = "missing-pam" }; - - var result = await _controller.ProductAttributeConditionPopup(model); - - var content = result as ContentResult; - Assert.IsNotNull(content); - Assert.AreEqual("No attribute value found with the specified id", content.Content); - _scopeMock.Verify(s => s.HasAccess(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ProductAttributeConditionPopup_Post_ScopeDeniesAccess_ReturnsPermissionsContent_DoesNotUpdate() - { - var mapping = new ProductAttributeMapping { Id = "pam1" }; - var product = new Product { Id = "p1" }; - product.ProductAttributeMappings.Add(mapping); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductAttributeConditionModel { ProductId = "p1", ProductAttributeMappingId = "pam1" }; - - var result = await _controller.ProductAttributeConditionPopup(model); - - var content = result as ContentResult; - Assert.IsNotNull(content); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify( - s => s.UpdateProductAttributeConditionModel(It.IsAny(), It.IsAny(), - It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ProductAttributeConditionPopup_Post_ScopeGrantsAccess_Updates() - { - var mapping = new ProductAttributeMapping { Id = "pam1" }; - var product = new Product { Id = "p1" }; - product.ProductAttributeMappings.Add(mapping); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductAttributeConditionModel { ProductId = "p1", ProductAttributeMappingId = "pam1" }; - - var result = await _controller.ProductAttributeConditionPopup(model); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.UpdateProductAttributeConditionModel(product, mapping, model), - Times.Once); - } - - // --- EditAttributeValues (GET) ------------------------------------------------------------------ - // HasAccess applied uniformly. Admin's original had no ownership check on this action at all. - - [TestMethod] - public async Task EditAttributeValues_MissingProduct_Throws() - { - _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); - var attrServiceMock = new Mock(); - - await Assert.ThrowsExactlyAsync( - () => _controller.EditAttributeValues("pam1", "missing", attrServiceMock.Object)); - } - - [TestMethod] - public async Task EditAttributeValues_ScopeDeniesAccess_ReturnsPermissionsContent() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var attrServiceMock = new Mock(); - - var result = await _controller.EditAttributeValues("pam1", "p1", attrServiceMock.Object); - - var content = result as ContentResult; - Assert.IsNotNull(content); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - } - - [TestMethod] - public async Task EditAttributeValues_ScopeGrantsAccess_MappingMissing_Throws() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var attrServiceMock = new Mock(); - - await Assert.ThrowsExactlyAsync( - () => _controller.EditAttributeValues("missing-pam", "p1", attrServiceMock.Object)); - } - - [TestMethod] - public async Task EditAttributeValues_ScopeGrantsAccess_PreparesListModel() - { - var mapping = new ProductAttributeMapping { Id = "pam1", ProductAttributeId = "pa1" }; - var product = new Product { Id = "p1", Name = "Product 1" }; - product.ProductAttributeMappings.Add(mapping); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var attrServiceMock = new Mock(); - attrServiceMock.Setup(s => s.GetProductAttributeById("pa1")) - .ReturnsAsync(new ProductAttribute { Id = "pa1", Name = "Color" }); - - var result = await _controller.EditAttributeValues("pam1", "p1", attrServiceMock.Object); - - var view = result as ViewResult; - Assert.IsNotNull(view); - var model = view.Model as ProductModel.ProductAttributeValueListModel; - Assert.IsNotNull(model); - Assert.AreEqual("p1", model.ProductId); - Assert.AreEqual("Product 1", model.ProductName); - Assert.AreEqual("Color", model.ProductAttributeName); - Assert.AreEqual("pam1", model.ProductAttributeMappingId); - } - - // --- ProductAttributeValueList (POST) ----------------------------------------------------------- - - [TestMethod] - public async Task ProductAttributeValueList_ScopeDeniesAccess_ReturnsErrorJson() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - - var result = await _controller.ProductAttributeValueList("pam1", "p1", new Grand.Web.Common.DataSource.DataSourceRequest()); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify( - s => s.PrepareProductAttributeValueModels(It.IsAny(), It.IsAny()), - Times.Never); - } - - [TestMethod] - public async Task ProductAttributeValueList_ScopeGrantsAccess_MappingMissing_Throws() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - - await Assert.ThrowsExactlyAsync( - () => _controller.ProductAttributeValueList("missing-pam", "p1", new Grand.Web.Common.DataSource.DataSourceRequest())); - } - - [TestMethod] - public async Task ProductAttributeValueList_ScopeGrantsAccess_ReturnsGrid() - { - var mapping = new ProductAttributeMapping { Id = "pam1" }; - var product = new Product { Id = "p1" }; - product.ProductAttributeMappings.Add(mapping); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var values = new List { new() { Id = "pav1" } }; - _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeValueModels(product, mapping)) - .ReturnsAsync(values); - - var result = await _controller.ProductAttributeValueList("pam1", "p1", new Grand.Web.Common.DataSource.DataSourceRequest()); - - var json = result as JsonResult; - Assert.IsNotNull(json); - var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; - Assert.IsNotNull(gridModel); - Assert.AreEqual(1, gridModel.Total); - } - - // --- ProductAttributeValueCreatePopup (GET) ----------------------------------------------------- - - [TestMethod] - public async Task ProductAttributeValueCreatePopup_Get_ScopeDeniesAccess_ReturnsPermissionsContent() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - - var result = await _controller.ProductAttributeValueCreatePopup("pam1", "p1"); - - var content = result as ContentResult; - Assert.IsNotNull(content); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify( - s => s.PrepareProductAttributeValueModel(It.IsAny(), It.IsAny()), - Times.Never); - } - - [TestMethod] - public async Task ProductAttributeValueCreatePopup_Get_ScopeGrantsAccess_MappingMissing_Throws() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - - await Assert.ThrowsExactlyAsync( - () => _controller.ProductAttributeValueCreatePopup("missing-pam", "p1")); - } - - [TestMethod] - public async Task ProductAttributeValueCreatePopup_Get_ScopeGrantsAccess_PreparesModel() - { - var mapping = new ProductAttributeMapping { Id = "pam1" }; - var product = new Product { Id = "p1" }; - product.ProductAttributeMappings.Add(mapping); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeValueModel(product, mapping)) - .ReturnsAsync(new ProductModel.ProductAttributeValueModel()); - - var result = await _controller.ProductAttributeValueCreatePopup("pam1", "p1"); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.PrepareProductAttributeValueModel(product, mapping), Times.Once); - } - - // --- ProductAttributeValueCreatePopup (POST) ---------------------------------------------------- - // HasAccess added explicitly. Admin has no ownership concept at all (GlobalAdminDataScope.HasAccess is - // always true), so Admin was never at risk here. The real risk is to VENDOR: Vendor's original relied - // solely on ProductAttributeValueModel : IProductValidVendor triggering ValidationFilter's - // ProductValidVendor check + Vendor's own `if (ModelState.IsValid)` gate - no explicit action-level - // check. Moving to the shared AdminShared model (no IProductValidVendor) would have silently dropped - // that guard for Vendor; scope.HasAccess replaces it explicitly and uniformly. - - [TestMethod] - public async Task ProductAttributeValueCreatePopup_Post_MissingProduct_Throws() - { - _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); - var model = new ProductModel.ProductAttributeValueModel { ProductId = "missing" }; - - await Assert.ThrowsExactlyAsync( - () => _controller.ProductAttributeValueCreatePopup(model)); - } - - [TestMethod] - public async Task ProductAttributeValueCreatePopup_Post_ScopeDeniesAccess_ReturnsPermissionsContent_DoesNotInsert() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.ProductAttributeValueModel { ProductId = "p1" }; - - var result = await _controller.ProductAttributeValueCreatePopup(model); - - var content = result as ContentResult; - Assert.IsNotNull(content); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify( - s => s.InsertProductAttributeValueModel(It.IsAny()), - Times.Never); - } - - [TestMethod] - public async Task ProductAttributeValueCreatePopup_Post_ScopeDeniesAccess_UsesVendorResourceKeyPrefix() - { - // The host/action combination this row's fix actually protects: Vendor losing its - // IProductValidVendor-driven guard in the merge. See the comment above the HasAccess check in - // BaseProductController.ProductAttributeValueCreatePopup(POST). - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - _scopeMock.Setup(s => s.ResourceKeyPrefix).Returns("Vendor"); - var model = new ProductModel.ProductAttributeValueModel { ProductId = "p1" }; - - var result = await _controller.ProductAttributeValueCreatePopup(model); - - var content = result as ContentResult; - Assert.IsNotNull(content); - _translationServiceMock.Verify(t => t.GetResource("Vendor.Catalog.Products.Permissions"), Times.Once); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Never); - _productViewModelServiceMock.Verify( - s => s.InsertProductAttributeValueModel(It.IsAny()), - Times.Never); - } - - [TestMethod] - public async Task ProductAttributeValueCreatePopup_Post_ScopeGrantsAccess_MappingMissing_RedirectsToList() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.ProductAttributeValueModel { ProductId = "p1", ProductAttributeMappingId = "missing-pam" }; - - var result = await _controller.ProductAttributeValueCreatePopup(model); - - var redirect = result as RedirectToActionResult; - Assert.IsNotNull(redirect); - Assert.AreEqual("List", redirect.ActionName); - Assert.AreEqual("Product", redirect.ControllerName); - } - - [TestMethod] - public async Task ProductAttributeValueCreatePopup_Post_ScopeGrantsAccess_ValidModel_Inserts() - { - var mapping = new ProductAttributeMapping { Id = "pam1" }; - var product = new Product { Id = "p1" }; - product.ProductAttributeMappings.Add(mapping); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.ProductAttributeValueModel { ProductId = "p1", ProductAttributeMappingId = "pam1" }; - - var result = await _controller.ProductAttributeValueCreatePopup(model); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.InsertProductAttributeValueModel(model), Times.Once); - } - - [TestMethod] - public async Task ProductAttributeValueCreatePopup_Post_ScopeGrantsAccess_InvalidModelState_ReturnsView() - { - var mapping = new ProductAttributeMapping { Id = "pam1" }; - var product = new Product { Id = "p1" }; - product.ProductAttributeMappings.Add(mapping); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - _controller.ModelState.AddModelError("x", "err"); - var model = new ProductModel.ProductAttributeValueModel { ProductId = "p1", ProductAttributeMappingId = "pam1" }; - - var result = await _controller.ProductAttributeValueCreatePopup(model); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify( - s => s.InsertProductAttributeValueModel(It.IsAny()), - Times.Never); - _productViewModelServiceMock.Verify(s => s.PrepareProductAttributeValueModel(product, model), Times.Once); - } - - // --- ProductAttributeValueEditPopup (GET) ------------------------------------------------------- - - [TestMethod] - public async Task ProductAttributeValueEditPopup_Get_ScopeDeniesAccess_ReturnsPermissionsContent() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - - var result = await _controller.ProductAttributeValueEditPopup("pav1", "p1", "pam1"); - - var content = result as ContentResult; - Assert.IsNotNull(content); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - } - - [TestMethod] - public async Task ProductAttributeValueEditPopup_Get_ScopeGrantsAccess_MappingMissing_RedirectsToList() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - - var result = await _controller.ProductAttributeValueEditPopup("pav1", "p1", "missing-pam"); - - var redirect = result as RedirectToActionResult; - Assert.IsNotNull(redirect); - Assert.AreEqual("List", redirect.ActionName); - } - - [TestMethod] - public async Task ProductAttributeValueEditPopup_Get_ScopeGrantsAccess_ValueMissing_RedirectsToList() - { - var mapping = new ProductAttributeMapping { Id = "pam1" }; - var product = new Product { Id = "p1" }; - product.ProductAttributeMappings.Add(mapping); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - - var result = await _controller.ProductAttributeValueEditPopup("missing-pav", "p1", "pam1"); - - var redirect = result as RedirectToActionResult; - Assert.IsNotNull(redirect); - Assert.AreEqual("List", redirect.ActionName); - } - - [TestMethod] - public async Task ProductAttributeValueEditPopup_Get_ScopeGrantsAccess_PreparesModel() - { - var pav = new ProductAttributeValue { Id = "pav1" }; - var mapping = new ProductAttributeMapping { Id = "pam1" }; - mapping.ProductAttributeValues.Add(pav); - var product = new Product { Id = "p1" }; - product.ProductAttributeMappings.Add(mapping); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.ProductAttributeValueModel(); - _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeValueModel(mapping, pav)) - .ReturnsAsync(model); - - var result = await _controller.ProductAttributeValueEditPopup("pav1", "p1", "pam1"); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.PrepareProductAttributeValueModel(mapping, pav), Times.Once); - _productViewModelServiceMock.Verify(s => s.PrepareProductAttributeValueModel(product, model), Times.Once); - } - - // --- ProductAttributeValueEditPopup (POST) ------------------------------------------------------ - // HasAccess added here: Vendor's original never checked ownership on this POST at all (only its GET - // sibling did); Admin's original had no check on either action. - - [TestMethod] - public async Task ProductAttributeValueEditPopup_Post_MissingProduct_Throws() - { - _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); - var model = new ProductModel.ProductAttributeValueModel(); - - await Assert.ThrowsExactlyAsync( - () => _controller.ProductAttributeValueEditPopup("missing", model)); - } - - [TestMethod] - public async Task ProductAttributeValueEditPopup_Post_ScopeDeniesAccess_ReturnsPermissionsContent_DoesNotUpdate() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.ProductAttributeValueModel { Id = "pav1", ProductAttributeMappingId = "pam1" }; - - var result = await _controller.ProductAttributeValueEditPopup("p1", model); - - var content = result as ContentResult; - Assert.IsNotNull(content); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify( - s => s.UpdateProductAttributeValueModel(It.IsAny(), - It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ProductAttributeValueEditPopup_Post_ScopeGrantsAccess_ValueMissing_RedirectsToList() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.ProductAttributeValueModel { Id = "missing-pav", ProductAttributeMappingId = "pam1" }; - - var result = await _controller.ProductAttributeValueEditPopup("p1", model); - - var redirect = result as RedirectToActionResult; - Assert.IsNotNull(redirect); - Assert.AreEqual("List", redirect.ActionName); - } - - [TestMethod] - public async Task ProductAttributeValueEditPopup_Post_ScopeGrantsAccess_ValidModel_Updates() - { - var pav = new ProductAttributeValue { Id = "pav1" }; - var mapping = new ProductAttributeMapping { Id = "pam1" }; - mapping.ProductAttributeValues.Add(pav); - var product = new Product { Id = "p1" }; - product.ProductAttributeMappings.Add(mapping); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.ProductAttributeValueModel { Id = "pav1", ProductAttributeMappingId = "pam1" }; - - var result = await _controller.ProductAttributeValueEditPopup("p1", model); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.UpdateProductAttributeValueModel(pav, model), Times.Once); - } - - [TestMethod] - public async Task ProductAttributeValueEditPopup_Post_ScopeGrantsAccess_InvalidModelState_ReturnsView() - { - var pav = new ProductAttributeValue { Id = "pav1" }; - var mapping = new ProductAttributeMapping { Id = "pam1" }; - mapping.ProductAttributeValues.Add(pav); - var product = new Product { Id = "p1" }; - product.ProductAttributeMappings.Add(mapping); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - _controller.ModelState.AddModelError("x", "err"); - var model = new ProductModel.ProductAttributeValueModel { Id = "pav1", ProductAttributeMappingId = "pam1" }; - - var result = await _controller.ProductAttributeValueEditPopup("p1", model); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify( - s => s.UpdateProductAttributeValueModel(It.IsAny(), - It.IsAny()), Times.Never); - _productViewModelServiceMock.Verify(s => s.PrepareProductAttributeValueModel(product, model), Times.Once); - } - - // --- ProductAttributeValueDelete ----------------------------------------------------------------- - // HasAccess added here: this action takes only simple string parameters, so no model-level validator - // ever runs for it. Admin's original had no ownership check at all. - - [TestMethod] - public async Task ProductAttributeValueDelete_MissingProduct_Throws() - { - _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); - var attrServiceMock = new Mock(); - - await Assert.ThrowsExactlyAsync( - () => _controller.ProductAttributeValueDelete("pav1", "pam1", "missing", attrServiceMock.Object)); - } - - [TestMethod] - public async Task ProductAttributeValueDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var attrServiceMock = new Mock(); - - var result = await _controller.ProductAttributeValueDelete("pav1", "pam1", "p1", attrServiceMock.Object); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - attrServiceMock.Verify( - s => s.DeleteProductAttributeValue(It.IsAny(), It.IsAny(), - It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ProductAttributeValueDelete_ScopeGrantsAccess_ValueMissing_Throws() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var attrServiceMock = new Mock(); - - await Assert.ThrowsExactlyAsync( - () => _controller.ProductAttributeValueDelete("missing-pav", "pam1", "p1", attrServiceMock.Object)); - } - - [TestMethod] - public async Task ProductAttributeValueDelete_ScopeGrantsAccess_ValidModel_Deletes() - { - var pav = new ProductAttributeValue { Id = "pav1" }; - var mapping = new ProductAttributeMapping { Id = "pam1" }; - mapping.ProductAttributeValues.Add(pav); - var product = new Product { Id = "p1" }; - product.ProductAttributeMappings.Add(mapping); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var attrServiceMock = new Mock(); - - var result = await _controller.ProductAttributeValueDelete("pav1", "pam1", "p1", attrServiceMock.Object); - - Assert.IsInstanceOfType(result); - attrServiceMock.Verify(s => s.DeleteProductAttributeValue(pav, "p1", "pam1"), Times.Once); - } - - [TestMethod] - public async Task ProductAttributeValueDelete_ScopeGrantsAccess_InvalidModelState_ReturnsErrorJson_DoesNotDelete() - { - var pav = new ProductAttributeValue { Id = "pav1" }; - var mapping = new ProductAttributeMapping { Id = "pam1" }; - mapping.ProductAttributeValues.Add(pav); - var product = new Product { Id = "p1" }; - product.ProductAttributeMappings.Add(mapping); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - _controller.ModelState.AddModelError("x", "err"); - var attrServiceMock = new Mock(); - - var result = await _controller.ProductAttributeValueDelete("pav1", "pam1", "p1", attrServiceMock.Object); - - Assert.IsInstanceOfType(result); - attrServiceMock.Verify( - s => s.DeleteProductAttributeValue(It.IsAny(), It.IsAny(), - It.IsAny()), Times.Never); - } - - // --- AssociateProductToAttributeValuePopup (GET) ------------------------------------------------ - - [TestMethod] - public async Task AssociateProductToAttributeValuePopup_Get_CallsPrepareAssociateProductToAttributeValueModel() - { - // PrepareAssociateProductToAttributeValueModel() now reads scope internally (Task 9). - _productViewModelServiceMock.Setup(s => s.PrepareAssociateProductToAttributeValueModel()) - .ReturnsAsync(new ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel()); - - var result = await _controller.AssociateProductToAttributeValuePopup(); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.PrepareAssociateProductToAttributeValueModel(), - Times.Once); - } - - // --- AssociateProductToAttributeValuePopupList (POST) ------------------------------------------- - - [TestMethod] - public async Task AssociateProductToAttributeValuePopupList_DefaultStoreIdSet_AppliesSearchStoreId() - { - _scopeMock.Setup(s => s.DefaultStoreId).Returns("store1"); - var model = new ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel(); - _productViewModelServiceMock.Setup(s => s.PrepareProductModel(model, 1, 10)) - .ReturnsAsync((new List(), 0)); - - var result = await _controller.AssociateProductToAttributeValuePopupList( - new Grand.Web.Common.DataSource.DataSourceRequest { Page = 1, PageSize = 10 }, model); - - Assert.IsInstanceOfType(result); - Assert.AreEqual("store1", model.SearchStoreId); - } - - [TestMethod] - public async Task AssociateProductToAttributeValuePopupList_DefaultStoreIdNull_LeavesSearchStoreIdUntouched() - { - var model = new ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel - { SearchStoreId = "preset" }; - _productViewModelServiceMock.Setup(s => s.PrepareProductModel(model, 1, 10)) - .ReturnsAsync((new List(), 0)); - - var result = await _controller.AssociateProductToAttributeValuePopupList( - new Grand.Web.Common.DataSource.DataSourceRequest { Page = 1, PageSize = 10 }, model); - - Assert.IsInstanceOfType(result); - Assert.AreEqual("preset", model.SearchStoreId); - } - - // --- AssociateProductToAttributeValuePopup (POST) ----------------------------------------------- - // HasAccess added here: Admin's original had no ownership check on the associated product at all. - - [TestMethod] - public async Task AssociateProductToAttributeValuePopup_Post_AssociatedProductMissing_ReturnsContent() - { - _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); - var model = new ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel - { AssociatedToProductId = "missing" }; - - var result = await _controller.AssociateProductToAttributeValuePopup(model); - - var content = result as ContentResult; - Assert.IsNotNull(content); - Assert.AreEqual("Cannot load a product", content.Content); - _scopeMock.Verify(s => s.HasAccess(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task AssociateProductToAttributeValuePopup_Post_ScopeDeniesAccess_ReturnsPermissionsContent() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel - { AssociatedToProductId = "p1" }; - - var result = await _controller.AssociateProductToAttributeValuePopup(model); - - var content = result as ContentResult; - Assert.IsNotNull(content); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - } - - [TestMethod] - public async Task AssociateProductToAttributeValuePopup_Post_ScopeGrantsAccess_ReturnsEmptyContent() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel - { AssociatedToProductId = "p1" }; - - var result = await _controller.AssociateProductToAttributeValuePopup(model); - - Assert.IsInstanceOfType(result); - var content = result as ContentResult; - Assert.AreEqual("", content.Content); - } - - // --- ProductAttributeCombinationList (POST) ------------------------------------------------------- - - [TestMethod] - public async Task ProductAttributeCombinationList_ScopeDeniesAccess_ReturnsErrorJson() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); - - var result = await _controller.ProductAttributeCombinationList(new DataSourceRequest(), "p1"); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify( - s => s.PrepareProductAttributeCombinationModel(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ProductAttributeCombinationList_ScopeGrantsAccess_ReturnsGrid() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); - _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeCombinationModel(product)) - .ReturnsAsync(new List { new() }); - - var result = await _controller.ProductAttributeCombinationList(new DataSourceRequest(), "p1"); - - var json = result as JsonResult; - Assert.IsNotNull(json); - var gridModel = json.Value as DataSourceResult; - Assert.AreEqual(1, gridModel.Total); - } - - // --- ProductAttributeCombinationDelete ------------------------------------------------------------- - - [TestMethod] - public async Task ProductAttributeCombinationDelete_MissingProduct_Throws() - { - _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); - var attrServiceMock = new Mock(); - - await Assert.ThrowsExactlyAsync( - () => _controller.ProductAttributeCombinationDelete("c1", "missing", attrServiceMock.Object)); - } - - [TestMethod] - public async Task ProductAttributeCombinationDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() - { - var product = new Product { Id = "p1" }; - product.ProductAttributeCombinations.Add(new ProductAttributeCombination { Id = "c1" }); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var attrServiceMock = new Mock(); - - var result = await _controller.ProductAttributeCombinationDelete("c1", "p1", attrServiceMock.Object); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - attrServiceMock.Verify( - s => s.DeleteProductAttributeCombination(It.IsAny(), It.IsAny()), - Times.Never); - } - - [TestMethod] - public async Task ProductAttributeCombinationDelete_ScopeGrantsAccess_CombinationMissing_Throws() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var attrServiceMock = new Mock(); - - await Assert.ThrowsExactlyAsync( - () => _controller.ProductAttributeCombinationDelete("missing-c", "p1", attrServiceMock.Object)); - } - - [TestMethod] - public async Task ProductAttributeCombinationDelete_ScopeGrantsAccess_Deletes() - { - var product = new Product { Id = "p1", ManageInventoryMethodId = ManageInventoryMethod.DontManageStock }; - var combination = new ProductAttributeCombination { Id = "c1" }; - product.ProductAttributeCombinations.Add(combination); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var attrServiceMock = new Mock(); - - var result = await _controller.ProductAttributeCombinationDelete("c1", "p1", attrServiceMock.Object); - - Assert.IsInstanceOfType(result); - attrServiceMock.Verify(s => s.DeleteProductAttributeCombination(combination, "p1"), Times.Once); - } - - // --- AttributeCombinationPopup (GET) --------------------------------------------------------------- - - [TestMethod] - public async Task AttributeCombinationPopupGet_ScopeDeniesAccess_ReturnsPermissionsContent() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - - var result = await _controller.AttributeCombinationPopup("p1", "c1"); - - var content = result as ContentResult; - Assert.IsNotNull(content); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify( - s => s.PrepareProductAttributeCombinationModel(It.IsAny(), It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task AttributeCombinationPopupGet_ScopeGrantsAccess_ReturnsView() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductAttributeCombinationModel { Id = "c1" }; - _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeCombinationModel(product, "c1")) - .ReturnsAsync(model); - - var result = await _controller.AttributeCombinationPopup("p1", "c1"); - - var view = result as ViewResult; - Assert.IsNotNull(view); - Assert.AreSame(model, view.Model); - _productViewModelServiceMock.Verify(s => s.PrepareAddProductAttributeCombinationModel(model, product), - Times.Once); - } - - // --- AttributeCombinationPopup (POST) -------------------------------------------------------------- - - [TestMethod] - public async Task AttributeCombinationPopupPost_MissingProduct_RedirectsToList() - { - _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); - var model = new ProductAttributeCombinationModel(); - - var result = await _controller.AttributeCombinationPopup("missing", model); - - var redirect = result as RedirectToActionResult; - Assert.IsNotNull(redirect); - Assert.AreEqual("List", redirect.ActionName); - _scopeMock.Verify(s => s.HasAccess(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task AttributeCombinationPopupPost_ScopeDeniesAccess_ReturnsPermissionsContent() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductAttributeCombinationModel(); - - var result = await _controller.AttributeCombinationPopup("p1", model); - - var content = result as ContentResult; - Assert.IsNotNull(content); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify( - s => s.InsertOrUpdateProductAttributeCombinationPopup(It.IsAny(), - It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task AttributeCombinationPopupPost_ScopeGrantsAccess_NoWarnings_ReturnsEmptyContent() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductAttributeCombinationModel(); - _productViewModelServiceMock.Setup(s => s.InsertOrUpdateProductAttributeCombinationPopup(product, model)) - .ReturnsAsync(new List()); - - var result = await _controller.AttributeCombinationPopup("p1", model); - - var content = result as ContentResult; - Assert.IsNotNull(content); - Assert.AreEqual("", content.Content); - } - - [TestMethod] - public async Task AttributeCombinationPopupPost_ScopeGrantsAccess_WithWarnings_ReturnsViewWithWarnings() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductAttributeCombinationModel(); - _productViewModelServiceMock.Setup(s => s.InsertOrUpdateProductAttributeCombinationPopup(product, model)) - .ReturnsAsync(new List { "warning" }); - - var result = await _controller.AttributeCombinationPopup("p1", model); - - var view = result as ViewResult; - Assert.IsNotNull(view); - Assert.AreSame(model, view.Model); - CollectionAssert.Contains(model.Warnings.ToList(), "warning"); - _productViewModelServiceMock.Verify(s => s.PrepareAddProductAttributeCombinationModel(model, product), - Times.Once); - } - - // --- GenerateAllAttributeCombinations --------------------------------------------------------------- - - [TestMethod] - public async Task GenerateAllAttributeCombinations_MissingProduct_Throws() - { - _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); - - await Assert.ThrowsExactlyAsync( - () => _controller.GenerateAllAttributeCombinations("missing")); - } - - [TestMethod] - public async Task GenerateAllAttributeCombinations_ScopeDeniesAccess_ReturnsErrorJson_DoesNotGenerate() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - - var result = await _controller.GenerateAllAttributeCombinations("p1"); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.GenerateAllAttributeCombinations(It.IsAny()), - Times.Never); - } - - [TestMethod] - public async Task GenerateAllAttributeCombinations_ScopeGrantsAccess_Generates() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - - var result = await _controller.GenerateAllAttributeCombinations("p1"); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.GenerateAllAttributeCombinations(product), Times.Once); - } - - // --- ClearAllAttributeCombinations ----------------------------------------------------------------- - - [TestMethod] - public async Task ClearAllAttributeCombinations_MissingProduct_Throws() - { - _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); - - await Assert.ThrowsExactlyAsync( - () => _controller.ClearAllAttributeCombinations("missing")); - } - - [TestMethod] - public async Task ClearAllAttributeCombinations_ScopeDeniesAccess_ReturnsErrorJson_DoesNotClear() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - - var result = await _controller.ClearAllAttributeCombinations("p1"); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify(s => s.ClearAllAttributeCombinations(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ClearAllAttributeCombinations_ScopeGrantsAccess_ValidModel_Clears() - { - var product = new Product - { Id = "p1", ManageInventoryMethodId = ManageInventoryMethod.DontManageStock }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - - var result = await _controller.ClearAllAttributeCombinations("p1"); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.ClearAllAttributeCombinations(product), Times.Once); - } - - [TestMethod] - public async Task ClearAllAttributeCombinations_ScopeGrantsAccess_InvalidModelState_ReturnsErrorJson_DoesNotClear() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - _controller.ModelState.AddModelError("x", "err"); - - var result = await _controller.ClearAllAttributeCombinations("p1"); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify(s => s.ClearAllAttributeCombinations(It.IsAny()), Times.Never); - } - - // --- ProductAttributeCombinationTierPriceList (POST) ----------------------------------------------- - - [TestMethod] - public async Task ProductAttributeCombinationTierPriceList_ScopeDeniesAccess_ReturnsErrorJson() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - - var result = await _controller.ProductAttributeCombinationTierPriceList(new DataSourceRequest(), "p1", "c1"); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify( - s => s.PrepareProductAttributeCombinationTierPricesModel(It.IsAny(), It.IsAny()), - Times.Never); - } - - [TestMethod] - public async Task ProductAttributeCombinationTierPriceList_ScopeGrantsAccess_ReturnsGrid() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - _productViewModelServiceMock.Setup(s => s.PrepareProductAttributeCombinationTierPricesModel(product, "c1")) - .ReturnsAsync(new List { new() }); - - var result = await _controller.ProductAttributeCombinationTierPriceList(new DataSourceRequest(), "p1", "c1"); - - var json = result as JsonResult; - Assert.IsNotNull(json); - var gridModel = json.Value as DataSourceResult; - Assert.AreEqual(1, gridModel.Total); - } - - // --- ProductAttributeCombinationTierPriceInsert ----------------------------------------------------- - - [TestMethod] - public async Task ProductAttributeCombinationTierPriceInsert_MissingProduct_Throws() - { - _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); - - await Assert.ThrowsExactlyAsync( - () => _controller.ProductAttributeCombinationTierPriceInsert("missing", "c1", - new ProductModel.ProductAttributeCombinationTierPricesModel())); - } - - [TestMethod] - public async Task ProductAttributeCombinationTierPriceInsert_ScopeDeniesAccess_ReturnsContent_DoesNotInsert() - { - var product = new Product { Id = "p1" }; - product.ProductAttributeCombinations.Add(new ProductAttributeCombination { Id = "c1" }); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - - var result = await _controller.ProductAttributeCombinationTierPriceInsert("p1", "c1", - new ProductModel.ProductAttributeCombinationTierPricesModel()); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify( - s => s.InsertProductAttributeCombinationTierPricesModel(It.IsAny(), - It.IsAny(), - It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ProductAttributeCombinationTierPriceInsert_ScopeGrantsAccess_CombinationMissing_DoesNotInsert() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - - var result = await _controller.ProductAttributeCombinationTierPriceInsert("p1", "missing-c", - new ProductModel.ProductAttributeCombinationTierPricesModel()); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify( - s => s.InsertProductAttributeCombinationTierPricesModel(It.IsAny(), - It.IsAny(), - It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ProductAttributeCombinationTierPriceInsert_ScopeGrantsAccess_Inserts() - { - var product = new Product { Id = "p1" }; - var combination = new ProductAttributeCombination { Id = "c1" }; - product.ProductAttributeCombinations.Add(combination); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.ProductAttributeCombinationTierPricesModel(); - - var result = await _controller.ProductAttributeCombinationTierPriceInsert("p1", "c1", model); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify( - s => s.InsertProductAttributeCombinationTierPricesModel(product, combination, model), Times.Once); - } - - // --- ProductAttributeCombinationTierPriceUpdate ----------------------------------------------------- - - [TestMethod] - public async Task ProductAttributeCombinationTierPriceUpdate_MissingProduct_Throws() - { - _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); - - await Assert.ThrowsExactlyAsync( - () => _controller.ProductAttributeCombinationTierPriceUpdate("missing", "c1", - new ProductModel.ProductAttributeCombinationTierPricesModel())); - } - - [TestMethod] - public async Task ProductAttributeCombinationTierPriceUpdate_ScopeDeniesAccess_ReturnsContent_DoesNotUpdate() - { - var product = new Product { Id = "p1" }; - product.ProductAttributeCombinations.Add(new ProductAttributeCombination { Id = "c1" }); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - - var result = await _controller.ProductAttributeCombinationTierPriceUpdate("p1", "c1", - new ProductModel.ProductAttributeCombinationTierPricesModel()); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify( - s => s.UpdateProductAttributeCombinationTierPricesModel(It.IsAny(), - It.IsAny(), - It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ProductAttributeCombinationTierPriceUpdate_ScopeGrantsAccess_Updates() - { - var product = new Product { Id = "p1" }; - var combination = new ProductAttributeCombination { Id = "c1" }; - product.ProductAttributeCombinations.Add(combination); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var model = new ProductModel.ProductAttributeCombinationTierPricesModel(); - - var result = await _controller.ProductAttributeCombinationTierPriceUpdate("p1", "c1", model); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify( - s => s.UpdateProductAttributeCombinationTierPricesModel(product, combination, model), Times.Once); - } - - // --- ProductAttributeCombinationTierPriceDelete ----------------------------------------------------- - - [TestMethod] - public async Task ProductAttributeCombinationTierPriceDelete_MissingProduct_Throws() - { - _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); - - await Assert.ThrowsExactlyAsync( - () => _controller.ProductAttributeCombinationTierPriceDelete("missing", "c1", "tp1")); - } - - [TestMethod] - public async Task ProductAttributeCombinationTierPriceDelete_ScopeDeniesAccess_ReturnsContent_DoesNotDelete() - { - var product = new Product { Id = "p1" }; - product.ProductAttributeCombinations.Add(new ProductAttributeCombination { Id = "c1" }); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - - var result = await _controller.ProductAttributeCombinationTierPriceDelete("p1", "c1", "tp1"); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productViewModelServiceMock.Verify( - s => s.DeleteProductAttributeCombinationTierPrices(It.IsAny(), - It.IsAny(), - It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ProductAttributeCombinationTierPriceDelete_ScopeGrantsAccess_TierPriceMissing_DoesNotDelete() - { - var product = new Product { Id = "p1" }; - product.ProductAttributeCombinations.Add(new ProductAttributeCombination { Id = "c1" }); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - - var result = await _controller.ProductAttributeCombinationTierPriceDelete("p1", "c1", "missing-tp"); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify( - s => s.DeleteProductAttributeCombinationTierPrices(It.IsAny(), - It.IsAny(), - It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ProductAttributeCombinationTierPriceDelete_ScopeGrantsAccess_Deletes() - { - var product = new Product { Id = "p1" }; - var combination = new ProductAttributeCombination { Id = "c1" }; - var tierPrice = new ProductCombinationTierPrices { Id = "tp1" }; - combination.TierPrices.Add(tierPrice); - product.ProductAttributeCombinations.Add(combination); - _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - - var result = await _controller.ProductAttributeCombinationTierPriceDelete("p1", "c1", "tp1"); - - Assert.IsInstanceOfType(result); - _productViewModelServiceMock.Verify( - s => s.DeleteProductAttributeCombinationTierPrices(product, combination, tierPrice), Times.Once); - } - - private static ProductReservation NewReservation(string id, string productId, string orderId = "") => - new() { Id = id, ProductId = productId, OrderId = orderId, Date = DateTime.UtcNow }; - - // --- ListReservations ---------------------------------------------------------------------------- - - [TestMethod] - public async Task ListReservations_ScopeDeniesAccess_ReturnsKendoGridError_DoesNotQuery() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); - - var result = await _controller.ListReservations( - new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productReservationServiceMock.Verify( - s => s.GetProductReservationsByProductId(It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task ListReservations_ScopeGrantsAccess_ReturnsGrid() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var reservations = new PagedList( - new List { NewReservation("r1", "p1") }, 0, 10); - _productReservationServiceMock - .Setup(s => s.GetProductReservationsByProductId("p1", null, null, 0, 10)) - .ReturnsAsync(reservations); - - var result = await _controller.ListReservations( - new Grand.Web.Common.DataSource.DataSourceRequest { Page = 1, PageSize = 10 }, "p1"); - - var json = result as JsonResult; - Assert.IsNotNull(json); - var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; - Assert.IsNotNull(gridModel); - Assert.AreEqual(1, gridModel.Total); - } - - // A product shared with other stores opens read-only on Store: the tab loads, but the - // reservations (other stores' orders) are not listed. - [TestMethod] - public async Task ListReservations_ViewableButNotOwned_ReturnsEmptyGrid_DoesNotQuery() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - - var result = await _controller.ListReservations( - new Grand.Web.Common.DataSource.DataSourceRequest { Page = 1, PageSize = 10 }, "p1"); - - var gridModel = (result as JsonResult)?.Value as Grand.Web.Common.DataSource.DataSourceResult; - Assert.IsNotNull(gridModel); - Assert.AreEqual(0, gridModel.Total); - _translationServiceMock.Verify(t => t.GetResource(It.IsAny()), Times.Never); - _productReservationServiceMock.Verify( - s => s.GetProductReservationsByProductId(It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny()), Times.Never); - } - - // --- GenerateCalendar ------------------------------------------------------------------------------ - - private static ProductModel.GenerateCalendarModel ValidCalendarModel(IntervalUnit unit = IntervalUnit.Day) => new() { - StartDate = new DateTime(2026, 1, 1), - EndDate = new DateTime(2026, 1, 1), - StartTime = new DateTime(2026, 1, 1, 8, 0, 0), - EndTime = new DateTime(2026, 1, 1, 18, 0, 0), - Interval = 1, - IntervalUnit = (int)unit, - Quantity = 1, - Resource = "room1", - // 2026-01-01 is a Thursday. - Thursday = true - }; - - [TestMethod] - public async Task GenerateCalendar_MissingProduct_Throws() - { - _productServiceMock.Setup(p => p.GetProductById("missing")).ReturnsAsync((Product)null); - - await Assert.ThrowsExactlyAsync( - () => _controller.GenerateCalendar("missing", ValidCalendarModel())); - } - - [TestMethod] - public async Task GenerateCalendar_ScopeDeniesAccess_ReturnsErrorsJson_DoesNotUpdate() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - - var result = await _controller.GenerateCalendar("p1", ValidCalendarModel()); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productReservationServiceMock.Verify( - s => s.InsertProductReservation(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task GenerateCalendar_InvalidModelState_ReturnsErrorsJson_DoesNotInsert() - { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - _productReservationServiceMock - .Setup(s => s.GetProductReservationsByProductId("p1", null, null)) - .ReturnsAsync(new PagedList(new List(), 0, int.MaxValue)); - _controller.ModelState.AddModelError("Resource", "Required"); - - var result = await _controller.GenerateCalendar("p1", ValidCalendarModel()); - - Assert.IsInstanceOfType(result); - _productReservationServiceMock.Verify( - s => s.InsertProductReservation(It.IsAny()), Times.Never); - } - - [TestMethod] - public async Task GenerateCalendar_ScopeGrantsAccess_Valid_InsertsReservationAndUpdatesProductFields() - { - var product = new Product { Id = "p1", IntervalUnitId = IntervalUnit.Day }; - _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - _productReservationServiceMock - .Setup(s => s.GetProductReservationsByProductId("p1", null, null)) - .ReturnsAsync(new PagedList(new List(), 0, int.MaxValue)); - - var result = await _controller.GenerateCalendar("p1", ValidCalendarModel()); - - Assert.IsInstanceOfType(result); - _productReservationServiceMock.Verify( - s => s.InsertProductReservation(It.Is(r => - r.ProductId == "p1" && r.Resource == "room1")), Times.Once); - _productServiceMock.Verify( - s => s.UpdateProductField(product, It.IsAny>>(), 1), Times.Once); - } - - // --- ClearCalendar ---------------------------------------------------------------------------------- - - [TestMethod] - public async Task ClearCalendar_MissingProduct_Throws() - { - _productServiceMock.Setup(p => p.GetProductById("missing")).ReturnsAsync((Product)null); - - await Assert.ThrowsExactlyAsync(() => _controller.ClearCalendar("missing")); - } - - [TestMethod] - public async Task ClearCalendar_ScopeDeniesAccess_Throws_DoesNotDelete() - { - // Throws rather than returning Json({errors}): CreateOrUpdate.Calendar.cshtml's success - // callback for this action never reads the response body (no else branch) on any of the three - // hosts, so a 200 response would silently refresh the grid as if the clear succeeded. - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - - await Assert.ThrowsExactlyAsync(() => _controller.ClearCalendar("p1")); + var result = await _controller.ImportExcel(fileMock.Object, importManagerMock.Object); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productReservationServiceMock.Verify( - s => s.DeleteProductReservation(It.IsAny()), Times.Never); + var redirect = Assert.IsInstanceOfType(result); + Assert.AreEqual("List", redirect.ActionName); + importManagerMock.Verify(i => i.Import(It.IsAny(), It.IsAny()), Times.Never); + _translationServiceMock.Verify(t => t.GetResource("Admin.AccessDenied.Title"), Times.Once); } [TestMethod] - public async Task ClearCalendar_ScopeGrantsAccess_DeletesAllReservations() + public async Task ImportExcel_VendorScope_DeniesAndNeverCallsImportManager() { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var reservation = NewReservation("r1", "p1"); - _productReservationServiceMock - .Setup(s => s.GetProductReservationsByProductId("p1", true, null)) - .ReturnsAsync(new PagedList(new List { reservation }, 0, int.MaxValue)); - - var result = await _controller.ClearCalendar("p1"); - - Assert.IsInstanceOfType(result); - _productReservationServiceMock.Verify(s => s.DeleteProductReservation(reservation), Times.Once); - } - - // --- ClearOld --------------------------------------------------------------------------------------- + _scopeMock.Setup(s => s.DefaultVendorId).Returns("vendor-1"); + var fileMock = new Mock(); + fileMock.Setup(f => f.Length).Returns(100); + var importManagerMock = new Mock>(); - [TestMethod] - public async Task ClearOld_MissingProduct_Throws() - { - _productServiceMock.Setup(p => p.GetProductById("missing")).ReturnsAsync((Product)null); + var result = await _controller.ImportExcel(fileMock.Object, importManagerMock.Object); - await Assert.ThrowsExactlyAsync(() => _controller.ClearOld("missing")); + var redirect = Assert.IsInstanceOfType(result); + Assert.AreEqual("List", redirect.ActionName); + importManagerMock.Verify(i => i.Import(It.IsAny(), It.IsAny()), Times.Never); + _translationServiceMock.Verify(t => t.GetResource("Admin.AccessDenied.Title"), Times.Once); } [TestMethod] - public async Task ClearOld_ScopeDeniesAccess_Throws_DoesNotDelete() + public async Task ImportExcel_NullFile_DoesNotImport_RedirectsToListWithError() { - // See the comment on ClearCalendar_ScopeDeniesAccess_Throws_DoesNotDelete above - same view-layer - // gap applies to this action's response handling. - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var importManagerMock = new Mock>(); - await Assert.ThrowsExactlyAsync(() => _controller.ClearOld("p1")); + var result = await _controller.ImportExcel(null, importManagerMock.Object); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productReservationServiceMock.Verify( - s => s.DeleteProductReservation(It.IsAny()), Times.Never); + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("List", redirect.ActionName); + importManagerMock.Verify(i => i.Import(It.IsAny(), It.IsAny()), Times.Never); } [TestMethod] - public async Task ClearOld_ScopeGrantsAccess_DeletesOnlyPastReservations() + public async Task ImportExcel_ValidFile_ImportsAndRedirectsToListWithSuccess() { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var oldReservation = new ProductReservation { Id = "old", ProductId = "p1", Date = DateTime.UtcNow.AddDays(-1) }; - var futureReservation = new ProductReservation { Id = "future", ProductId = "p1", Date = DateTime.UtcNow.AddDays(1) }; - _productReservationServiceMock - .Setup(s => s.GetProductReservationsByProductId("p1", true, null)) - .ReturnsAsync(new PagedList( - new List { oldReservation, futureReservation }, 0, int.MaxValue)); - - var result = await _controller.ClearOld("p1"); - - Assert.IsInstanceOfType(result); - _productReservationServiceMock.Verify(s => s.DeleteProductReservation(oldReservation), Times.Once); - _productReservationServiceMock.Verify(s => s.DeleteProductReservation(futureReservation), Times.Never); - } - - // --- ProductReservationDelete ------------------------------------------------------------------------ + using var stream = new MemoryStream([1, 2, 3]); + var fileMock = new Mock(); + fileMock.Setup(f => f.Length).Returns(3); + fileMock.Setup(f => f.OpenReadStream()).Returns(stream); + var importManagerMock = new Mock>(); + importManagerMock.Setup(i => i.Import(stream)).Returns(Task.CompletedTask); - [TestMethod] - public async Task ProductReservationDelete_MissingProduct_Throws() - { - _productServiceMock.Setup(p => p.GetProductById("missing")).ReturnsAsync((Product)null); - var model = new ProductModel.ReservationModel { ProductId = "missing", ReservationId = "r1" }; + var result = await _controller.ImportExcel(fileMock.Object, importManagerMock.Object); - await Assert.ThrowsExactlyAsync(() => _controller.ProductReservationDelete(model)); + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("List", redirect.ActionName); + importManagerMock.Verify(i => i.Import(stream), Times.Once); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Imported"), Times.Once); } [TestMethod] - public async Task ProductReservationDelete_ScopeDeniesAccess_ReturnsKendoGridError_DoesNotDelete() + public async Task ImportExcel_ImportThrows_RedirectsToListWithError() { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.ReservationModel { ProductId = "p1", ReservationId = "r1" }; + using var stream = new MemoryStream([1]); + var fileMock = new Mock(); + fileMock.Setup(f => f.Length).Returns(1); + fileMock.Setup(f => f.OpenReadStream()).Returns(stream); + var importManagerMock = new Mock>(); + importManagerMock.Setup(i => i.Import(stream)).ThrowsAsync(new Exception("bad file")); - var result = await _controller.ProductReservationDelete(model); + var result = await _controller.ImportExcel(fileMock.Object, importManagerMock.Object); - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productReservationServiceMock.Verify( - s => s.DeleteProductReservation(It.IsAny()), Times.Never); + var redirect = result as RedirectToActionResult; + Assert.IsNotNull(redirect); + Assert.AreEqual("List", redirect.ActionName); } - // Regression guard for a real cross-product IDOR found in Store's and Vendor's original code: the - // access check ran against model.ProductId while the delete ran against model.ReservationId, with no - // check that the two referred to the same product. A caller with access to *any* product could pass - // that product's id for the access check while supplying the ReservationId of a reservation belonging - // to a different, unowned product, deleting it. - [TestMethod] - public async Task ProductReservationDelete_ReservationBelongsToDifferentProduct_ReturnsKendoGridError_DoesNotDelete() - { - var ownedProduct = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(ownedProduct); - _scopeMock.Setup(s => s.HasAccess(ownedProduct)).ReturnsAsync(true); - var foreignReservation = NewReservation("r1", "p2"); - _productReservationServiceMock.Setup(s => s.GetProductReservation("r1")).ReturnsAsync(foreignReservation); - var model = new ProductModel.ReservationModel { ProductId = "p1", ReservationId = "r1" }; - - var result = await _controller.ProductReservationDelete(model); - - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _productReservationServiceMock.Verify( - s => s.DeleteProductReservation(It.IsAny()), Times.Never); - } + // --- Bulk editing -------------------------------------------------------------------------------- [TestMethod] - public async Task ProductReservationDelete_ReservationHasOrder_ReturnsErrorsJson_DoesNotDelete() + public async Task BulkEdit_CallsPrepareBulkEditListModel() { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var reservation = NewReservation("r1", "p1", "order1"); - _productReservationServiceMock.Setup(s => s.GetProductReservation("r1")).ReturnsAsync(reservation); - var model = new ProductModel.ReservationModel { ProductId = "p1", ReservationId = "r1" }; + // PrepareBulkEditListModel() now reads scope internally (Task 9). + _productViewModelServiceMock.Setup(s => s.PrepareBulkEditListModel()) + .ReturnsAsync(new BulkEditListModel()); - var result = await _controller.ProductReservationDelete(model); + var result = await _controller.BulkEdit(); - var json = result as JsonResult; - Assert.IsNotNull(json); - var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; - Assert.IsNotNull(gridModel); - Assert.IsNotNull(gridModel.Errors); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.ProductReservations.CantDeleteWithOrder"), Times.Once); - _productReservationServiceMock.Verify( - s => s.DeleteProductReservation(It.IsAny()), Times.Never); + Assert.IsInstanceOfType(result, typeof(ViewResult)); + _productViewModelServiceMock.Verify(s => s.PrepareBulkEditListModel(), Times.Once); } [TestMethod] - public async Task ProductReservationDelete_ScopeGrantsAccess_SameProduct_Deletes() + public async Task BulkEditSelect_StoreScoped_SetsSearchStoreIdFromScope() { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var reservation = NewReservation("r1", "p1"); - _productReservationServiceMock.Setup(s => s.GetProductReservation("r1")).ReturnsAsync(reservation); - var model = new ProductModel.ReservationModel { ProductId = "p1", ReservationId = "r1" }; + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store1"); + var model = new BulkEditListModel(); + _productViewModelServiceMock + .Setup(s => s.PrepareBulkEditProductModel(It.IsAny(), 1, 10)) + .ReturnsAsync((Enumerable.Empty(), 0)); - var result = await _controller.ProductReservationDelete(model); + await _controller.BulkEditSelect(new Grand.Web.Common.DataSource.DataSourceRequest { Page = 1, PageSize = 10 }, model); - Assert.IsInstanceOfType(result); - _productReservationServiceMock.Verify(s => s.DeleteProductReservation(reservation), Times.Once); + Assert.AreEqual("store1", model.SearchStoreId); } [TestMethod] - public async Task ProductReservationDelete_ReservationNotFound_ReturnsEmptyJson_NoOp() + public async Task BulkEditSelect_NotStoreScoped_LeavesSearchStoreIdUntouched() { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - _productReservationServiceMock.Setup(s => s.GetProductReservation("missing")).ReturnsAsync((ProductReservation)null); - var model = new ProductModel.ReservationModel { ProductId = "p1", ReservationId = "missing" }; + var model = new BulkEditListModel { SearchStoreId = "preset" }; + _productViewModelServiceMock + .Setup(s => s.PrepareBulkEditProductModel(It.IsAny(), 1, 10)) + .ReturnsAsync((Enumerable.Empty(), 0)); - var result = await _controller.ProductReservationDelete(model); + await _controller.BulkEditSelect(new Grand.Web.Common.DataSource.DataSourceRequest { Page = 1, PageSize = 10 }, model); - Assert.IsInstanceOfType(result); - _productReservationServiceMock.Verify( - s => s.DeleteProductReservation(It.IsAny()), Times.Never); + Assert.AreEqual("preset", model.SearchStoreId); } - private static Bid NewBid(string id, string productId, string orderId = "") => - new() { Id = id, ProductId = productId, OrderId = orderId, Date = DateTime.UtcNow }; - - // --- ListBids -------------------------------------------------------------------------------- - [TestMethod] - public async Task ListBids_MissingProduct_Throws() + public async Task BulkEditCreate_NullProducts_DoesNotCallService() { - _productServiceMock.Setup(p => p.GetProductById("missing")).ReturnsAsync((Product)null); + var result = await _controller.BulkEditCreate(null); - await Assert.ThrowsExactlyAsync( - () => _controller.ListBids(new Grand.Web.Common.DataSource.DataSourceRequest(), "missing")); + Assert.IsInstanceOfType(result, typeof(JsonResult)); + _productViewModelServiceMock.Verify( + s => s.InsertBulkEdit(It.IsAny>()), Times.Never); } [TestMethod] - public async Task ListBids_ScopeDeniesAccess_ReturnsKendoGridError_DoesNotQuery() + public async Task BulkEditCreate_PassesOnlyRowsWithAName() { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); - - var result = await _controller.ListBids(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + await _controller.BulkEditCreate(new List { + new() { Id = "", Name = "New product" }, + new() { Id = "", Name = " " } + }); - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); _productViewModelServiceMock.Verify( - s => s.PrepareBidMode(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + s => s.InsertBulkEdit(It.Is>( + l => l.Count == 1 && l[0].Name == "New product")), Times.Once); } [TestMethod] - public async Task ListBids_ScopeGrantsAccess_ReturnsBids() + public async Task BulkEditUpdate_NullProducts_DoesNotCallService() { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - _productViewModelServiceMock.Setup(s => s.PrepareBidMode("p1", 0, 10)) - .ReturnsAsync((Enumerable.Empty(), 0)); - - var result = await _controller.ListBids( - new Grand.Web.Common.DataSource.DataSourceRequest { PageSize = 10 }, "p1"); + var result = await _controller.BulkEditUpdate(null); - Assert.IsInstanceOfType(result); - var json = result as JsonResult; - var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; - Assert.IsNotNull(gridModel); - Assert.AreEqual(0, gridModel.Total); + Assert.IsInstanceOfType(result, typeof(JsonResult)); + _productViewModelServiceMock.Verify( + s => s.UpdateBulkEdit(It.IsAny>()), Times.Never); } - // A product shared with other stores opens read-only on Store: the tab loads, but the bids - // (other stores' customers) are not listed. [TestMethod] - public async Task ListBids_ViewableButNotOwned_ReturnsEmptyGrid_DoesNotQuery() + public async Task BulkEditUpdate_FiltersOutProductsScopeDenies() { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); - _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - - var result = await _controller.ListBids(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); - - var gridModel = (result as JsonResult)?.Value as Grand.Web.Common.DataSource.DataSourceResult; - Assert.IsNotNull(gridModel); - Assert.AreEqual(0, gridModel.Total); - _translationServiceMock.Verify(t => t.GetResource(It.IsAny()), Times.Never); - _productViewModelServiceMock.Verify( - s => s.PrepareBidMode(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); - } + // Regression guard: Admin's original had no ownership check at all on this bulk-mutate endpoint + // (any client-supplied id list was updated unconditionally). Store's original filtered via + // FilterValidProductsForStore/CanAccessProduct; Vendor's original filtered via + // HasAccessToProduct inside its own service. Routing through scope.HasAccess here reproduces + // that per-item gate uniformly. + var owned = new Product { Id = "owned" }; + var foreign = new Product { Id = "foreign" }; + _productServiceMock.Setup(p => p.GetProductsByIds(new[] { "owned", "foreign" }, + CatalogAccess.IncludingHidden())) + .ReturnsAsync(new List { owned, foreign }); + _scopeMock.Setup(s => s.HasAccess(owned)).ReturnsAsync(true); + _scopeMock.Setup(s => s.HasAccess(foreign)).ReturnsAsync(false); - // --- BidDelete --------------------------------------------------------------------------------- + var ownedModel = new BulkEditProductModel { Id = "owned" }; + var foreignModel = new BulkEditProductModel { Id = "foreign" }; - [TestMethod] - public async Task BidDelete_MissingProduct_Throws() - { - _productServiceMock.Setup(p => p.GetProductById("missing")).ReturnsAsync((Product)null); - var model = new ProductModel.BidModel { ProductId = "missing", BidId = "b1" }; + await _controller.BulkEditUpdate(new List { ownedModel, foreignModel }); - await Assert.ThrowsExactlyAsync(() => _controller.BidDelete(model)); + _productViewModelServiceMock.Verify( + s => s.UpdateBulkEdit(It.Is>( + p => p.Count == 1 && p[0].Id == "owned")), Times.Once); } [TestMethod] - public async Task BidDelete_ScopeDeniesAccess_ReturnsKendoGridError_DoesNotDelete() + public async Task BulkEditUpdate_AllProductsScopeDenies_DoesNotCallUpdateBulkEdit() { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); - var model = new ProductModel.BidModel { ProductId = "p1", BidId = "b1" }; + var foreign = new Product { Id = "foreign" }; + _productServiceMock.Setup(p => p.GetProductsByIds(new[] { "foreign" }, CatalogAccess.IncludingHidden())) + .ReturnsAsync(new List { foreign }); + _scopeMock.Setup(s => s.HasAccess(foreign)).ReturnsAsync(false); - var result = await _controller.BidDelete(model); + await _controller.BulkEditUpdate(new List { new() { Id = "foreign" } }); - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _auctionServiceMock.Verify(s => s.DeleteBid(It.IsAny()), Times.Never); + _productViewModelServiceMock.Verify( + s => s.UpdateBulkEdit(It.IsAny>()), Times.Never); } - // Regression guard for the same cross-product IDOR shape as ProductReservationDelete above (found by - // the row 23 reviewer to apply identically here): the access check ran against model.ProductId while - // the delete ran against model.BidId, with no check that the two referred to the same product. A - // caller with access to *any* product could pass that product's id for the access check while - // supplying the BidId of a bid belonging to a different, unowned product, deleting it. [TestMethod] - public async Task BidDelete_BidBelongsToDifferentProduct_ReturnsKendoGridError_DoesNotDelete() + public async Task BulkEditDelete_NullProducts_DoesNotCallService() { - var ownedProduct = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(ownedProduct); - _scopeMock.Setup(s => s.HasAccess(ownedProduct)).ReturnsAsync(true); - var foreignBid = NewBid("b1", "p2"); - _auctionServiceMock.Setup(s => s.GetBid("b1")).ReturnsAsync(foreignBid); - var model = new ProductModel.BidModel { ProductId = "p1", BidId = "b1" }; - - var result = await _controller.BidDelete(model); + var result = await _controller.BulkEditDelete(null); - Assert.IsInstanceOfType(result); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); - _auctionServiceMock.Verify(s => s.DeleteBid(It.IsAny()), Times.Never); + Assert.IsInstanceOfType(result, typeof(JsonResult)); + _productViewModelServiceMock.Verify( + s => s.DeleteBulkEdit(It.IsAny>()), Times.Never); } [TestMethod] - public async Task BidDelete_BidHasOrder_ReturnsErrorsJson_DoesNotDelete() + public async Task BulkEditDelete_FiltersOutProductsScopeDenies() { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var bid = NewBid("b1", "p1", "order1"); - _auctionServiceMock.Setup(s => s.GetBid("b1")).ReturnsAsync(bid); - var model = new ProductModel.BidModel { ProductId = "p1", BidId = "b1" }; + var owned = new Product { Id = "owned" }; + var foreign = new Product { Id = "foreign" }; + _productServiceMock.Setup(p => p.GetProductsByIds(new[] { "owned", "foreign" }, + CatalogAccess.IncludingHidden())) + .ReturnsAsync(new List { owned, foreign }); + _scopeMock.Setup(s => s.HasAccess(owned)).ReturnsAsync(true); + _scopeMock.Setup(s => s.HasAccess(foreign)).ReturnsAsync(false); - var result = await _controller.BidDelete(model); + await _controller.BulkEditDelete(new List { + new() { Id = "owned" }, new() { Id = "foreign" } + }); - var json = result as JsonResult; - Assert.IsNotNull(json); - var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; - Assert.IsNotNull(gridModel); - Assert.IsNotNull(gridModel.Errors); - _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Bids.CantDeleteWithOrder"), Times.Once); - _auctionServiceMock.Verify(s => s.DeleteBid(It.IsAny()), Times.Never); + _productViewModelServiceMock.Verify( + s => s.DeleteBulkEdit(It.Is>( + p => p.Count == 1 && p[0].Id == "owned")), Times.Once); } [TestMethod] - public async Task BidDelete_ScopeGrantsAccess_SameProduct_Deletes() + public async Task BulkEditDelete_AllProductsScopeDenies_DoesNotCallDeleteBulkEdit() { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - var bid = NewBid("b1", "p1"); - _auctionServiceMock.Setup(s => s.GetBid("b1")).ReturnsAsync(bid); - var model = new ProductModel.BidModel { ProductId = "p1", BidId = "b1" }; + var foreign = new Product { Id = "foreign" }; + _productServiceMock.Setup(p => p.GetProductsByIds(new[] { "foreign" }, CatalogAccess.IncludingHidden())) + .ReturnsAsync(new List { foreign }); + _scopeMock.Setup(s => s.HasAccess(foreign)).ReturnsAsync(false); - var result = await _controller.BidDelete(model); + await _controller.BulkEditDelete(new List { new() { Id = "foreign" } }); - Assert.IsInstanceOfType(result); - _auctionServiceMock.Verify(s => s.DeleteBid(bid), Times.Once); + _productViewModelServiceMock.Verify( + s => s.DeleteBulkEdit(It.IsAny>()), Times.Never); } [TestMethod] - public async Task BidDelete_BidNotFound_ReturnsErrorsJson_NoOp() + public async Task BulkEditUpdate_ProductsWithEmptyIds_AreIgnoredWithoutServiceCall() { - var product = new Product { Id = "p1" }; - _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); - _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); - _auctionServiceMock.Setup(s => s.GetBid("missing")).ReturnsAsync((Bid)null); - var model = new ProductModel.BidModel { ProductId = "p1", BidId = "missing" }; - - var result = await _controller.BidDelete(model); + var result = await _controller.BulkEditUpdate(new List { new() { Id = "" } }); - var json = result as JsonResult; - Assert.IsNotNull(json); - var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; - Assert.IsNotNull(gridModel); - Assert.AreEqual("Bid not exists", gridModel.Errors); - _auctionServiceMock.Verify(s => s.DeleteBid(It.IsAny()), Times.Never); + Assert.IsInstanceOfType(result, typeof(JsonResult)); + _productServiceMock.Verify(p => p.GetProductsByIds(It.IsAny(), + It.IsAny()), Times.Never); + _productViewModelServiceMock.Verify( + s => s.UpdateBulkEdit(It.IsAny>()), Times.Never); } } diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductPictureControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductPictureControllerTests.cs new file mode 100644 index 000000000..74a940ccb --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductPictureControllerTests.cs @@ -0,0 +1,255 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Domain.Catalog; +using Grand.Domain.Media; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.AdminShared.Models.Orders; +using Grand.Web.Common.DataSource; +using Grand.Web.Common.Localization; +using Grand.Web.Common.Security.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using System.Linq.Expressions; + +namespace Grand.Web.Admin.Tests.Controllers; + +[TestClass] +public class BaseProductPictureControllerTests : ProductControllerTestsBase +{ + // BaseProductPictureController is abstract. This minimal subclass exists only so the actions under test + // can be invoked directly. + public class TestProductPictureController( + IProductViewModelService productViewModelService, + IProductService productService, + ILanguageService languageService, + IRequestTranslationService translationService, + IRequestPermissionService permissionService, + IAdminDataScope scope) + : BaseProductPictureController(productViewModelService, productService, languageService, translationService, permissionService, scope); + + protected override TestProductPictureController CreateController() => new( + _productViewModelServiceMock.Object, + _productServiceMock.Object, + _languageServiceMock.Object, + _translationServiceMock.Object, + _permissionServiceMock.Object, + _scopeMock.Object); + + // --- ProductPictureList -------------------------------------------------------------------------- + + [TestMethod] + public async Task ProductPictureList_ScopeDeniesAccess_ReturnsErrorJson() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); + + var result = await _controller.ProductPictureList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.PrepareProductPicturesModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductPictureList_ScopeGrantsAccess_ReturnsGrid() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); + _productViewModelServiceMock.Setup(s => s.PrepareProductPicturesModel(product)) + .ReturnsAsync(new List { new() { Id = "pic1" } }); + + var result = await _controller.ProductPictureList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(1, gridModel.Total); + } + + // --- ProductPicturePopup (GET) ------------------------------------------------------------------- + + [TestMethod] + public async Task ProductPicturePopupGet_ProductNotFound_ReturnsContent() + { + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync((Product)null); + + var result = await _controller.ProductPicturePopup("p1", "pic1"); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("Product not exist", content.Content); + _scopeMock.Verify(s => s.HasAccess(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductPicturePopupGet_ScopeDeniesAccess_ReturnsErrorJson() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ProductPicturePopup("p1", "pic1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + } + + [TestMethod] + public async Task ProductPicturePopupGet_ScopeGrantsAccess_PictureNotFound_ReturnsContent() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + + var result = await _controller.ProductPicturePopup("p1", "pic1"); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("Product picture not exist", content.Content); + } + + [TestMethod] + public async Task ProductPicturePopupGet_ScopeGrantsAccess_PictureFound_ReturnsView() + { + var pp = new ProductPicture { Id = "pic1" }; + var product = new Product { Id = "p1" }; + product.ProductPictures.Add(pp); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductPictureModel { Id = "pic1" }; + _productViewModelServiceMock.Setup(s => s.PrepareProductPictureModel(product, pp)) + .ReturnsAsync((model, (Picture)null)); + + var result = await _controller.ProductPicturePopup("p1", "pic1"); + + var view = result as ViewResult; + Assert.IsNotNull(view); + Assert.AreSame(model, view.Model); + } + + // --- ProductPicturePopup (POST) ------------------------------------------------------------------ + + [TestMethod] + public async Task ProductPicturePopupPost_ProductNotFound_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync((Product)null); + var model = new ProductModel.ProductPictureModel { ProductId = "p1", Id = "pic1" }; + + await Assert.ThrowsExactlyAsync(() => _controller.ProductPicturePopup(model)); + } + + [TestMethod] + public async Task ProductPicturePopupPost_ScopeDeniesAccess_Throws_DoesNotUpdate() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.ProductPictureModel { ProductId = "p1", Id = "pic1" }; + + // Regression guard: Vendor's original ProductPicturePopup(POST) had no access check at all, + // letting any vendor rename/re-alt-text another vendor's product picture by posting its + // productId/model.Id. + await Assert.ThrowsExactlyAsync(() => _controller.ProductPicturePopup(model)); + _productViewModelServiceMock.Verify(s => s.UpdateProductPicture(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductPicturePopupPost_ScopeGrantsAccess_PictureNotFound_Throws() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductPictureModel { ProductId = "p1", Id = "pic1" }; + + await Assert.ThrowsExactlyAsync(() => _controller.ProductPicturePopup(model)); + } + + [TestMethod] + public async Task ProductPicturePopupPost_ScopeGrantsAccess_ValidModel_Updates() + { + var pp = new ProductPicture { Id = "pic1" }; + var product = new Product { Id = "p1" }; + product.ProductPictures.Add(pp); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductPictureModel { ProductId = "p1", Id = "pic1" }; + + var result = await _controller.ProductPicturePopup(model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("", content.Content); + _productViewModelServiceMock.Verify(s => s.UpdateProductPicture(model), Times.Once); + } + + [TestMethod] + public async Task ProductPicturePopupPost_InvalidModelState_ReturnsView() + { + var model = new ProductModel.ProductPictureModel { ProductId = "p1", Id = "pic1" }; + _controller.ModelState.AddModelError("x", "error"); + + var result = await _controller.ProductPicturePopup(model); + + var view = result as ViewResult; + Assert.IsNotNull(view); + Assert.AreSame(model, view.Model); + _productServiceMock.Verify(p => p.GetProductById(It.IsAny(), It.IsAny()), Times.Never); + } + + // --- ProductPictureDelete ------------------------------------------------------------------------- + + [TestMethod] + public async Task ProductPictureDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.ProductPictureModel { ProductId = "p1", Id = "pic1" }; + + // Regression guard: Vendor's original ProductPictureDelete had no access check at all, letting + // any vendor delete another vendor's product picture by posting its productId/model.Id. + var result = await _controller.ProductPictureDelete(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.DeleteProductPicture(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductPictureDelete_ScopeGrantsAccess_ValidModel_Deletes() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductPictureModel { ProductId = "p1", Id = "pic1" }; + + var result = await _controller.ProductPictureDelete(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.DeleteProductPicture(model), Times.Once); + } + + [TestMethod] + public async Task ProductPictureDelete_ScopeGrantsAccess_InvalidModelState_ReturnsKendoGridError_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductPictureModel { ProductId = "p1", Id = "pic1" }; + _controller.ModelState.AddModelError("x", "error"); + + var result = await _controller.ProductPictureDelete(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.DeleteProductPicture(It.IsAny()), Times.Never); + } +} diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductPriceControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductPriceControllerTests.cs new file mode 100644 index 000000000..92808e9f4 --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductPriceControllerTests.cs @@ -0,0 +1,436 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.AdminShared.Models.Orders; +using Grand.Web.Common.DataSource; +using Grand.Web.Common.Localization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using System.Linq.Expressions; + +namespace Grand.Web.Admin.Tests.Controllers; + +[TestClass] +public class BaseProductPriceControllerTests : ProductControllerTestsBase +{ + // BaseProductPriceController is abstract. This minimal subclass exists only so the actions under test + // can be invoked directly. + public class TestProductPriceController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IDateTimeService dateTimeService, + IAdminDataScope scope) + : BaseProductPriceController(productViewModelService, productService, translationService, dateTimeService, scope); + + protected override TestProductPriceController CreateController() => new( + _productViewModelServiceMock.Object, + _productServiceMock.Object, + _translationServiceMock.Object, + _dateTimeServiceMock.Object, + _scopeMock.Object); + + // --- ProductPriceList ------------------------------------------------------------------------- + // HasAccess (strict), not CanView: mirrors Store's CanAccessProduct check on this action. Applying it + // uniformly also closes real gaps on the mutate actions below: Store's original checked access only on + // List/Insert (Update/Delete had no check at all), and Vendor's original checked access only on List + // (Insert/Update/Delete had no check at all). + + [TestMethod] + public async Task ProductPriceList_ScopeDeniesAccess_ReturnsErrorJson() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); + + var result = await _controller.ProductPriceList( + new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + } + + [TestMethod] + public async Task ProductPriceList_ScopeDeniesAccess_UsesScopeResourceKeyPrefix() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); + _scopeMock.Setup(s => s.ResourceKeyPrefix).Returns("Vendor"); + + var result = await _controller.ProductPriceList( + new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Vendor.Catalog.Products.Permissions"), Times.Once); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Never); + } + + [TestMethod] + public async Task ProductPriceList_ScopeGrantsAccess_ReturnsGrid() + { + var product = new Product { Id = "p1" }; + product.ProductPrices.Add(new ProductPrice { Id = "pp1", CurrencyCode = "EUR", Price = 9.99 }); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); + + var result = await _controller.ProductPriceList( + new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(1, gridModel.Total); + } + + // --- ProductPriceInsert ----------------------------------------------------------------------- + + [TestMethod] + public async Task ProductPriceInsert_ScopeDeniesAccess_ReturnsErrorJson_DoesNotInsert() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.ProductPriceModel { ProductId = "p1", CurrencyCode = "EUR", Price = 9.99 }; + + var result = await _controller.ProductPriceInsert(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productServiceMock.Verify(s => s.InsertProductPrice(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductPriceInsert_ScopeGrantsAccess_ValidModel_Inserts() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductPriceModel { ProductId = "p1", CurrencyCode = "EUR", Price = 9.99 }; + + var result = await _controller.ProductPriceInsert(model); + + Assert.IsInstanceOfType(result); + _productServiceMock.Verify(s => s.InsertProductPrice(It.Is( + pp => pp.ProductId == "p1" && pp.CurrencyCode == "EUR" && pp.Price == 9.99)), Times.Once); + } + + // --- ProductPriceUpdate ----------------------------------------------------------------------- + + [TestMethod] + public async Task ProductPriceUpdate_ScopeDeniesAccess_ReturnsErrorJson_DoesNotUpdate() + { + var product = new Product { Id = "p1" }; + product.ProductPrices.Add(new ProductPrice { Id = "pp1", CurrencyCode = "EUR", Price = 9.99 }); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.ProductPriceModel { Id = "pp1", ProductId = "p1", CurrencyCode = "USD", Price = 19.99 }; + + var result = await _controller.ProductPriceUpdate(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productServiceMock.Verify(s => s.UpdateProductPrice(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductPriceUpdate_ScopeGrantsAccess_ValidModel_Updates() + { + var product = new Product { Id = "p1" }; + product.ProductPrices.Add(new ProductPrice { Id = "pp1", CurrencyCode = "EUR", Price = 9.99 }); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductPriceModel { Id = "pp1", ProductId = "p1", CurrencyCode = "USD", Price = 19.99 }; + + var result = await _controller.ProductPriceUpdate(model); + + Assert.IsInstanceOfType(result); + _productServiceMock.Verify(s => s.UpdateProductPrice(It.Is( + pp => pp.Id == "pp1" && pp.CurrencyCode == "USD" && pp.Price == 19.99 && pp.ProductId == "p1")), Times.Once); + } + + // --- ProductPriceDelete ----------------------------------------------------------------------- + + [TestMethod] + public async Task ProductPriceDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + product.ProductPrices.Add(new ProductPrice { Id = "pp1", CurrencyCode = "EUR", Price = 9.99 }); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.ProductPriceModel { Id = "pp1", ProductId = "p1" }; + + var result = await _controller.ProductPriceDelete(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productServiceMock.Verify(s => s.DeleteProductPrice(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductPriceDelete_ScopeGrantsAccess_ValidModel_Deletes() + { + var product = new Product { Id = "p1" }; + product.ProductPrices.Add(new ProductPrice { Id = "pp1", CurrencyCode = "EUR", Price = 9.99 }); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.ProductPriceModel { Id = "pp1", ProductId = "p1" }; + + var result = await _controller.ProductPriceDelete(model); + + Assert.IsInstanceOfType(result); + _productServiceMock.Verify(s => s.DeleteProductPrice(It.Is( + pp => pp.Id == "pp1" && pp.ProductId == "p1")), Times.Once); + } + + // --- TierPriceList ---------------------------------------------------------------------------- + // HasAccess applied uniformly on every action in this region. Vendor's original checked ownership + // only on List and TierPriceEditPopup(GET); TierPriceCreatePopup(POST), TierPriceEditPopup(POST) and + // TierPriceDelete had NO ownership check at all, letting a vendor create/update/delete a tier price on + // any product by id. Store's original never checked TierPriceEditPopup(GET) either. + + [TestMethod] + public async Task TierPriceList_ScopeDeniesAccess_ReturnsErrorJson() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); + + var result = await _controller.TierPriceList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify( + s => s.PrepareTierPriceModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task TierPriceList_ScopeDeniesAccess_UsesScopeResourceKeyPrefix() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); + _scopeMock.Setup(s => s.ResourceKeyPrefix).Returns("Vendor"); + + var result = await _controller.TierPriceList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Vendor.Catalog.Products.Permissions"), Times.Once); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Never); + } + + [TestMethod] + public async Task TierPriceList_ScopeGrantsAccess_ReturnsGrid() + { + // PrepareTierPriceModel(product) now reads scope internally (Task 9). + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); + var tierPrices = new List { new() { Id = "tp1" } }; + _productViewModelServiceMock.Setup(s => s.PrepareTierPriceModel(product)).ReturnsAsync(tierPrices); + + var result = await _controller.TierPriceList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(1, gridModel.Total); + } + + // --- TierPriceCreatePopup (GET) ---------------------------------------------------------------- + + [TestMethod] + public async Task TierPriceCreatePopup_Get_PreparesModel() + { + // PrepareTierPriceModel(model) now reads scope internally (Task 9). + var result = await _controller.TierPriceCreatePopup("p1"); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify( + s => s.PrepareTierPriceModel(It.Is(m => m.ProductId == "p1")), + Times.Once); + } + + // --- TierPriceCreatePopup (POST) --------------------------------------------------------------- + + [TestMethod] + public async Task TierPriceCreatePopup_Post_ScopeDeniesAccess_DoesNotInsert() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.TierPriceModel { ProductId = "p1" }; + + var result = await _controller.TierPriceCreatePopup(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productServiceMock.Verify(s => s.InsertTierPrice(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task TierPriceCreatePopup_Post_ScopeGrantsAccess_ValidModel_Inserts() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.TierPriceModel { ProductId = "p1" }; + + var result = await _controller.TierPriceCreatePopup(model); + + Assert.IsInstanceOfType(result); + _productServiceMock.Verify( + s => s.InsertTierPrice(It.IsAny(), "p1"), Times.Once); + } + + [TestMethod] + public async Task TierPriceCreatePopup_Post_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); + var model = new ProductModel.TierPriceModel { ProductId = "missing" }; + + await Assert.ThrowsExactlyAsync(() => _controller.TierPriceCreatePopup(model)); + } + + // --- TierPriceEditPopup (GET) ------------------------------------------------------------------ + // HasAccess added here: Store's original had no ownership check on this GET action at all. + + [TestMethod] + public async Task TierPriceEditPopup_Get_ScopeDeniesAccess_ReturnsNotYourProductContent() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.TierPriceEditPopup("tp1", "p1"); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("This is not your product", content.Content); + _productViewModelServiceMock.Verify( + s => s.PrepareTierPriceModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task TierPriceEditPopup_Get_ScopeGrantsAccess_TierPriceMissing_ReturnsEmptyContent() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + + var result = await _controller.TierPriceEditPopup("missing-tp", "p1"); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("Empty tier price", content.Content); + } + + [TestMethod] + public async Task TierPriceEditPopup_Get_ScopeGrantsAccess_PreparesModel() + { + // PrepareTierPriceModel(model) now reads scope internally (Task 9). + var product = new Product { Id = "p1" }; + product.TierPrices.Add(new TierPrice { Id = "tp1" }); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + + var result = await _controller.TierPriceEditPopup("tp1", "p1"); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify( + s => s.PrepareTierPriceModel(It.Is(m => m.ProductId == "p1")), + Times.Once); + } + + [TestMethod] + public async Task TierPriceEditPopup_Get_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing", false)).ReturnsAsync((Product)null); + + await Assert.ThrowsExactlyAsync(() => _controller.TierPriceEditPopup("tp1", "missing")); + } + + // --- TierPriceEditPopup (POST) ----------------------------------------------------------------- + + [TestMethod] + public async Task TierPriceEditPopup_Post_ScopeDeniesAccess_DoesNotUpdate() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.TierPriceModel { Id = "tp1", ProductId = "p1" }; + + var result = await _controller.TierPriceEditPopup("p1", model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productServiceMock.Verify(s => s.UpdateTierPrice(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task TierPriceEditPopup_Post_ScopeGrantsAccess_ValidModel_Updates() + { + var product = new Product { Id = "p1" }; + product.TierPrices.Add(new TierPrice { Id = "tp1" }); + _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.TierPriceModel { Id = "tp1", ProductId = "p1" }; + + var result = await _controller.TierPriceEditPopup("p1", model); + + Assert.IsInstanceOfType(result); + _productServiceMock.Verify(s => s.UpdateTierPrice(It.IsAny(), "p1"), Times.Once); + } + + // --- TierPriceDelete ---------------------------------------------------------------------------- + + [TestMethod] + public async Task TierPriceDelete_ScopeDeniesAccess_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.TierPriceDeleteModel("tp1", "p1"); + + var result = await _controller.TierPriceDelete(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productServiceMock.Verify(s => s.DeleteTierPrice(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task TierPriceDelete_ScopeGrantsAccess_ValidModel_Deletes() + { + var product = new Product { Id = "p1" }; + product.TierPrices.Add(new TierPrice { Id = "tp1" }); + _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.TierPriceDeleteModel("tp1", "p1"); + + var result = await _controller.TierPriceDelete(model); + + Assert.IsInstanceOfType(result); + _productServiceMock.Verify(s => s.DeleteTierPrice(It.IsAny(), "p1"), Times.Once); + } + + [TestMethod] + public async Task TierPriceDelete_ScopeGrantsAccess_TierPriceMissing_Throws() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", true)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.TierPriceDeleteModel("missing-tp", "p1"); + + await Assert.ThrowsExactlyAsync(() => _controller.TierPriceDelete(model)); + } +} diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductRelatedControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductRelatedControllerTests.cs new file mode 100644 index 000000000..5d8269fd9 --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductRelatedControllerTests.cs @@ -0,0 +1,838 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.AdminShared.Models.Orders; +using Grand.Web.Common.DataSource; +using Grand.Web.Common.Localization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using System.Linq.Expressions; + +namespace Grand.Web.Admin.Tests.Controllers; + +[TestClass] +public class BaseProductRelatedControllerTests : ProductControllerTestsBase +{ + // BaseProductRelatedController is abstract. This minimal subclass exists only so the actions under test + // can be invoked directly. + public class TestProductRelatedController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseProductRelatedController(productViewModelService, productService, translationService, scope); + + protected override TestProductRelatedController CreateController() => new( + _productViewModelServiceMock.Object, + _productServiceMock.Object, + _translationServiceMock.Object, + _scopeMock.Object); + + // --- RelatedProductList ------------------------------------------------------------------------ + // HasAccess (strict), not CanView: same shape as ProductCategoryList/ProductCollectionList above. + + [TestMethod] + public async Task RelatedProductList_ScopeDeniesAccess_ReturnsErrorJson() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); + + var result = await _controller.RelatedProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + } + + [TestMethod] + public async Task RelatedProductList_ScopeGrantsAccess_ReturnsGrid() + { + var product = new Product { Id = "p1" }; + product.RelatedProducts.Add(new RelatedProduct { Id = "r1", ProductId2 = "p2", DisplayOrder = 0 }); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _productServiceMock.Setup(p => p.GetProductById("p2", false)).ReturnsAsync(new Product { Id = "p2", Name = "Second" }); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); + + var result = await _controller.RelatedProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(1, gridModel.Total); + } + + // --- RelatedProductUpdate ---------------------------------------------------------------------- + + [TestMethod] + public async Task RelatedProductUpdate_ScopeDeniesAccess_ReturnsErrorJson_DoesNotUpdate() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.RelatedProductModel { ProductId1 = "p1" }; + + var result = await _controller.RelatedProductUpdate(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.UpdateRelatedProductModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task RelatedProductUpdate_ScopeGrantsAccess_ValidModel_Updates() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.RelatedProductModel { ProductId1 = "p1" }; + + var result = await _controller.RelatedProductUpdate(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.UpdateRelatedProductModel(model), Times.Once); + } + + // --- RelatedProductDelete ---------------------------------------------------------------------- + + [TestMethod] + public async Task RelatedProductDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.RelatedProductModel { ProductId1 = "p1" }; + + var result = await _controller.RelatedProductDelete(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.DeleteRelatedProductModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task RelatedProductDelete_ScopeGrantsAccess_ValidModel_Deletes() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.RelatedProductModel { ProductId1 = "p1" }; + + var result = await _controller.RelatedProductDelete(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.DeleteRelatedProductModel(model), Times.Once); + } + + // --- RelatedProductAddPopup (GET) -------------------------------------------------------------- + + [TestMethod] + public async Task RelatedProductAddPopupGet_CallsPrepareRelatedProductModel() + { + // PrepareRelatedProductModel() now reads scope internally (Task 9). + _productViewModelServiceMock.Setup(s => s.PrepareRelatedProductModel()) + .ReturnsAsync(new ProductModel.AddRelatedProductModel()); + + var result = await _controller.RelatedProductAddPopup("p1") as ViewResult; + + Assert.IsNotNull(result); + var model = result.Model as ProductModel.AddRelatedProductModel; + Assert.IsNotNull(model); + Assert.AreEqual("p1", model.ProductId); + _productViewModelServiceMock.Verify(s => s.PrepareRelatedProductModel(), Times.Once); + } + + // --- RelatedProductAddPopupList ----------------------------------------------------------------- + + [TestMethod] + public async Task RelatedProductAddPopupList_UsesScopeDefaultStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + _productViewModelServiceMock + .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) + .ReturnsAsync((new List(), 0)); + + var model = new ProductModel.AddRelatedProductModel(); + var result = await _controller.RelatedProductAddPopupList( + new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); + + Assert.IsInstanceOfType(result); + Assert.AreEqual("store-1", model.SearchStoreId); + } + + [TestMethod] + public async Task RelatedProductAddPopupList_NoDefaultStoreId_DoesNotOverrideModelSearchStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + _productViewModelServiceMock + .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) + .ReturnsAsync((new List(), 0)); + + var model = new ProductModel.AddRelatedProductModel { SearchStoreId = "explicit" }; + var result = await _controller.RelatedProductAddPopupList( + new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); + + Assert.IsInstanceOfType(result); + Assert.AreEqual("explicit", model.SearchStoreId); + } + + // --- RelatedProductAddPopup (POST) -------------------------------------------------------------- + // HasAccess (strict): closes a real gap - Vendor's original RelatedProductAddPopup(POST) had no + // ownership check at all, letting any vendor attach related-product mappings onto another vendor's + // product by posting its id. + + [TestMethod] + public async Task RelatedProductAddPopupPost_ScopeDeniesAccess_ReturnsContentMessage_DoesNotInsert() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.AddRelatedProductModel { ProductId = "p1", SelectedProductIds = ["p2"] }; + + var result = await _controller.RelatedProductAddPopup(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.InsertRelatedProductModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task RelatedProductAddPopupPost_ScopeGrantsAccess_ValidModel_Inserts() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.AddRelatedProductModel { ProductId = "p1", SelectedProductIds = ["p2"] }; + + var result = await _controller.RelatedProductAddPopup(model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("", content.Content); + _productViewModelServiceMock.Verify(s => s.InsertRelatedProductModel(model), Times.Once); + } + + [TestMethod] + public async Task RelatedProductAddPopupPost_ScopeGrantsAccess_InvalidModel_ReturnsViewWithRepreparedModel() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var reprepared = new ProductModel.AddRelatedProductModel(); + _productViewModelServiceMock.Setup(s => s.PrepareRelatedProductModel()).ReturnsAsync(reprepared); + var model = new ProductModel.AddRelatedProductModel { ProductId = "p1" }; + _controller.ModelState.AddModelError("x", "error"); + + var result = await _controller.RelatedProductAddPopup(model); + + var view = result as ViewResult; + Assert.IsNotNull(view); + Assert.AreSame(reprepared, view.Model); + _productViewModelServiceMock.Verify(s => s.InsertRelatedProductModel(It.IsAny()), Times.Never); + } + + // --- SimilarProductList ------------------------------------------------------------------------ + // HasAccess (strict), not CanView: same shape as RelatedProductList above. + + [TestMethod] + public async Task SimilarProductList_ScopeDeniesAccess_ReturnsErrorJson() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); + + var result = await _controller.SimilarProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + } + + [TestMethod] + public async Task SimilarProductList_ScopeGrantsAccess_ReturnsGrid() + { + var product = new Product { Id = "p1" }; + product.SimilarProducts.Add(new SimilarProduct { Id = "r1", ProductId2 = "p2", DisplayOrder = 0 }); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _productServiceMock.Setup(p => p.GetProductById("p2", false)).ReturnsAsync(new Product { Id = "p2", Name = "Second" }); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); + + var result = await _controller.SimilarProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(1, gridModel.Total); + } + + // --- SimilarProductUpdate ---------------------------------------------------------------------- + + [TestMethod] + public async Task SimilarProductUpdate_ScopeDeniesAccess_ReturnsErrorJson_DoesNotUpdate() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.SimilarProductModel { ProductId1 = "p1" }; + + var result = await _controller.SimilarProductUpdate(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.UpdateSimilarProductModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task SimilarProductUpdate_ScopeGrantsAccess_ValidModel_Updates() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.SimilarProductModel { ProductId1 = "p1" }; + + var result = await _controller.SimilarProductUpdate(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.UpdateSimilarProductModel(model), Times.Once); + } + + // --- SimilarProductDelete ---------------------------------------------------------------------- + + [TestMethod] + public async Task SimilarProductDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.SimilarProductModel { ProductId1 = "p1" }; + + var result = await _controller.SimilarProductDelete(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.DeleteSimilarProductModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task SimilarProductDelete_ScopeGrantsAccess_ValidModel_Deletes() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.SimilarProductModel { ProductId1 = "p1" }; + + var result = await _controller.SimilarProductDelete(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.DeleteSimilarProductModel(model), Times.Once); + } + + // --- SimilarProductAddPopup (GET) -------------------------------------------------------------- + + [TestMethod] + public async Task SimilarProductAddPopupGet_CallsPrepareSimilarProductModel() + { + // PrepareSimilarProductModel() now reads scope internally (Task 9). + _productViewModelServiceMock.Setup(s => s.PrepareSimilarProductModel()) + .ReturnsAsync(new ProductModel.AddSimilarProductModel()); + + var result = await _controller.SimilarProductAddPopup("p1") as ViewResult; + + Assert.IsNotNull(result); + var model = result.Model as ProductModel.AddSimilarProductModel; + Assert.IsNotNull(model); + Assert.AreEqual("p1", model.ProductId); + _productViewModelServiceMock.Verify(s => s.PrepareSimilarProductModel(), Times.Once); + } + + // --- SimilarProductAddPopupList ----------------------------------------------------------------- + + [TestMethod] + public async Task SimilarProductAddPopupList_UsesScopeDefaultStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + _productViewModelServiceMock + .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) + .ReturnsAsync((new List(), 0)); + + var model = new ProductModel.AddSimilarProductModel(); + var result = await _controller.SimilarProductAddPopupList( + new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); + + Assert.IsInstanceOfType(result); + Assert.AreEqual("store-1", model.SearchStoreId); + } + + [TestMethod] + public async Task SimilarProductAddPopupList_NoDefaultStoreId_DoesNotOverrideModelSearchStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + _productViewModelServiceMock + .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) + .ReturnsAsync((new List(), 0)); + + var model = new ProductModel.AddSimilarProductModel { SearchStoreId = "explicit" }; + var result = await _controller.SimilarProductAddPopupList( + new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); + + Assert.IsInstanceOfType(result); + Assert.AreEqual("explicit", model.SearchStoreId); + } + + // --- SimilarProductAddPopup (POST) -------------------------------------------------------------- + // HasAccess (strict): closes a real gap - Vendor's original SimilarProductAddPopup(POST) had no + // ownership check at all, letting any vendor attach similar-product mappings onto another vendor's + // product by posting its id. + + [TestMethod] + public async Task SimilarProductAddPopupPost_ScopeDeniesAccess_ReturnsContentMessage_DoesNotInsert() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.AddSimilarProductModel { ProductId = "p1", SelectedProductIds = ["p2"] }; + + var result = await _controller.SimilarProductAddPopup(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.InsertSimilarProductModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task SimilarProductAddPopupPost_ScopeGrantsAccess_ValidModel_Inserts() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.AddSimilarProductModel { ProductId = "p1", SelectedProductIds = ["p2"] }; + + var result = await _controller.SimilarProductAddPopup(model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("", content.Content); + _productViewModelServiceMock.Verify(s => s.InsertSimilarProductModel(model), Times.Once); + } + + [TestMethod] + public async Task SimilarProductAddPopupPost_ScopeGrantsAccess_InvalidModel_ReturnsViewWithRepreparedModel() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var reprepared = new ProductModel.AddSimilarProductModel(); + _productViewModelServiceMock.Setup(s => s.PrepareSimilarProductModel()).ReturnsAsync(reprepared); + var model = new ProductModel.AddSimilarProductModel { ProductId = "p1" }; + _controller.ModelState.AddModelError("x", "error"); + + var result = await _controller.SimilarProductAddPopup(model); + + var view = result as ViewResult; + Assert.IsNotNull(view); + Assert.AreSame(reprepared, view.Model); + _productViewModelServiceMock.Verify(s => s.InsertSimilarProductModel(It.IsAny()), Times.Never); + } + + // --- CrossSellProductList --------------------------------------------------------------------- + // HasAccess (strict), not CanView: same shape as RelatedProductList/BundleProductList above. Admin's + // original CrossSellProductList had no check at all. + + [TestMethod] + public async Task CrossSellProductList_ScopeDeniesAccess_ReturnsErrorJson() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); + + var result = await _controller.CrossSellProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + } + + [TestMethod] + public async Task CrossSellProductList_ScopeGrantsAccess_ReturnsGrid() + { + var product = new Product { Id = "p1" }; + product.CrossSellProduct.Add("p2"); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _productServiceMock.Setup(p => p.GetProductById("p2", false)).ReturnsAsync(new Product { Id = "p2", Name = "Second" }); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); + + var result = await _controller.CrossSellProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(1, gridModel.Total); + } + + // --- CrossSellProductDelete ------------------------------------------------------------------- + // Admin/Store both throw ArgumentException when the product does not exist; Vendor's original + // CrossSellProductDelete had no ownership check at all - closed here the same way as List above. + + [TestMethod] + public async Task CrossSellProductDelete_ProductNotFound_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync((Product)null); + var model = new ProductModel.CrossSellProductModel { ProductId = "p1", Id = "p2" }; + + await Assert.ThrowsExactlyAsync(() => _controller.CrossSellProductDelete(model)); + } + + [TestMethod] + public async Task CrossSellProductDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + product.CrossSellProduct.Add("p2"); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.CrossSellProductModel { ProductId = "p1", Id = "p2" }; + + var result = await _controller.CrossSellProductDelete(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.DeleteCrossSellProduct(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task CrossSellProductDelete_NoMatchingCrossSellProduct_Throws() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.CrossSellProductModel { ProductId = "p1", Id = "p2" }; + + await Assert.ThrowsExactlyAsync(() => _controller.CrossSellProductDelete(model)); + } + + [TestMethod] + public async Task CrossSellProductDelete_ScopeGrantsAccess_ValidModel_Deletes() + { + var product = new Product { Id = "p1" }; + product.CrossSellProduct.Add("p2"); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.CrossSellProductModel { ProductId = "p1", Id = "p2" }; + + var result = await _controller.CrossSellProductDelete(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.DeleteCrossSellProduct("p1", "p2"), Times.Once); + } + + // --- CrossSellProductAddPopup (GET) ------------------------------------------------------------- + + [TestMethod] + public async Task CrossSellProductAddPopupGet_CallsPrepareCrossSellProductModel() + { + // PrepareCrossSellProductModel() now reads scope internally (Task 9). + _productViewModelServiceMock.Setup(s => s.PrepareCrossSellProductModel()) + .ReturnsAsync(new ProductModel.AddCrossSellProductModel()); + + var result = await _controller.CrossSellProductAddPopup("p1") as ViewResult; + + Assert.IsNotNull(result); + var model = result.Model as ProductModel.AddCrossSellProductModel; + Assert.IsNotNull(model); + Assert.AreEqual("p1", model.ProductId); + _productViewModelServiceMock.Verify(s => s.PrepareCrossSellProductModel(), Times.Once); + } + + // --- CrossSellProductAddPopupList --------------------------------------------------------------- + + [TestMethod] + public async Task CrossSellProductAddPopupList_UsesScopeDefaultStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + _productViewModelServiceMock + .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) + .ReturnsAsync((new List(), 0)); + + var model = new ProductModel.AddCrossSellProductModel(); + var result = await _controller.CrossSellProductAddPopupList( + new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); + + Assert.IsInstanceOfType(result); + Assert.AreEqual("store-1", model.SearchStoreId); + } + + [TestMethod] + public async Task CrossSellProductAddPopupList_NoDefaultStoreId_DoesNotOverrideModelSearchStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + _productViewModelServiceMock + .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) + .ReturnsAsync((new List(), 0)); + + var model = new ProductModel.AddCrossSellProductModel { SearchStoreId = "explicit" }; + var result = await _controller.CrossSellProductAddPopupList( + new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); + + Assert.IsInstanceOfType(result); + Assert.AreEqual("explicit", model.SearchStoreId); + } + + // --- CrossSellProductAddPopup (POST) -------------------------------------------------------------- + // HasAccess (strict): closes a real gap - Vendor's original CrossSellProductAddPopup(POST) had no + // ownership check at all, letting any vendor attach cross-sell-product mappings onto another + // vendor's product by posting its id. + + [TestMethod] + public async Task CrossSellProductAddPopupPost_ScopeDeniesAccess_ReturnsContentMessage_DoesNotInsert() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.AddCrossSellProductModel { ProductId = "p1", SelectedProductIds = ["p2"] }; + + var result = await _controller.CrossSellProductAddPopup(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.InsertCrossSellProductModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task CrossSellProductAddPopupPost_ScopeGrantsAccess_ValidModel_Inserts() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.AddCrossSellProductModel { ProductId = "p1", SelectedProductIds = ["p2"] }; + + var result = await _controller.CrossSellProductAddPopup(model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("", content.Content); + _productViewModelServiceMock.Verify(s => s.InsertCrossSellProductModel(model), Times.Once); + } + + [TestMethod] + public async Task CrossSellProductAddPopupPost_ScopeGrantsAccess_InvalidModel_ReturnsViewWithRepreparedModel() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var reprepared = new ProductModel.AddCrossSellProductModel(); + _productViewModelServiceMock.Setup(s => s.PrepareCrossSellProductModel()).ReturnsAsync(reprepared); + var model = new ProductModel.AddCrossSellProductModel { ProductId = "p1" }; + _controller.ModelState.AddModelError("x", "error"); + + var result = await _controller.CrossSellProductAddPopup(model); + + var view = result as ViewResult; + Assert.IsNotNull(view); + Assert.AreSame(reprepared, view.Model); + _productViewModelServiceMock.Verify(s => s.InsertCrossSellProductModel(It.IsAny()), Times.Never); + } + + // --- RecommendedProductList --------------------------------------------------------------------- + // HasAccess (strict), not CanView: same shape as CrossSellProductList above. Admin's original + // RecommendedProductList had no check at all. Vendor's original signature also dropped the + // DataSourceRequest command parameter entirely - kept here for parity with Admin/Store. + + [TestMethod] + public async Task RecommendedProductList_ScopeDeniesAccess_ReturnsErrorJson() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); + + var result = await _controller.RecommendedProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + } + + [TestMethod] + public async Task RecommendedProductList_ScopeGrantsAccess_ReturnsGrid() + { + var product = new Product { Id = "p1" }; + product.RecommendedProduct.Add("p2"); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _productServiceMock.Setup(p => p.GetProductById("p2", false)).ReturnsAsync(new Product { Id = "p2", Name = "Second" }); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); + + var result = await _controller.RecommendedProductList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(1, gridModel.Total); + } + + // --- RecommendedProductDelete ------------------------------------------------------------------- + // Admin/Store/Vendor all throw ArgumentException when the product does not exist; Vendor's original + // RecommendedProductDelete had no ownership check at all - closed here the same way as List above. + + [TestMethod] + public async Task RecommendedProductDelete_ProductNotFound_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync((Product)null); + var model = new ProductModel.RecommendedProductModel { ProductId = "p1", Id = "p2" }; + + await Assert.ThrowsExactlyAsync(() => _controller.RecommendedProductDelete(model)); + } + + [TestMethod] + public async Task RecommendedProductDelete_ScopeDeniesAccess_ReturnsErrorJson_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + product.RecommendedProduct.Add("p2"); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.RecommendedProductModel { ProductId = "p1", Id = "p2" }; + + var result = await _controller.RecommendedProductDelete(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.DeleteRecommendedProduct(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task RecommendedProductDelete_NoMatchingRecommendedProduct_Throws() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.RecommendedProductModel { ProductId = "p1", Id = "p2" }; + + await Assert.ThrowsExactlyAsync(() => _controller.RecommendedProductDelete(model)); + } + + [TestMethod] + public async Task RecommendedProductDelete_ScopeGrantsAccess_ValidModel_Deletes() + { + var product = new Product { Id = "p1" }; + product.RecommendedProduct.Add("p2"); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.RecommendedProductModel { ProductId = "p1", Id = "p2" }; + + var result = await _controller.RecommendedProductDelete(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.DeleteRecommendedProduct("p1", "p2"), Times.Once); + } + + // --- RecommendedProductAddPopup (GET) ------------------------------------------------------------- + + [TestMethod] + public async Task RecommendedProductAddPopupGet_CallsPrepareRecommendedProductModel() + { + // PrepareRecommendedProductModel() now reads scope internally (Task 9). + _productViewModelServiceMock.Setup(s => s.PrepareRecommendedProductModel()) + .ReturnsAsync(new ProductModel.AddRecommendedProductModel()); + + var result = await _controller.RecommendedProductAddPopup("p1") as ViewResult; + + Assert.IsNotNull(result); + var model = result.Model as ProductModel.AddRecommendedProductModel; + Assert.IsNotNull(model); + Assert.AreEqual("p1", model.ProductId); + _productViewModelServiceMock.Verify(s => s.PrepareRecommendedProductModel(), Times.Once); + } + + // --- RecommendedProductAddPopupList --------------------------------------------------------------- + + [TestMethod] + public async Task RecommendedProductAddPopupList_UsesScopeDefaultStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + _productViewModelServiceMock + .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) + .ReturnsAsync((new List(), 0)); + + var model = new ProductModel.AddRecommendedProductModel(); + var result = await _controller.RecommendedProductAddPopupList( + new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); + + Assert.IsInstanceOfType(result); + Assert.AreEqual("store-1", model.SearchStoreId); + } + + [TestMethod] + public async Task RecommendedProductAddPopupList_NoDefaultStoreId_DoesNotOverrideModelSearchStoreId() + { + _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + _productViewModelServiceMock + .Setup(s => s.PrepareProductModel(It.IsAny(), 0, 10)) + .ReturnsAsync((new List(), 0)); + + var model = new ProductModel.AddRecommendedProductModel { SearchStoreId = "explicit" }; + var result = await _controller.RecommendedProductAddPopupList( + new Grand.Web.Common.DataSource.DataSourceRequest { Page = 0, PageSize = 10 }, model); + + Assert.IsInstanceOfType(result); + Assert.AreEqual("explicit", model.SearchStoreId); + } + + // --- RecommendedProductAddPopup (POST) -------------------------------------------------------------- + // HasAccess (strict): closes a real gap - Vendor's original RecommendedProductAddPopup(POST) had no + // ownership check at all, letting any vendor attach recommended-product mappings onto another + // vendor's product by posting its id. + + [TestMethod] + public async Task RecommendedProductAddPopupPost_ScopeDeniesAccess_ReturnsContentMessage_DoesNotInsert() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.AddRecommendedProductModel { ProductId = "p1", SelectedProductIds = ["p2"] }; + + var result = await _controller.RecommendedProductAddPopup(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productViewModelServiceMock.Verify(s => s.InsertRecommendedProductModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task RecommendedProductAddPopupPost_ScopeGrantsAccess_ValidModel_Inserts() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.AddRecommendedProductModel { ProductId = "p1", SelectedProductIds = ["p2"] }; + + var result = await _controller.RecommendedProductAddPopup(model); + + var content = result as ContentResult; + Assert.IsNotNull(content); + Assert.AreEqual("", content.Content); + _productViewModelServiceMock.Verify(s => s.InsertRecommendedProductModel(model), Times.Once); + } + + [TestMethod] + public async Task RecommendedProductAddPopupPost_ScopeGrantsAccess_InvalidModel_ReturnsViewWithRepreparedModel() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var reprepared = new ProductModel.AddRecommendedProductModel(); + _productViewModelServiceMock.Setup(s => s.PrepareRecommendedProductModel()).ReturnsAsync(reprepared); + var model = new ProductModel.AddRecommendedProductModel { ProductId = "p1" }; + _controller.ModelState.AddModelError("x", "error"); + + var result = await _controller.RecommendedProductAddPopup(model); + + var view = result as ViewResult; + Assert.IsNotNull(view); + Assert.AreSame(reprepared, view.Model); + _productViewModelServiceMock.Verify(s => s.InsertRecommendedProductModel(It.IsAny()), Times.Never); + } +} diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductReservationControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductReservationControllerTests.cs new file mode 100644 index 000000000..ee5d6ab24 --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductReservationControllerTests.cs @@ -0,0 +1,377 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.AdminShared.Models.Orders; +using Grand.Web.Common.DataSource; +using Grand.Web.Common.Localization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using System.Linq.Expressions; + +namespace Grand.Web.Admin.Tests.Controllers; + +[TestClass] +public class BaseProductReservationControllerTests : ProductControllerTestsBase +{ + // BaseProductReservationController is abstract. This minimal subclass exists only so the actions under test + // can be invoked directly. + public class TestProductReservationController( + IProductService productService, + IRequestTranslationService translationService, + IProductReservationService productReservationService, + IEnumTranslationService enumTranslationService, + IAdminDataScope scope) + : BaseProductReservationController(productService, translationService, productReservationService, enumTranslationService, scope); + + protected override TestProductReservationController CreateController() => new( + _productServiceMock.Object, + _translationServiceMock.Object, + _productReservationServiceMock.Object, + _enumTranslationServiceMock.Object, + _scopeMock.Object); + + private static ProductReservation NewReservation(string id, string productId, string orderId = "") => + new() { Id = id, ProductId = productId, OrderId = orderId, Date = DateTime.UtcNow }; + + // --- ListReservations ---------------------------------------------------------------------------- + + [TestMethod] + public async Task ListReservations_ScopeDeniesAccess_ReturnsKendoGridError_DoesNotQuery() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); + + var result = await _controller.ListReservations( + new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productReservationServiceMock.Verify( + s => s.GetProductReservationsByProductId(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ListReservations_ScopeGrantsAccess_ReturnsGrid() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var reservations = new PagedList( + new List { NewReservation("r1", "p1") }, 0, 10); + _productReservationServiceMock + .Setup(s => s.GetProductReservationsByProductId("p1", null, null, 0, 10)) + .ReturnsAsync(reservations); + + var result = await _controller.ListReservations( + new Grand.Web.Common.DataSource.DataSourceRequest { Page = 1, PageSize = 10 }, "p1"); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(1, gridModel.Total); + } + + // A product shared with other stores opens read-only on Store: the tab loads, but the + // reservations (other stores' orders) are not listed. + [TestMethod] + public async Task ListReservations_ViewableButNotOwned_ReturnsEmptyGrid_DoesNotQuery() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ListReservations( + new Grand.Web.Common.DataSource.DataSourceRequest { Page = 1, PageSize = 10 }, "p1"); + + var gridModel = (result as JsonResult)?.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.AreEqual(0, gridModel.Total); + _translationServiceMock.Verify(t => t.GetResource(It.IsAny()), Times.Never); + _productReservationServiceMock.Verify( + s => s.GetProductReservationsByProductId(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + } + + // --- GenerateCalendar ------------------------------------------------------------------------------ + + private static ProductModel.GenerateCalendarModel ValidCalendarModel(IntervalUnit unit = IntervalUnit.Day) => new() { + StartDate = new DateTime(2026, 1, 1), + EndDate = new DateTime(2026, 1, 1), + StartTime = new DateTime(2026, 1, 1, 8, 0, 0), + EndTime = new DateTime(2026, 1, 1, 18, 0, 0), + Interval = 1, + IntervalUnit = (int)unit, + Quantity = 1, + Resource = "room1", + // 2026-01-01 is a Thursday. + Thursday = true + }; + + [TestMethod] + public async Task GenerateCalendar_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing")).ReturnsAsync((Product)null); + + await Assert.ThrowsExactlyAsync( + () => _controller.GenerateCalendar("missing", ValidCalendarModel())); + } + + [TestMethod] + public async Task GenerateCalendar_ScopeDeniesAccess_ReturnsErrorsJson_DoesNotUpdate() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.GenerateCalendar("p1", ValidCalendarModel()); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productReservationServiceMock.Verify( + s => s.InsertProductReservation(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task GenerateCalendar_InvalidModelState_ReturnsErrorsJson_DoesNotInsert() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _productReservationServiceMock + .Setup(s => s.GetProductReservationsByProductId("p1", null, null)) + .ReturnsAsync(new PagedList(new List(), 0, int.MaxValue)); + _controller.ModelState.AddModelError("Resource", "Required"); + + var result = await _controller.GenerateCalendar("p1", ValidCalendarModel()); + + Assert.IsInstanceOfType(result); + _productReservationServiceMock.Verify( + s => s.InsertProductReservation(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task GenerateCalendar_ScopeGrantsAccess_Valid_InsertsReservationAndUpdatesProductFields() + { + var product = new Product { Id = "p1", IntervalUnitId = IntervalUnit.Day }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _productReservationServiceMock + .Setup(s => s.GetProductReservationsByProductId("p1", null, null)) + .ReturnsAsync(new PagedList(new List(), 0, int.MaxValue)); + + var result = await _controller.GenerateCalendar("p1", ValidCalendarModel()); + + Assert.IsInstanceOfType(result); + _productReservationServiceMock.Verify( + s => s.InsertProductReservation(It.Is(r => + r.ProductId == "p1" && r.Resource == "room1")), Times.Once); + _productServiceMock.Verify( + s => s.UpdateProductField(product, It.IsAny>>(), 1), Times.Once); + } + + // --- ClearCalendar ---------------------------------------------------------------------------------- + + [TestMethod] + public async Task ClearCalendar_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing")).ReturnsAsync((Product)null); + + await Assert.ThrowsExactlyAsync(() => _controller.ClearCalendar("missing")); + } + + [TestMethod] + public async Task ClearCalendar_ScopeDeniesAccess_Throws_DoesNotDelete() + { + // Throws rather than returning Json({errors}): CreateOrUpdate.Calendar.cshtml's success + // callback for this action never reads the response body (no else branch) on any of the three + // hosts, so a 200 response would silently refresh the grid as if the clear succeeded. + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + await Assert.ThrowsExactlyAsync(() => _controller.ClearCalendar("p1")); + + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productReservationServiceMock.Verify( + s => s.DeleteProductReservation(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ClearCalendar_ScopeGrantsAccess_DeletesAllReservations() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var reservation = NewReservation("r1", "p1"); + _productReservationServiceMock + .Setup(s => s.GetProductReservationsByProductId("p1", true, null)) + .ReturnsAsync(new PagedList(new List { reservation }, 0, int.MaxValue)); + + var result = await _controller.ClearCalendar("p1"); + + Assert.IsInstanceOfType(result); + _productReservationServiceMock.Verify(s => s.DeleteProductReservation(reservation), Times.Once); + } + + // --- ClearOld --------------------------------------------------------------------------------------- + + [TestMethod] + public async Task ClearOld_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing")).ReturnsAsync((Product)null); + + await Assert.ThrowsExactlyAsync(() => _controller.ClearOld("missing")); + } + + [TestMethod] + public async Task ClearOld_ScopeDeniesAccess_Throws_DoesNotDelete() + { + // See the comment on ClearCalendar_ScopeDeniesAccess_Throws_DoesNotDelete above - same view-layer + // gap applies to this action's response handling. + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + await Assert.ThrowsExactlyAsync(() => _controller.ClearOld("p1")); + + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productReservationServiceMock.Verify( + s => s.DeleteProductReservation(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ClearOld_ScopeGrantsAccess_DeletesOnlyPastReservations() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var oldReservation = new ProductReservation { Id = "old", ProductId = "p1", Date = DateTime.UtcNow.AddDays(-1) }; + var futureReservation = new ProductReservation { Id = "future", ProductId = "p1", Date = DateTime.UtcNow.AddDays(1) }; + _productReservationServiceMock + .Setup(s => s.GetProductReservationsByProductId("p1", true, null)) + .ReturnsAsync(new PagedList( + new List { oldReservation, futureReservation }, 0, int.MaxValue)); + + var result = await _controller.ClearOld("p1"); + + Assert.IsInstanceOfType(result); + _productReservationServiceMock.Verify(s => s.DeleteProductReservation(oldReservation), Times.Once); + _productReservationServiceMock.Verify(s => s.DeleteProductReservation(futureReservation), Times.Never); + } + + // --- ProductReservationDelete ------------------------------------------------------------------------ + + [TestMethod] + public async Task ProductReservationDelete_MissingProduct_Throws() + { + _productServiceMock.Setup(p => p.GetProductById("missing")).ReturnsAsync((Product)null); + var model = new ProductModel.ReservationModel { ProductId = "missing", ReservationId = "r1" }; + + await Assert.ThrowsExactlyAsync(() => _controller.ProductReservationDelete(model)); + } + + [TestMethod] + public async Task ProductReservationDelete_ScopeDeniesAccess_ReturnsKendoGridError_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.ReservationModel { ProductId = "p1", ReservationId = "r1" }; + + var result = await _controller.ProductReservationDelete(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productReservationServiceMock.Verify( + s => s.DeleteProductReservation(It.IsAny()), Times.Never); + } + + // Regression guard for a real cross-product IDOR found in Store's and Vendor's original code: the + // access check ran against model.ProductId while the delete ran against model.ReservationId, with no + // check that the two referred to the same product. A caller with access to *any* product could pass + // that product's id for the access check while supplying the ReservationId of a reservation belonging + // to a different, unowned product, deleting it. + [TestMethod] + public async Task ProductReservationDelete_ReservationBelongsToDifferentProduct_ReturnsKendoGridError_DoesNotDelete() + { + var ownedProduct = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(ownedProduct); + _scopeMock.Setup(s => s.HasAccess(ownedProduct)).ReturnsAsync(true); + var foreignReservation = NewReservation("r1", "p2"); + _productReservationServiceMock.Setup(s => s.GetProductReservation("r1")).ReturnsAsync(foreignReservation); + var model = new ProductModel.ReservationModel { ProductId = "p1", ReservationId = "r1" }; + + var result = await _controller.ProductReservationDelete(model); + + Assert.IsInstanceOfType(result); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.Products.Permissions"), Times.Once); + _productReservationServiceMock.Verify( + s => s.DeleteProductReservation(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductReservationDelete_ReservationHasOrder_ReturnsErrorsJson_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var reservation = NewReservation("r1", "p1", "order1"); + _productReservationServiceMock.Setup(s => s.GetProductReservation("r1")).ReturnsAsync(reservation); + var model = new ProductModel.ReservationModel { ProductId = "p1", ReservationId = "r1" }; + + var result = await _controller.ProductReservationDelete(model); + + var json = result as JsonResult; + Assert.IsNotNull(json); + var gridModel = json.Value as Grand.Web.Common.DataSource.DataSourceResult; + Assert.IsNotNull(gridModel); + Assert.IsNotNull(gridModel.Errors); + _translationServiceMock.Verify(t => t.GetResource("Admin.Catalog.ProductReservations.CantDeleteWithOrder"), Times.Once); + _productReservationServiceMock.Verify( + s => s.DeleteProductReservation(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductReservationDelete_ScopeGrantsAccess_SameProduct_Deletes() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var reservation = NewReservation("r1", "p1"); + _productReservationServiceMock.Setup(s => s.GetProductReservation("r1")).ReturnsAsync(reservation); + var model = new ProductModel.ReservationModel { ProductId = "p1", ReservationId = "r1" }; + + var result = await _controller.ProductReservationDelete(model); + + Assert.IsInstanceOfType(result); + _productReservationServiceMock.Verify(s => s.DeleteProductReservation(reservation), Times.Once); + } + + [TestMethod] + public async Task ProductReservationDelete_ReservationNotFound_ReturnsEmptyJson_NoOp() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1")).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + _productReservationServiceMock.Setup(s => s.GetProductReservation("missing")).ReturnsAsync((ProductReservation)null); + var model = new ProductModel.ReservationModel { ProductId = "p1", ReservationId = "missing" }; + + var result = await _controller.ProductReservationDelete(model); + + Assert.IsInstanceOfType(result); + _productReservationServiceMock.Verify( + s => s.DeleteProductReservation(It.IsAny()), Times.Never); + } +} diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductSpecificationControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductSpecificationControllerTests.cs new file mode 100644 index 000000000..6bc7d0311 --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseProductSpecificationControllerTests.cs @@ -0,0 +1,282 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.AdminShared.Models.Orders; +using Grand.Web.Common.DataSource; +using Grand.Web.Common.Localization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using System.Linq.Expressions; + +namespace Grand.Web.Admin.Tests.Controllers; + +[TestClass] +public class BaseProductSpecificationControllerTests : ProductControllerTestsBase +{ + // BaseProductSpecificationController is abstract. This minimal subclass exists only so the actions under test + // can be invoked directly. + public class TestProductSpecificationController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseProductSpecificationController(productViewModelService, productService, translationService, scope); + + protected override TestProductSpecificationController CreateController() => new( + _productViewModelServiceMock.Object, + _productServiceMock.Object, + _translationServiceMock.Object, + _scopeMock.Object); + + // --- ProductSpecAttrList -------------------------------------------------------------------------- + + [TestMethod] + public async Task ProductSpecAttrList_ScopeDeniesAccess_ReturnsErrorJson() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(false); + + var result = await _controller.ProductSpecAttrList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.PrepareProductSpecificationAttributeModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductSpecAttrList_ScopeGrantsAccess_ReturnsGrid() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.CanView(product)).ReturnsAsync(true); + _productViewModelServiceMock.Setup(s => s.PrepareProductSpecificationAttributeModel(product)) + .ReturnsAsync(new List { new() { Id = "psa1" } }); + + var result = await _controller.ProductSpecAttrList(new Grand.Web.Common.DataSource.DataSourceRequest(), "p1"); + + Assert.IsInstanceOfType(result); + var json = (JsonResult)result; + var grid = (Grand.Web.Common.DataSource.DataSourceResult)json.Value; + Assert.AreEqual(1, grid.Total); + } + + // --- ProductSpecAttrPopup (GET) ----------------------------------------------------------------- + + [TestMethod] + public async Task ProductSpecAttrPopupGet_ScopeDeniesAccess_ReturnsContent() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + + var result = await _controller.ProductSpecAttrPopup(new Mock().Object, "p1", "psa1"); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify( + s => s.PrepareProductSpecificationAttributeModel(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductSpecAttrPopupGet_ScopeGrantsAccess_NewAttribute_ReturnsView() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var specAttrServiceMock = new Mock(); + specAttrServiceMock.Setup(s => s.GetSpecificationAttributes(It.IsAny(), 0, int.MaxValue)) + .ReturnsAsync(new PagedList(new List(), 0, int.MaxValue)); + + var result = await _controller.ProductSpecAttrPopup(specAttrServiceMock.Object, "p1", ""); + + var view = result as ViewResult; + Assert.IsNotNull(view); + var model = view.Model as ProductModel.AddProductSpecificationAttributeModel; + Assert.IsNotNull(model); + Assert.IsTrue(model.ShowOnProductPage); + } + + [TestMethod] + public async Task ProductSpecAttrPopupGet_ScopeGrantsAccess_ExistingAttribute_ReturnsPopulatedView() + { + var psa = new ProductSpecificationAttribute { Id = "psa1", SpecificationAttributeOptionId = "opt1" }; + var product = new Product { Id = "p1" }; + product.ProductSpecificationAttributes.Add(psa); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var specAttrServiceMock = new Mock(); + specAttrServiceMock.Setup(s => s.GetSpecificationAttributes(It.IsAny(), 0, int.MaxValue)) + .ReturnsAsync(new PagedList(new List(), 0, int.MaxValue)); + + var result = await _controller.ProductSpecAttrPopup(specAttrServiceMock.Object, "p1", "psa1"); + + var view = result as ViewResult; + Assert.IsNotNull(view); + var model = view.Model as ProductModel.AddProductSpecificationAttributeModel; + Assert.IsNotNull(model); + Assert.AreEqual("psa1", model.Id); + } + + // --- ProductSpecAttrPopup (POST) ---------------------------------------------------------------- + + [TestMethod] + public async Task ProductSpecAttrPopupPost_ProductNotFound_ReturnsContent() + { + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync((Product)null); + var model = new ProductModel.AddProductSpecificationAttributeModel { ProductId = "p1", Id = "psa1" }; + + var result = await _controller.ProductSpecAttrPopup(new Mock().Object, model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify( + s => s.UpdateProductSpecificationAttributeModel(It.IsAny(), + It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductSpecAttrPopupPost_ScopeDeniesAccess_ReturnsContent_DoesNotInsertOrUpdate() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductModel.AddProductSpecificationAttributeModel { ProductId = "p1", Id = "psa1" }; + + // Regression guard: Vendor's original ProductSpecAttrPopup(POST) had no access check at all, + // letting any vendor add/edit specification attributes on another vendor's product by posting + // its id. + var result = await _controller.ProductSpecAttrPopup(new Mock().Object, model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify( + s => s.InsertProductSpecificationAttributeModel(It.IsAny(), + It.IsAny()), Times.Never); + _productViewModelServiceMock.Verify( + s => s.UpdateProductSpecificationAttributeModel(It.IsAny(), + It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task ProductSpecAttrPopupPost_ScopeGrantsAccess_NewAttribute_Inserts() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.AddProductSpecificationAttributeModel { ProductId = "p1", Id = "psa-new" }; + + var result = await _controller.ProductSpecAttrPopup(new Mock().Object, model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.InsertProductSpecificationAttributeModel(model, product), Times.Once); + } + + [TestMethod] + public async Task ProductSpecAttrPopupPost_ScopeGrantsAccess_ExistingAttribute_Updates() + { + var psa = new ProductSpecificationAttribute { Id = "psa1" }; + var product = new Product { Id = "p1" }; + product.ProductSpecificationAttributes.Add(psa); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductModel.AddProductSpecificationAttributeModel { ProductId = "p1", Id = "psa1" }; + + var result = await _controller.ProductSpecAttrPopup(new Mock().Object, model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.UpdateProductSpecificationAttributeModel(psa, model), Times.Once); + } + + [TestMethod] + public async Task ProductSpecAttrPopupPost_InvalidModelState_ReturnsView_DoesNotAccessProduct() + { + var model = new ProductModel.AddProductSpecificationAttributeModel { ProductId = "p1", Id = "psa1" }; + _controller.ModelState.AddModelError("x", "error"); + var specAttrServiceMock = new Mock(); + specAttrServiceMock.Setup(s => s.GetSpecificationAttributes(It.IsAny(), 0, int.MaxValue)) + .ReturnsAsync(new PagedList(new List(), 0, int.MaxValue)); + + var result = await _controller.ProductSpecAttrPopup(specAttrServiceMock.Object, model); + + Assert.IsInstanceOfType(result); + _productServiceMock.Verify(p => p.GetProductById(It.IsAny(), It.IsAny()), Times.Never); + } + + // --- ProductSpecAttrDelete ------------------------------------------------------------------------ + + [TestMethod] + public async Task ProductSpecAttrDelete_ProductNotFound_ReturnsContent() + { + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync((Product)null); + var model = new ProductSpecificationAttributeModel { ProductId = "p1", Id = "psa1" }; + + var result = await _controller.ProductSpecAttrDelete(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify( + s => s.DeleteProductSpecificationAttribute(It.IsAny(), It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task ProductSpecAttrDelete_ScopeDeniesAccess_ReturnsContent_DoesNotDelete() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(false); + var model = new ProductSpecificationAttributeModel { ProductId = "p1", Id = "psa1" }; + + // Regression guard: Vendor's original ProductSpecAttrDelete had no access check at all, letting + // any vendor delete another vendor's specification attribute mapping by posting its + // productId/model.Id. + var result = await _controller.ProductSpecAttrDelete(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify( + s => s.DeleteProductSpecificationAttribute(It.IsAny(), It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task ProductSpecAttrDelete_ScopeGrantsAccess_ValidModel_Deletes() + { + var psa = new ProductSpecificationAttribute { Id = "psa1" }; + var product = new Product { Id = "p1" }; + product.ProductSpecificationAttributes.Add(psa); + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductSpecificationAttributeModel { ProductId = "p1", Id = "psa1" }; + + var result = await _controller.ProductSpecAttrDelete(model); + + Assert.IsInstanceOfType(result); + _productViewModelServiceMock.Verify(s => s.DeleteProductSpecificationAttribute(product, psa), Times.Once); + } + + [TestMethod] + public async Task ProductSpecAttrDelete_ScopeGrantsAccess_AttributeNotFound_Throws() + { + var product = new Product { Id = "p1" }; + _productServiceMock.Setup(p => p.GetProductById("p1", false)).ReturnsAsync(product); + _scopeMock.Setup(s => s.HasAccess(product)).ReturnsAsync(true); + var model = new ProductSpecificationAttributeModel { ProductId = "p1", Id = "missing" }; + + await Assert.ThrowsExactlyAsync(() => _controller.ProductSpecAttrDelete(model)); + } + + [TestMethod] + public async Task ProductSpecAttrDelete_InvalidModelState_ReturnsKendoGridError_DoesNotAccessProduct() + { + var model = new ProductSpecificationAttributeModel { ProductId = "p1", Id = "psa1" }; + _controller.ModelState.AddModelError("x", "error"); + + var result = await _controller.ProductSpecAttrDelete(model); + + Assert.IsInstanceOfType(result); + _productServiceMock.Verify(p => p.GetProductById(It.IsAny(), It.IsAny()), Times.Never); + } +} diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerAttributesTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerAttributesTests.cs index aa1495099..8f957909a 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerAttributesTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerAttributesTests.cs @@ -10,22 +10,33 @@ namespace Grand.Web.Admin.Tests.Controllers; public class ProductControllerAttributesTests { [TestMethod] - public void ProductController_CarriesRequiredAuthorizationAndCsrfAttributes() + [DataRow(typeof(ProductController))] + [DataRow(typeof(ProductAssociatedController))] + [DataRow(typeof(ProductAttributeCombinationController))] + [DataRow(typeof(ProductAttributeMappingController))] + [DataRow(typeof(ProductBidController))] + [DataRow(typeof(ProductBundleController))] + [DataRow(typeof(ProductCategoryController))] + [DataRow(typeof(ProductCollectionController))] + [DataRow(typeof(ProductPictureController))] + [DataRow(typeof(ProductPriceController))] + [DataRow(typeof(ProductRelatedController))] + [DataRow(typeof(ProductReservationController))] + [DataRow(typeof(ProductSpecificationController))] + public void ProductControllers_CarryRequiredAuthorizationAndCsrfAttributes(Type type) { - var type = typeof(ProductController); - - Assert.IsTrue(type.IsDefined(typeof(AuthorizeAdminAttribute), true), "Missing [AuthorizeAdmin]."); + Assert.IsTrue(type.IsDefined(typeof(AuthorizeAdminAttribute), true), $"{type.Name}: missing [AuthorizeAdmin]."); Assert.IsTrue(type.IsDefined(typeof(AutoValidateAntiforgeryTokenAttribute), true), - "Missing [AutoValidateAntiforgeryToken] - CSRF protection would be lost."); - Assert.IsTrue(type.IsDefined(typeof(AreaAttribute), true), "Missing [Area]."); - Assert.IsTrue(type.IsDefined(typeof(AuthorizeMenuAttribute), true), "Missing [AuthorizeMenu]."); + $"{type.Name}: missing [AutoValidateAntiforgeryToken] - CSRF protection would be lost."); + Assert.IsTrue(type.IsDefined(typeof(AreaAttribute), true), $"{type.Name}: missing [Area]."); + Assert.IsTrue(type.IsDefined(typeof(AuthorizeMenuAttribute), true), $"{type.Name}: missing [AuthorizeMenu]."); - // Inherited from BaseProductController - PermissionAuthorizeAttribute has no + // Inherited from the Base*Controller in AdminShared - PermissionAuthorizeAttribute has no // AttributeUsage(Inherited = false), so MVC's attribute discovery (inherit: true) picks it up // from the base class without the subclass needing to restate it. var permissionAttr = (PermissionAuthorizeAttribute)Attribute.GetCustomAttribute(type, typeof(PermissionAuthorizeAttribute), true); - Assert.IsNotNull(permissionAttr, "Missing [PermissionAuthorize] (expected via inheritance from BaseProductController)."); + Assert.IsNotNull(permissionAttr, $"{type.Name}: missing [PermissionAuthorize] (expected via inheritance from its base controller)."); Assert.AreEqual(PermissionSystemName.Products, permissionAttr.Permission); } } diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerTestsBase.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerTestsBase.cs new file mode 100644 index 000000000..560bbfe7d --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/ProductControllerTestsBase.cs @@ -0,0 +1,96 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Domain.Catalog; +using Grand.Domain.Permissions; +using Grand.Infrastructure.Mapper; +using Grand.Mapping; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Mapper; +using Grand.Web.Common.Localization; +using Grand.Web.Common.Security.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Admin.Tests.Controllers; + +/// +/// Fixture shared by the tests of BaseProductController and the per-tab product controllers split out of it. +/// Each derived class only says how to build its controller from the mocks below. +/// +public abstract class ProductControllerTestsBase where TController : Controller +{ + protected TController _controller; + protected Mock _productServiceMock; + protected Mock _productViewModelServiceMock; + protected Mock _translationServiceMock; + protected Mock> _scopeMock; + protected Mock _permissionServiceMock; + protected Mock _productReservationServiceMock; + protected Mock _auctionServiceMock; + protected Mock _inventoryManageServiceMock; + protected Mock _languageServiceMock; + protected Mock _dateTimeServiceMock; + protected Mock _enumTranslationServiceMock; + + protected abstract TController CreateController(); + + [TestInitialize] + public void Setup() + { + var mapperConfig = new MapperConfiguration(cfg => + { + cfg.AddProfile(); + cfg.AddProfile(); + }); + AutoMapperConfig.Init(mapperConfig); + + _productServiceMock = new Mock(); + _productViewModelServiceMock = new Mock(); + _translationServiceMock = new Mock(); + _translationServiceMock.Setup(t => t.GetResource(It.IsAny())).Returns("resource"); + + _scopeMock = new Mock>(); + _scopeMock.Setup(s => s.ResourceKeyPrefix).Returns("Admin"); + _scopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + + _permissionServiceMock = new Mock(); + _permissionServiceMock.Setup(p => p.Authorize(It.IsAny())).ReturnsAsync(true); + + _languageServiceMock = new Mock(); + _languageServiceMock.Setup(l => l.GetAllLanguages(true, It.IsAny())).ReturnsAsync(new List()); + + _productReservationServiceMock = new Mock(); + _auctionServiceMock = new Mock(); + _inventoryManageServiceMock = new Mock(); + _dateTimeServiceMock = new Mock(); + _enumTranslationServiceMock = new Mock(); + + _controller = CreateController(); + + var httpContext = new DefaultHttpContext(); + // Needed for actions whose catch (Exception) block calls Error(exc), which logs via + // HttpContext.RequestServices.GetRequiredService() (see Export / Import). + // Once RequestServices is non-null, ControllerBase.Url's own + // HttpContext.RequestServices.GetRequiredService() call (used by every + // RedirectToAction(action, controller) - e.g. GoToSku) stops being short-circuited by the + // null-conditional it uses when RequestServices itself is null, so IUrlHelperFactory must resolve + // too or those tests start throwing. + var loggerFactoryMock = new Mock(); + loggerFactoryMock.Setup(l => l.CreateLogger(It.IsAny())).Returns(new Mock().Object); + var urlHelperFactoryMock = new Mock(); + urlHelperFactoryMock.Setup(f => f.GetUrlHelper(It.IsAny())) + .Returns(new Mock().Object); + var requestServicesMock = new Mock(); + requestServicesMock.Setup(s => s.GetService(typeof(ILoggerFactory))).Returns(loggerFactoryMock.Object); + requestServicesMock.Setup(s => s.GetService(typeof(IUrlHelperFactory))).Returns(urlHelperFactoryMock.Object); + httpContext.RequestServices = requestServicesMock.Object; + _controller.ControllerContext = new ControllerContext { HttpContext = httpContext }; + _controller.TempData = new TempDataDictionary(httpContext, new Mock().Object); + } +} diff --git a/src/Tests/Grand.Web.Store.Tests/Controllers/ProductControllerAttributesTests.cs b/src/Tests/Grand.Web.Store.Tests/Controllers/ProductControllerAttributesTests.cs index 0d8ef6ffc..c06342640 100644 --- a/src/Tests/Grand.Web.Store.Tests/Controllers/ProductControllerAttributesTests.cs +++ b/src/Tests/Grand.Web.Store.Tests/Controllers/ProductControllerAttributesTests.cs @@ -10,22 +10,33 @@ namespace Grand.Web.Store.Tests.Controllers; public class ProductControllerAttributesTests { [TestMethod] - public void ProductController_CarriesRequiredAuthorizationAndCsrfAttributes() + [DataRow(typeof(ProductController))] + [DataRow(typeof(ProductAssociatedController))] + [DataRow(typeof(ProductAttributeCombinationController))] + [DataRow(typeof(ProductAttributeMappingController))] + [DataRow(typeof(ProductBidController))] + [DataRow(typeof(ProductBundleController))] + [DataRow(typeof(ProductCategoryController))] + [DataRow(typeof(ProductCollectionController))] + [DataRow(typeof(ProductPictureController))] + [DataRow(typeof(ProductPriceController))] + [DataRow(typeof(ProductRelatedController))] + [DataRow(typeof(ProductReservationController))] + [DataRow(typeof(ProductSpecificationController))] + public void ProductControllers_CarryRequiredAuthorizationAndCsrfAttributes(Type type) { - var type = typeof(ProductController); - - Assert.IsTrue(type.IsDefined(typeof(AuthorizeStoreAttribute), true), "Missing [AuthorizeStore]."); + Assert.IsTrue(type.IsDefined(typeof(AuthorizeStoreAttribute), true), $"{type.Name}: missing [AuthorizeStore]."); Assert.IsTrue(type.IsDefined(typeof(AutoValidateAntiforgeryTokenAttribute), true), - "Missing [AutoValidateAntiforgeryToken] - CSRF protection would be lost."); - Assert.IsTrue(type.IsDefined(typeof(AreaAttribute), true), "Missing [Area]."); - Assert.IsTrue(type.IsDefined(typeof(AuthorizeMenuAttribute), true), "Missing [AuthorizeMenu]."); + $"{type.Name}: missing [AutoValidateAntiforgeryToken] - CSRF protection would be lost."); + Assert.IsTrue(type.IsDefined(typeof(AreaAttribute), true), $"{type.Name}: missing [Area]."); + Assert.IsTrue(type.IsDefined(typeof(AuthorizeMenuAttribute), true), $"{type.Name}: missing [AuthorizeMenu]."); - // Inherited from BaseProductController - PermissionAuthorizeAttribute has no + // Inherited from the Base*Controller in AdminShared - PermissionAuthorizeAttribute has no // AttributeUsage(Inherited = false), so MVC's attribute discovery (inherit: true) picks it up // from the base class without the subclass needing to restate it. var permissionAttr = (PermissionAuthorizeAttribute)Attribute.GetCustomAttribute(type, typeof(PermissionAuthorizeAttribute), true); - Assert.IsNotNull(permissionAttr, "Missing [PermissionAuthorize] (expected via inheritance from BaseProductController)."); + Assert.IsNotNull(permissionAttr, $"{type.Name}: missing [PermissionAuthorize] (expected via inheritance from its base controller)."); Assert.AreEqual(PermissionSystemName.Products, permissionAttr.Permission); } } diff --git a/src/Tests/Grand.Web.Store.Tests/Controllers/ProductControllerTests.cs b/src/Tests/Grand.Web.Store.Tests/Controllers/ProductControllerTests.cs index 365735cc4..a683b3095 100644 --- a/src/Tests/Grand.Web.Store.Tests/Controllers/ProductControllerTests.cs +++ b/src/Tests/Grand.Web.Store.Tests/Controllers/ProductControllerTests.cs @@ -48,14 +48,10 @@ public void Setup() _controller = new ProductController( productViewModelServiceMock.Object, _productServiceMock.Object, - new Mock().Object, languageServiceMock.Object, translationServiceMock.Object, - new Mock().Object, - new Mock().Object, new Mock().Object, new Mock().Object, - new Mock().Object, _scopeMock.Object); var httpContext = new DefaultHttpContext(); diff --git a/src/Tests/Grand.Web.Vendor.Tests/Controllers/ProductControllerTests.cs b/src/Tests/Grand.Web.Vendor.Tests/Controllers/ProductControllerTests.cs index 8e204653d..cd5fc9457 100644 --- a/src/Tests/Grand.Web.Vendor.Tests/Controllers/ProductControllerTests.cs +++ b/src/Tests/Grand.Web.Vendor.Tests/Controllers/ProductControllerTests.cs @@ -3,6 +3,7 @@ using Grand.Web.Common.Security.Authorization; using Grand.Web.Vendor.Controllers; using Microsoft.AspNetCore.Mvc; +using System.Reflection; namespace Grand.Web.Vendor.Tests.Controllers; @@ -10,22 +11,50 @@ namespace Grand.Web.Vendor.Tests.Controllers; public class ProductControllerTests { [TestMethod] - public void ProductController_CarriesRequiredAuthorizationAndCsrfAttributes() + [DataRow(typeof(ProductController))] + [DataRow(typeof(ProductAssociatedController))] + [DataRow(typeof(ProductAttributeCombinationController))] + [DataRow(typeof(ProductAttributeMappingController))] + [DataRow(typeof(ProductBidController))] + [DataRow(typeof(ProductBundleController))] + [DataRow(typeof(ProductCategoryController))] + [DataRow(typeof(ProductCollectionController))] + [DataRow(typeof(ProductPictureController))] + [DataRow(typeof(ProductPriceController))] + [DataRow(typeof(ProductRelatedController))] + [DataRow(typeof(ProductReservationController))] + [DataRow(typeof(ProductSpecificationController))] + public void ProductControllers_CarryRequiredAuthorizationAndCsrfAttributes(Type type) { - var type = typeof(ProductController); - - Assert.IsTrue(type.IsDefined(typeof(AuthorizeVendorAttribute), true), "Missing [AuthorizeVendor]."); + Assert.IsTrue(type.IsDefined(typeof(AuthorizeVendorAttribute), true), $"{type.Name}: missing [AuthorizeVendor]."); Assert.IsTrue(type.IsDefined(typeof(AutoValidateAntiforgeryTokenAttribute), true), - "Missing [AutoValidateAntiforgeryToken] - CSRF protection would be lost."); - Assert.IsTrue(type.IsDefined(typeof(AreaAttribute), true), "Missing [Area]."); - Assert.IsTrue(type.IsDefined(typeof(AuthorizeMenuAttribute), true), "Missing [AuthorizeMenu]."); + $"{type.Name}: missing [AutoValidateAntiforgeryToken] - CSRF protection would be lost."); + Assert.IsTrue(type.IsDefined(typeof(AreaAttribute), true), $"{type.Name}: missing [Area]."); + Assert.IsTrue(type.IsDefined(typeof(AuthorizeMenuAttribute), true), $"{type.Name}: missing [AuthorizeMenu]."); - // Inherited from BaseProductController - PermissionAuthorizeAttribute has no + // Inherited from the Base*Controller in AdminShared - PermissionAuthorizeAttribute has no // AttributeUsage(Inherited = false), so MVC's attribute discovery (inherit: true) picks it up // from the base class without the subclass needing to restate it. var permissionAttr = (PermissionAuthorizeAttribute)Attribute.GetCustomAttribute(type, typeof(PermissionAuthorizeAttribute), true); - Assert.IsNotNull(permissionAttr, "Missing [PermissionAuthorize] (expected via inheritance from BaseProductController)."); + Assert.IsNotNull(permissionAttr, $"{type.Name}: missing [PermissionAuthorize] (expected via inheritance from its base controller)."); Assert.AreEqual(PermissionSystemName.Products, permissionAttr.Permission); } + + // The Vendor-only answers the shared tab controllers leave to the host. A hook that is not overridden + // falls back to the Admin/Store behavior without a build error, so a lost override shows up only here. + [TestMethod] + [DataRow(typeof(ProductRelatedController), "InvalidRelatedProductAddPopupResult")] + [DataRow(typeof(ProductRelatedController), "InvalidSimilarProductAddPopupResult")] + [DataRow(typeof(ProductRelatedController), "InvalidCrossSellProductAddPopupResult")] + [DataRow(typeof(ProductRelatedController), "InvalidRecommendedProductAddPopupResult")] + [DataRow(typeof(ProductBundleController), "InvalidBundleProductAddPopupResult")] + [DataRow(typeof(ProductAssociatedController), "get_AssociatedProductVendorId")] + public void ProductControllers_OverrideTheVendorHooks(Type type, string member) + { + var method = type.GetMethod(member, BindingFlags.Instance | BindingFlags.NonPublic); + + Assert.IsNotNull(method, $"{type.Name}.{member} not found."); + Assert.AreEqual(type, method.DeclaringType, $"{type.Name} does not override {member}."); + } } diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Prices.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Prices.cshtml index 75053fe5e..ad9d3fe84 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Prices.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/CreateOrUpdate.Prices.cshtml @@ -63,14 +63,14 @@ { - @Loc["Admin.Catalog.Products.TierPrices.AddNew"] + @Loc["Admin.Catalog.Products.TierPrices.AddNew"] @* Delete posts the whole row; the Kendo parameterMap posted only Id and ProductId, which are the fields TierPriceDeleteModel binds *@ @@ -92,7 +92,7 @@ -  {{ $texts.edit }} +  {{ $texts.edit }} diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/AttributeCombinationPopup.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/ProductAttributeCombination/AttributeCombinationPopup.cshtml similarity index 90% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/AttributeCombinationPopup.cshtml rename to src/Web/Grand.Web.Admin/Areas/Admin/Views/ProductAttributeCombination/AttributeCombinationPopup.cshtml index 5374afc69..0a5bf577c 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/AttributeCombinationPopup.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/ProductAttributeCombination/AttributeCombinationPopup.cshtml @@ -7,7 +7,7 @@ Layout = ""; ViewBag.Title = string.IsNullOrEmpty(Model.Id) ? Loc["Admin.Catalog.Products.ProductAttributes.AttributeCombinations.AddTitle"] : Loc["Admin.Catalog.Products.ProductAttributes.AttributeCombinations.Update"]; } -
new SelectListItem(group.Name ?? "", group.Id))); } diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.AttributeValueButtons.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/ProductAttributeMapping/Partials/WidgetZone.AttributeValueButtons.cshtml similarity index 100% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.AttributeValueButtons.cshtml rename to src/Web/Grand.Web.Admin/Areas/Admin/Views/ProductAttributeMapping/Partials/WidgetZone.AttributeValueButtons.cshtml diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.TierPrice.Bottom.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/ProductPrice/Partials/WidgetZone.TierPrice.Bottom.cshtml similarity index 100% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.TierPrice.Bottom.cshtml rename to src/Web/Grand.Web.Admin/Areas/Admin/Views/ProductPrice/Partials/WidgetZone.TierPrice.Bottom.cshtml diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.TierPrice.Buttons.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/ProductPrice/Partials/WidgetZone.TierPrice.Buttons.cshtml similarity index 100% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.TierPrice.Buttons.cshtml rename to src/Web/Grand.Web.Admin/Areas/Admin/Views/ProductPrice/Partials/WidgetZone.TierPrice.Buttons.cshtml diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.TierPrice.Top.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/ProductPrice/Partials/WidgetZone.TierPrice.Top.cshtml similarity index 100% rename from src/Web/Grand.Web.Admin/Areas/Admin/Views/Product/Partials/WidgetZone.TierPrice.Top.cshtml rename to src/Web/Grand.Web.Admin/Areas/Admin/Views/ProductPrice/Partials/WidgetZone.TierPrice.Top.cshtml diff --git a/src/Web/Grand.Web.Admin/Controllers/ProductAssociatedController.cs b/src/Web/Grand.Web.Admin/Controllers/ProductAssociatedController.cs new file mode 100644 index 000000000..74b0de7f3 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Controllers/ProductAssociatedController.cs @@ -0,0 +1,21 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Web.Admin.Extensions; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Admin.Controllers; + +[AuthorizeAdmin] +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaAdmin)] +[AuthorizeMenu] +public class ProductAssociatedController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseProductAssociatedController(productViewModelService, productService, translationService, scope); diff --git a/src/Web/Grand.Web.Admin/Controllers/ProductAttributeCombinationController.cs b/src/Web/Grand.Web.Admin/Controllers/ProductAttributeCombinationController.cs new file mode 100644 index 000000000..8f8082f58 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Controllers/ProductAttributeCombinationController.cs @@ -0,0 +1,22 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Web.Admin.Extensions; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Admin.Controllers; + +[AuthorizeAdmin] +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaAdmin)] +[AuthorizeMenu] +public class ProductAttributeCombinationController( + IProductViewModelService productViewModelService, + IProductService productService, + IInventoryManageService inventoryManageService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseProductAttributeCombinationController(productViewModelService, productService, inventoryManageService, translationService, scope); diff --git a/src/Web/Grand.Web.Admin/Controllers/ProductAttributeMappingController.cs b/src/Web/Grand.Web.Admin/Controllers/ProductAttributeMappingController.cs new file mode 100644 index 000000000..2515f4d78 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Controllers/ProductAttributeMappingController.cs @@ -0,0 +1,23 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Domain.Catalog; +using Grand.Web.Admin.Extensions; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Admin.Controllers; + +[AuthorizeAdmin] +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaAdmin)] +[AuthorizeMenu] +public class ProductAttributeMappingController( + IProductViewModelService productViewModelService, + IProductService productService, + ILanguageService languageService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseProductAttributeMappingController(productViewModelService, productService, languageService, translationService, scope); diff --git a/src/Web/Grand.Web.Admin/Controllers/ProductBidController.cs b/src/Web/Grand.Web.Admin/Controllers/ProductBidController.cs new file mode 100644 index 000000000..c5f18311a --- /dev/null +++ b/src/Web/Grand.Web.Admin/Controllers/ProductBidController.cs @@ -0,0 +1,22 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Web.Admin.Extensions; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Admin.Controllers; + +[AuthorizeAdmin] +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaAdmin)] +[AuthorizeMenu] +public class ProductBidController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IAuctionService auctionService, + IAdminDataScope scope) + : BaseProductBidController(productViewModelService, productService, translationService, auctionService, scope); diff --git a/src/Web/Grand.Web.Admin/Controllers/ProductBundleController.cs b/src/Web/Grand.Web.Admin/Controllers/ProductBundleController.cs new file mode 100644 index 000000000..147e0fbac --- /dev/null +++ b/src/Web/Grand.Web.Admin/Controllers/ProductBundleController.cs @@ -0,0 +1,21 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Web.Admin.Extensions; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Admin.Controllers; + +[AuthorizeAdmin] +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaAdmin)] +[AuthorizeMenu] +public class ProductBundleController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseProductBundleController(productViewModelService, productService, translationService, scope); diff --git a/src/Web/Grand.Web.Admin/Controllers/ProductCategoryController.cs b/src/Web/Grand.Web.Admin/Controllers/ProductCategoryController.cs new file mode 100644 index 000000000..07976a66a --- /dev/null +++ b/src/Web/Grand.Web.Admin/Controllers/ProductCategoryController.cs @@ -0,0 +1,21 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Web.Admin.Extensions; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Admin.Controllers; + +[AuthorizeAdmin] +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaAdmin)] +[AuthorizeMenu] +public class ProductCategoryController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseProductCategoryController(productViewModelService, productService, translationService, scope); diff --git a/src/Web/Grand.Web.Admin/Controllers/ProductCollectionController.cs b/src/Web/Grand.Web.Admin/Controllers/ProductCollectionController.cs new file mode 100644 index 000000000..c5c9fb3a0 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Controllers/ProductCollectionController.cs @@ -0,0 +1,21 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Web.Admin.Extensions; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Admin.Controllers; + +[AuthorizeAdmin] +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaAdmin)] +[AuthorizeMenu] +public class ProductCollectionController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseProductCollectionController(productViewModelService, productService, translationService, scope); diff --git a/src/Web/Grand.Web.Admin/Controllers/ProductController.cs b/src/Web/Grand.Web.Admin/Controllers/ProductController.cs index 44c0b3013..0a7e0a44f 100644 --- a/src/Web/Grand.Web.Admin/Controllers/ProductController.cs +++ b/src/Web/Grand.Web.Admin/Controllers/ProductController.cs @@ -19,15 +19,10 @@ namespace Grand.Web.Admin.Controllers; public class ProductController( IProductViewModelService productViewModelService, IProductService productService, - IInventoryManageService inventoryManageService, ILanguageService languageService, IRequestTranslationService translationService, - IProductReservationService productReservationService, - IAuctionService auctionService, IDateTimeService dateTimeService, IRequestPermissionService permissionService, - IEnumTranslationService enumTranslationService, IAdminDataScope scope) - : BaseProductController(productViewModelService, productService, inventoryManageService, languageService, - translationService, productReservationService, auctionService, dateTimeService, permissionService, - enumTranslationService, scope); + : BaseProductController(productViewModelService, productService, languageService, translationService, + dateTimeService, permissionService, scope); diff --git a/src/Web/Grand.Web.Admin/Controllers/ProductPictureController.cs b/src/Web/Grand.Web.Admin/Controllers/ProductPictureController.cs new file mode 100644 index 000000000..392d4d2bb --- /dev/null +++ b/src/Web/Grand.Web.Admin/Controllers/ProductPictureController.cs @@ -0,0 +1,25 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Domain.Catalog; +using Grand.Web.Admin.Extensions; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Grand.Web.Common.Security.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Admin.Controllers; + +[AuthorizeAdmin] +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaAdmin)] +[AuthorizeMenu] +public class ProductPictureController( + IProductViewModelService productViewModelService, + IProductService productService, + ILanguageService languageService, + IRequestTranslationService translationService, + IRequestPermissionService permissionService, + IAdminDataScope scope) + : BaseProductPictureController(productViewModelService, productService, languageService, translationService, permissionService, scope); diff --git a/src/Web/Grand.Web.Admin/Controllers/ProductPriceController.cs b/src/Web/Grand.Web.Admin/Controllers/ProductPriceController.cs new file mode 100644 index 000000000..558d3949e --- /dev/null +++ b/src/Web/Grand.Web.Admin/Controllers/ProductPriceController.cs @@ -0,0 +1,23 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Domain.Catalog; +using Grand.Web.Admin.Extensions; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Admin.Controllers; + +[AuthorizeAdmin] +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaAdmin)] +[AuthorizeMenu] +public class ProductPriceController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IDateTimeService dateTimeService, + IAdminDataScope scope) + : BaseProductPriceController(productViewModelService, productService, translationService, dateTimeService, scope); diff --git a/src/Web/Grand.Web.Admin/Controllers/ProductRelatedController.cs b/src/Web/Grand.Web.Admin/Controllers/ProductRelatedController.cs new file mode 100644 index 000000000..7aba3b247 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Controllers/ProductRelatedController.cs @@ -0,0 +1,21 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Web.Admin.Extensions; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Admin.Controllers; + +[AuthorizeAdmin] +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaAdmin)] +[AuthorizeMenu] +public class ProductRelatedController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseProductRelatedController(productViewModelService, productService, translationService, scope); diff --git a/src/Web/Grand.Web.Admin/Controllers/ProductReservationController.cs b/src/Web/Grand.Web.Admin/Controllers/ProductReservationController.cs new file mode 100644 index 000000000..0b178a096 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Controllers/ProductReservationController.cs @@ -0,0 +1,22 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Web.Admin.Extensions; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Admin.Controllers; + +[AuthorizeAdmin] +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaAdmin)] +[AuthorizeMenu] +public class ProductReservationController( + IProductService productService, + IRequestTranslationService translationService, + IProductReservationService productReservationService, + IEnumTranslationService enumTranslationService, + IAdminDataScope scope) + : BaseProductReservationController(productService, translationService, productReservationService, enumTranslationService, scope); diff --git a/src/Web/Grand.Web.Admin/Controllers/ProductSpecificationController.cs b/src/Web/Grand.Web.Admin/Controllers/ProductSpecificationController.cs new file mode 100644 index 000000000..696e219f9 --- /dev/null +++ b/src/Web/Grand.Web.Admin/Controllers/ProductSpecificationController.cs @@ -0,0 +1,21 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Web.Admin.Extensions; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Admin.Controllers; + +[AuthorizeAdmin] +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaAdmin)] +[AuthorizeMenu] +public class ProductSpecificationController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseProductSpecificationController(productViewModelService, productService, translationService, scope); diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductAssociatedController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductAssociatedController.cs new file mode 100644 index 000000000..086d95892 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductAssociatedController.cs @@ -0,0 +1,205 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Utilities.Catalog; +using Grand.Domain.Catalog; +using Grand.Domain.Permissions; +using Grand.SharedKernel.Extensions; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.AdminShared.Models.Orders; +using Grand.Web.Common.Controllers; +using Grand.Web.Common.DataSource; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Grand.Web.Common.Security.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.AdminShared.Controllers; + +[PermissionAuthorize(PermissionSystemName.Products)] +[AutoValidateAntiforgeryToken] +public abstract class BaseProductAssociatedController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseController +{ + #region Associated products + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task AssociatedProductList(DataSourceRequest command, string productId) + { + var product = await productService.GetProductById(productId); + + // CanView on this read: a product shared with other stores opens read-only on Store, so its + // tabs must load; the mutating actions below keep the strict HasAccess. History: + // HasAccess (strict): mirrors Store's CanAccessProduct and Vendor's CheckAccessToProduct gating + // this action on both hosts. Admin's original had no check at all - GlobalAdminDataScope.HasAccess + // is a no-op there, so this closes that gap the same way as every other row in this task. + if (!await scope.CanView(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + // AssociatedProductVendorId (hook below): Vendor's original also passed CurrentVendor.Id into + // GetAssociatedProducts(vendorId:) so a vendor only sees, among a grouped product's full + // associated-product set, the ones they themselves own. That vendor filter is unconditional in + // GetAssociatedProducts regardless of showHidden, unlike its storeId parameter (which only applies + // when showHidden is false - moot here since all three hosts pass showHidden: true). + var associatedProducts = await productService.GetAssociatedProducts(productId, + access: CatalogAccess.IncludingHidden(), + vendorId: AssociatedProductVendorId); + var associatedProductsModel = associatedProducts + .Select(x => new ProductModel.AssociatedProductModel { + Id = x.Id, + ProductId = productId, + ProductName = x.Name, + DisplayOrder = x.DisplayOrder + }) + .ToList(); + + var gridModel = new DataSourceResult { + Data = associatedProductsModel, + Total = associatedProductsModel.Count + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task AssociatedProductUpdate(ProductModel.AssociatedProductModel model) + { + var associatedProduct = await productService.GetProductById(model.Id); + if (associatedProduct == null) + throw new ArgumentException("No associated product found with the specified id"); + + // HasAccess (strict): mirrors Vendor's inline HasAccessToProduct(associatedProduct) check and + // Store's CanAccessProduct. Admin's original had no check at all. + if (!await scope.HasAccess(associatedProduct)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + associatedProduct.DisplayOrder = model.DisplayOrder; + await productService.UpdateAssociatedProduct(associatedProduct); + + return new JsonResult(""); + } + + return ErrorForKendoGridJson(ModelState); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task AssociatedProductDelete(ProductModel.AssociatedProductModel model) + { + var product = await productService.GetProductById(model.Id); + if (product == null) + throw new ArgumentException("No associated product found with the specified id"); + + // HasAccess (strict): mirrors Vendor's inline HasAccessToProduct(product) check and Store's + // CanAccessProduct. Admin's original had no check at all. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + await productViewModelService.DeleteAssociatedProduct(product); + return new JsonResult(""); + } + + return ErrorForKendoGridJson(ModelState); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task AssociatedProductAddPopup(string productId) + { + // No access check here in any of the three original hosts (all open this popup unconditionally + // once the Edit permission is satisfied) - only the mutating actions below tie access to a + // specific product. PrepareAssociatedProductModel() now reads scope internally (Task 9). + var model = await productViewModelService.PrepareAssociatedProductModel(); + model.ProductId = productId; + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task AssociatedProductAddPopupList(DataSourceRequest command, + ProductModel.AddAssociatedProductModel model) + { + if (scope.DefaultStoreId is not null) model.SearchStoreId = scope.DefaultStoreId; + + var (products, totalCount) = + await productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); + var gridModel = new DataSourceResult { + Data = products.ToList(), + Total = totalCount + }; + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task AssociatedProductAddPopup(ProductModel.AddAssociatedProductModel model) + { + var parentProduct = await productService.GetProductById(model.ProductId); + // HasAccess (strict): mirrors Store's CanAccessProduct(parentProduct) check on this action. + // Vendor's controller had NO check on the parent product at all (see below for the selected-ids + // side). Vendor's own service-layer InsertAssociatedProductModel already filtered each selected + // product via HasAccessToProduct before reparenting it, but never checked the parent - so a vendor + // could attach their own products under another vendor's grouped product (cross-vendor storefront + // pollution / data-integrity issue, not a write to someone else's product record). Closed here the + // same way as every other AddPopup(POST) in this task. + if (!await scope.HasAccess(parentProduct)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + // InsertAssociatedProductModel reparents each selected product - it writes + // ParentGroupedProductId directly onto that product's own record - unlike + // Related/Similar/Bundle/Cross-sell/Recommended, whose Insert*ProductModel only adds a mapping + // entry that references the selected product's id from the parent's own list; the selected + // product's record is never itself modified there. So here, unlike those simpler regions, + // every selected id must independently pass HasAccess too - the same per-id filtering Store's + // controller-level original already did, and which Vendor's original also had, but only at the + // service layer (Grand.Web.Vendor/Services/ProductViewModelService.cs InsertAssociatedProductModel: + // `if (product == null || !HasAccessToProduct(product)) continue;`). BaseProductController + // injects AdminShared's IProductViewModelService - the unfiltered variant - not Vendor's own, so + // once Vendor is subclassed onto this base (Task 11) it loses that service-layer filter + // entirely. Enforcing the per-id check here at the controller level is what preserves the + // invariant going forward. + if (model.SelectedProductIds != null) + { + var validIds = new List(); + foreach (var id in model.SelectedProductIds) + { + var selected = await productService.GetProductById(id); + if (await scope.HasAccess(selected)) validIds.Add(id); + } + + model.SelectedProductIds = validIds.ToArray(); + if (validIds.Count > 0) await productViewModelService.InsertAssociatedProductModel(model); + } + + return Content(""); + } + + // Unlike Related/Similar/Bundle/Cross-sell/Recommended, all three original hosts share the exact + // same invalid-model-state handling here (Error(ModelState) + re-prepare + View) - Vendor's + // AssociatedProductAddPopup(POST) does not use the Content(ModelState.GetErrors()) shortcut it + // uses in those other regions, so no host-specific hook is needed for this action. + Error(ModelState); + model = await productViewModelService.PrepareAssociatedProductModel(); + return View(model); + } + + /// Vendor id to filter the associated-products grid by, in addition to the HasAccess gate in + /// AssociatedProductList above. Vendor's original passed CurrentVendor.Id into + /// GetAssociatedProducts(vendorId:) so a vendor only sees the subset of a grouped product's associated + /// products that they themselves own. Admin/Store passed no vendorId (both show every associated + /// product on the parent). Empty here, matching Admin/Store; a future Vendor subclass overrides it + /// once hosts are subclassed onto BaseProductController (Task 11). + protected virtual string AssociatedProductVendorId => ""; + + #endregion +} diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductAttributeCombinationController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductAttributeCombinationController.cs new file mode 100644 index 000000000..2e72b4438 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductAttributeCombinationController.cs @@ -0,0 +1,300 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Domain.Permissions; +using Grand.SharedKernel.Extensions; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.AdminShared.Models.Orders; +using Grand.Web.Common.Controllers; +using Grand.Web.Common.DataSource; +using Grand.Web.Common.Localization; +using Grand.Web.Common.Security.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.AdminShared.Controllers; + +[PermissionAuthorize(PermissionSystemName.Products)] +[AutoValidateAntiforgeryToken] +public abstract class BaseProductAttributeCombinationController( + IProductViewModelService productViewModelService, + IProductService productService, + IInventoryManageService inventoryManageService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseController +{ + #region Product attribute combinations + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task ProductAttributeCombinationList(DataSourceRequest command, string productId) + { + var product = await productService.GetProductById(productId); + + // CanView on this read: a product shared with other stores opens read-only on Store, so its + // tabs must load; the mutating actions below keep the strict HasAccess. History: + // HasAccess added here: Admin's original had no ownership check on this grid at all. Store's + // original used CanAccessProduct + a hardcoded "Admin.Catalog.Products.Permissions" resource key + // (even though it's the Store host); Vendor's original used a local CheckAccessToProduct helper + // that returned a plain hardcoded string ("This is not your product" / "Product not exists") + // rather than a resource key. scope.HasAccess plus the scope.ResourceKeyPrefix-qualified resource + // key normalizes all three to the convention used throughout this file. + if (!await scope.CanView(product)) + return ErrorForKendoGridJson( + translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var combinationsModel = await productViewModelService.PrepareProductAttributeCombinationModel(product); + var gridModel = new DataSourceResult { + Data = combinationsModel, + Total = combinationsModel.Count + }; + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductAttributeCombinationDelete(string id, string productId, + [FromServices] IProductAttributeService productAttributeService) + { + var product = await productService.GetProductById(productId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // HasAccess added here, checked before the combination lookup: matches the + // ProductAttributeValueDelete precedent above (deny before revealing whether the sub-entity + // exists). Vendor's original combined the null-check and the ownership check into a single throw + // (an unhandled exception on denial); normalized here to the file's ErrorForKendoGridJson + // convention - this is a Kendo grid row-delete action - matching how Store's original reported + // denial (though Store checked access after the combination lookup, not before). + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson( + translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var combination = product.ProductAttributeCombinations.FirstOrDefault(x => x.Id == id); + if (combination == null) + throw new ArgumentException("No product attribute combination found with the specified id"); + + await productAttributeService.DeleteProductAttributeCombination(combination, productId); + if (product.ManageInventoryMethodId == ManageInventoryMethod.ManageStockByAttributes) + { + var pr = await productService.GetProductById(productId); + pr.StockQuantity = pr.ProductAttributeCombinations.Sum(x => x.StockQuantity); + pr.ReservedQuantity = pr.ProductAttributeCombinations.Sum(x => x.ReservedQuantity); + await inventoryManageService.UpdateStockProduct(pr, false); + } + + return new JsonResult(""); + } + + //edit + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task AttributeCombinationPopup(string productId, string id) + { + var product = await productService.GetProductById(productId); + + // Content(...), not ErrorForKendoGridJson: Store's original used ErrorForKendoGridJson here even + // though this action returns a View inside a magnificPopup modal (not a grid), which would render + // raw JSON as page content on denial - using Content(...) instead, consistent with the + // EditAttributeValues GET popup correction in region "Product attribute values" above. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var model = await productViewModelService.PrepareProductAttributeCombinationModel(product, id); + await productViewModelService.PrepareAddProductAttributeCombinationModel(model, product); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task AttributeCombinationPopup(string productId, + ProductAttributeCombinationModel model) + { + var product = await productService.GetProductById(productId); + if (product == null) + //No product found with the specified id + return RedirectToAction("List", "Product"); + + // Content(...) on denial, matching Store's original. Vendor's original folded denial into the + // same redirect used for a missing product; Content(...) surfaces the permissions message instead + // of silently redirecting, matching every other Edit-scoped action in this region. + // ProductAttributeCombinationModel (Grand.Web.AdminShared.Models.Catalog) does not implement + // IProductValidVendor and has no registered FluentValidation validator, so there is no + // validator-layer ownership check being displaced here - scope.HasAccess is the only guard. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var warnings = await productViewModelService.InsertOrUpdateProductAttributeCombinationPopup(product, model); + if (!warnings.Any()) return Content(""); + //If we got this far, something failed, redisplay form + await productViewModelService.PrepareAddProductAttributeCombinationModel(model, product); + model.Warnings = warnings; + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task GenerateAllAttributeCombinations(string productId) + { + var product = await productService.GetProductById(productId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // ErrorForKendoGridJson(...), not Content(...): this action is invoked via a dataType:'json' ajax + // call (see CreateOrUpdate.ProductAttributes.TabAttributeCombinations.cshtml). Store's original + // returned Content(resource) on denial - a plain string the client's dataType:'json' parser + // cannot parse - which threw a JSON-parse exception and fell into the ajax error callback, + // showing a generic "Error while generating attribute combinations" alert. Vendor's original + // folded denial into the null-product throw (also an unhandled exception). Normalized here to a + // real JSON response, which avoids that parse-exception/generic-alert path - but note the + // .cshtml's ajax success callback for this action ignores the response body entirely (it just + // unconditionally refreshes the grid), so the denial still isn't surfaced to the user; it's a + // silent no-op instead of a visible (if wrong) error. Server-side enforcement is correct either + // way - nothing is generated on denial - this is a pre-existing view-layer gap, out of scope for + // this controller-only row (flagged for Phase 2 view work). + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson( + translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + await productViewModelService.GenerateAllAttributeCombinations(product); + + return Json(new { Success = true }); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ClearAllAttributeCombinations(string productId) + { + var product = await productService.GetProductById(productId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // See the GenerateAllAttributeCombinations comment above - same dataType:'json' contract, same fix. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson( + translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + await productViewModelService.ClearAllAttributeCombinations(product); + + if (product.ManageInventoryMethodId == ManageInventoryMethod.ManageStockByAttributes) + { + product.StockQuantity = 0; + product.ReservedQuantity = 0; + await inventoryManageService.UpdateStockProduct(product, false); + } + + return Json(new { Success = true }); + } + + return ErrorForKendoGridJson(ModelState); + } + + #endregion + + #region Product Attribute combination - tier prices + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductAttributeCombinationTierPriceList(DataSourceRequest command, + string productId, string productAttributeCombinationId) + { + var product = await productService.GetProductById(productId); + + // HasAccess added here: Admin's original had no ownership check at all on this region (any of its + // four actions). Store checked CanAccessProduct on every action. Vendor's List/Delete used explicit + // checks (CheckAccessToProduct / HasAccessToProduct); Vendor's Insert/Update relied entirely on + // ProductAttributeCombinationTierPricesModel implementing IProductValidVendor (the global + // ValidationFilter resolves ProductValidVendor, a FluentValidation rule comparing + // product.VendorId to CurrentVendor.Id, and the action only proceeded inside `if + // (ModelState.IsValid)`) - a real check, not a no-op, but the shared AdminShared model used here + // does not implement that marker interface, so merging Vendor's Insert/Update as-is would silently + // drop vendor ownership enforcement. scope.HasAccess replaces it uniformly across all four actions + // (no-op for Admin, equivalent-or-stronger for Store/Vendor). + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson( + translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var tierPriceModel = + await productViewModelService.PrepareProductAttributeCombinationTierPricesModel(product, + productAttributeCombinationId); + var gridModel = new DataSourceResult { + Data = tierPriceModel, + Total = tierPriceModel.Count + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductAttributeCombinationTierPriceInsert(string productId, + string productAttributeCombinationId, ProductModel.ProductAttributeCombinationTierPricesModel model) + { + var product = await productService.GetProductById(productId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // See the List comment above - this is the action where Vendor's IProductValidVendor-driven check + // would have silently disappeared without an explicit scope.HasAccess call. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var combination = + product.ProductAttributeCombinations.FirstOrDefault(x => x.Id == productAttributeCombinationId); + if (combination != null) + await productViewModelService.InsertProductAttributeCombinationTierPricesModel(product, combination, + model); + + return new JsonResult(""); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductAttributeCombinationTierPriceUpdate(string productId, + string productAttributeCombinationId, ProductModel.ProductAttributeCombinationTierPricesModel model) + { + var product = await productService.GetProductById(productId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // See the List comment above. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var combination = product.ProductAttributeCombinations.FirstOrDefault(x => x.Id == productAttributeCombinationId); + if (combination != null) + await productViewModelService.UpdateProductAttributeCombinationTierPricesModel(product, combination, + model); + + return new JsonResult(""); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductAttributeCombinationTierPriceDelete(string productId, + string productAttributeCombinationId, string id) + { + var product = await productService.GetProductById(productId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // HasAccess here matches Store's CanAccessProduct and Vendor's HasAccessToProduct checks (both + // already present on Delete in the originals); normalized to scope.HasAccess for Admin too. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var combination = product.ProductAttributeCombinations.FirstOrDefault(x => x.Id == productAttributeCombinationId); + if (combination != null) + { + var tierPrice = combination.TierPrices.FirstOrDefault(x => x.Id == id); + if (tierPrice != null) + await productViewModelService.DeleteProductAttributeCombinationTierPrices(product, combination, + tierPrice); + } + + return new JsonResult(""); + } + + #endregion +} diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductAttributeMappingController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductAttributeMappingController.cs new file mode 100644 index 000000000..852efd3ce --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductAttributeMappingController.cs @@ -0,0 +1,513 @@ +using Grand.Business.Core.Extensions; +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Domain.Catalog; +using Grand.Domain.Permissions; +using Grand.SharedKernel.Extensions; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.AdminShared.Models.Orders; +using Grand.Web.Common.Controllers; +using Grand.Web.Common.DataSource; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Grand.Web.Common.Security.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.AdminShared.Controllers; + +[PermissionAuthorize(PermissionSystemName.Products)] +[AutoValidateAntiforgeryToken] +public abstract class BaseProductAttributeMappingController( + IProductViewModelService productViewModelService, + IProductService productService, + ILanguageService languageService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseController +{ + #region Product attributes + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task ProductAttributeMappingList(DataSourceRequest command, string productId) + { + var product = await productService.GetProductById(productId); + + // CanView on this read: a product shared with other stores opens read-only on Store, so its + // tabs must load; the mutating actions below keep the strict HasAccess. History: + // HasAccess applied uniformly across this region (Admin's Global scope is a no-op). Store originally + // checked ownership here (CanAccessProduct) and Vendor did too (CheckAccessToProduct), but neither + // host checked ProductAttributeMappingPopup(POST) or ProductAttributeValidationRulesPopup(POST) at + // all - a store/vendor user could edit an attribute mapping's name/values or its validation rules on + // any product (not just their own/in-scope one) by posting a known productId. Vendor's + // ProductAttributeMappingPopup(POST) had no check either, despite its own GET sibling checking. + if (!await scope.CanView(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var attributesModel = await productViewModelService.PrepareProductAttributeMappingModels(product); + var gridModel = new DataSourceResult { + Data = attributesModel, + Total = attributesModel.Count + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task ProductAttributeMappingPopup(string productId, string productAttributeMappingId) + { + var product = await productService.GetProductById(productId); + + // See the List-action comment above. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (string.IsNullOrEmpty(productAttributeMappingId)) + { + var model = await productViewModelService.PrepareProductAttributeMappingModel(product); + return View(model); + } + + var productAttributeMapping = + product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); + var editModel = await productViewModelService.PrepareProductAttributeMappingModel(product, + productAttributeMapping); + return View(editModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductAttributeMappingPopup(ProductModel.ProductAttributeMappingModel model) + { + if (ModelState.IsValid) + { + var product = await productService.GetProductById(model.ProductId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // HasAccess here too: Vendor's original never checked ownership on this POST at all (only its + // GET sibling did), letting a vendor insert/update an attribute mapping on any product. See the + // List-action comment. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (string.IsNullOrEmpty(model.Id)) + await productViewModelService.InsertProductAttributeMappingModel(model); + else + await productViewModelService.UpdateProductAttributeMappingModel(model); + + return Content(""); + } + + Error(ModelState); + model = await productViewModelService.PrepareProductAttributeMappingModel(model); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductAttributeMappingDelete(string id, string productId, + [FromServices] IProductAttributeService productAttributeService) + { + var product = await productService.GetProductById(productId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + var productAttributeMapping = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == id); + if (productAttributeMapping == null) + throw new ArgumentException("No product attribute mapping found with the specified id"); + + // See the List-action comment above. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + await productAttributeService.DeleteProductAttributeMapping(productAttributeMapping, product.Id); + return new JsonResult(""); + } + + //edit + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task ProductAttributeValidationRulesPopup(string id, string productId) + { + var product = await productService.GetProductById(productId); + + // See the List-action comment above. Store's original used ErrorForKendoGridJson here even though + // this action returns a View (not a grid), which would have rendered raw JSON as page content on + // denial - using Content(...) instead, consistent with the other popup GET action in this region. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var productAttributeMapping = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == id); + if (productAttributeMapping == null) + return Content("No attribute value found with the specified id"); + + var model = await productViewModelService.PrepareProductAttributeMappingModel(productAttributeMapping); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductAttributeValidationRulesPopup( + ProductModel.ProductAttributeMappingModel model) + { + var product = await productService.GetProductById(model.ProductId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + var productAttributeMapping = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == model.Id); + if (productAttributeMapping == null) + throw new ArgumentException("No attribute value found with the specified id"); + + // HasAccess here too: none of the three original hosts checked ownership on this POST at all - + // a store/vendor user could update an attribute mapping's validation rules (min/max length, + // allowed file extensions, default value) on any product by posting a known productId/model.Id. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + await productViewModelService.UpdateProductAttributeValidationRulesModel(productAttributeMapping, model); + return Content(""); + } + + Error(ModelState); + model = await productViewModelService.PrepareProductAttributeMappingModel(productAttributeMapping); + return View(model); + } + + #endregion + + #region Product attributes. Condition + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task ProductAttributeConditionPopup(string productId, string productAttributeMappingId) + { + var product = await productService.GetProductById(productId); + + // HasAccess applied uniformly across this region (Admin's Global scope is a no-op). Admin's original + // had no ownership check on either action of this region. See the "Product attributes" region above + // for the same pattern. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var productAttributeMapping = + product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); + if (productAttributeMapping == null) + //No attribute value found with the specified id + return Content("No attribute value found with the specified id"); + + var model = await productViewModelService.PrepareProductAttributeConditionModel(product, + productAttributeMapping); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductAttributeConditionPopup(ProductAttributeConditionModel model) + { + var product = await productService.GetProductById(model.ProductId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + var productAttributeMapping = + product.ProductAttributeMappings.FirstOrDefault(x => x.Id == model.ProductAttributeMappingId); + if (productAttributeMapping == null) + return Content("No attribute value found with the specified id"); + + // ModelState.IsValid: Vendor's original wrapped this action in a ModelState.IsValid check and + // returned ModelState.GetErrors() on failure; Admin's and Store's originals had no such check. + // That check was not inert: Vendor's ProductAttributeConditionModel implements the + // IProductValidVendor marker interface, which the global ValidationFilter resolves to + // ProductValidVendor (a FluentValidation rule checking product.VendorId == CurrentVendor.Id) - + // real ownership enforcement, not a no-op. Dropping it here is safe because the scope.HasAccess + // (product) call below now performs the equivalent check directly against the loaded entity, + // which HasAccess.cs's own doc comment argues is more robust than re-deriving ownership from a + // request field. + // + // HasAccess here too: Vendor's original never checked ownership on this POST at all (only its GET + // sibling did, via CheckAccessToProduct), letting a vendor update an attribute condition on any + // product by posting a known productId/productAttributeMappingId. Admin's original had no check on + // either action. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + await productViewModelService.UpdateProductAttributeConditionModel(product, productAttributeMapping, model); + return Content(""); + } + + #endregion + + #region Product attribute values + + //list + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task EditAttributeValues(string productAttributeMappingId, string productId, + [FromServices] IProductAttributeService productAttributeService) + { + var product = await productService.GetProductById(productId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // HasAccess applied uniformly across this region (Admin's Global scope is a no-op). Admin's + // original had no ownership check on this action at all. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var productAttributeMapping = + product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); + if (productAttributeMapping == null) + throw new ArgumentException("No product attribute mapping found with the specified id"); + + var productAttribute = + await productAttributeService.GetProductAttributeById(productAttributeMapping.ProductAttributeId); + var model = new ProductModel.ProductAttributeValueListModel { + ProductName = product.Name, + ProductId = product.Id, + ProductAttributeName = productAttribute.Name, + ProductAttributeMappingId = productAttributeMappingId + }; + + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductAttributeValueList(string productAttributeMappingId, string productId, + DataSourceRequest command) + { + var product = await productService.GetProductById(productId); + + // See the EditAttributeValues comment above. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson( + translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var productAttributeMapping = + product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); + if (productAttributeMapping == null) + throw new ArgumentException("No product attribute mapping found with the specified id"); + + var values = + await productViewModelService.PrepareProductAttributeValueModels(product, productAttributeMapping); + var gridModel = new DataSourceResult { + Data = values, + Total = values.Count + }; + return Json(gridModel); + } + + //create + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task ProductAttributeValueCreatePopup(string productAttributeMappingId, + string productId) + { + var product = await productService.GetProductById(productId); + + // Content(...), not ErrorForKendoGridJson: Store's original used ErrorForKendoGridJson here even + // though this action returns a View (not a grid), which would have rendered raw JSON as page + // content on denial - using Content(...) instead, consistent with the sibling GET popup actions + // in this region and elsewhere in this file. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var productAttributeMapping = + product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); + if (productAttributeMapping == null) + throw new ArgumentException("No product attribute mapping found with the specified id"); + + var model = + await productViewModelService.PrepareProductAttributeValueModel(product, productAttributeMapping); + //locales + await AddLocales(languageService, model.Locales); + + return View(model); + } + + [HttpPost] + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task ProductAttributeValueCreatePopup(ProductModel.ProductAttributeValueModel model) + { + var product = await productService.GetProductById(model.ProductId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // HasAccess added explicitly rather than relying on validation-layer side effects. Admin has no + // ownership concept at all (GlobalAdminDataScope.HasAccess is always true), so Admin was never at + // risk here despite having neither an explicit check nor a validator. Store's shared + // ProductAttributeValueModelValidator (BaseStoreAccessValidator<...>) enforces ownership whenever + // StaffStoreId is set, so Store's original had validator-layer coverage. The actual risk this + // guards against is to VENDOR: Vendor's original model implements IProductValidVendor, so the + // global ValidationFilter resolves IValidator (ProductValidVendor) and adds a + // ModelState error when product.VendorId doesn't match the current vendor - a real check, not a + // no-op - but ValidationFilter never short-circuits a non-JSON POST (see + // ValidationFilter.OnActionExecutionAsync), so that protection only actually held because Vendor's + // original action gated the insert behind `if (ModelState.IsValid)`. Once this action moves to the + // shared AdminShared model (which does not implement IProductValidVendor), Vendor would silently + // lose that guard in the merge unless replaced - scope.HasAccess is that replacement, and now + // applies uniformly (a no-op for Admin, equivalent-or-stronger for Store/Vendor) instead of leaning + // on a marker-interface side effect that only one host had. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var productAttributeMapping = + product.ProductAttributeMappings.FirstOrDefault(x => x.Id == model.ProductAttributeMappingId); + if (productAttributeMapping == null) + //No product attribute found with the specified id + return RedirectToAction("List", "Product"); + + if (ModelState.IsValid) + { + await productViewModelService.InsertProductAttributeValueModel(model); + return Content(""); + } + + //If we got this far, something failed, redisplay form + await productViewModelService.PrepareProductAttributeValueModel(product, model); + return View(model); + } + + //edit + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task ProductAttributeValueEditPopup(string id, string productId, + string productAttributeMappingId) + { + var product = await productService.GetProductById(productId); + + // See the EditAttributeValues comment above. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var pa = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); + if (pa == null) + return RedirectToAction("List", "Product"); + + var pav = pa.ProductAttributeValues.FirstOrDefault(x => x.Id == id); + if (pav == null) + //No attribute value found with the specified id + return RedirectToAction("List", "Product"); + + var model = await productViewModelService.PrepareProductAttributeValueModel(pa, pav); + //locales + await AddLocales(languageService, model.Locales, (locale, languageId) => + { + locale.Name = pav.GetTranslation(x => x.Name, languageId, false); + }); + //pictures + await productViewModelService.PrepareProductAttributeValueModel(product, model); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductAttributeValueEditPopup(string productId, + ProductModel.ProductAttributeValueModel model) + { + var product = await productService.GetProductById(productId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // See the ProductAttributeValueCreatePopup(POST) comment above re: the validator's coverage gap - + // the same reasoning applies here (this action shares the same model type). Unlike CreatePopup(POST), + // Vendor's original for THIS action already had an explicit inline check + // (`if (product == null || !_contextAccessor.WorkContext.HasAccessToProduct(product)) throw ...`), + // so scope.HasAccess is a mechanical substitution there, not a fix. Admin's original had no check + // on either action of this pair. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var pav = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == model.ProductAttributeMappingId) + ?.ProductAttributeValues.FirstOrDefault(x => x.Id == model.Id); + if (pav == null) + //No attribute value found with the specified id + return RedirectToAction("List", "Product"); + + if (ModelState.IsValid) + { + await productViewModelService.UpdateProductAttributeValueModel(pav, model); + return Content(""); + } + + //If we got this far, something failed, redisplay form + await productViewModelService.PrepareProductAttributeValueModel(product, model); + return View(model); + } + + //delete + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductAttributeValueDelete(string id, string pam, string productId, + [FromServices] IProductAttributeService productAttributeService) + { + var product = await productService.GetProductById(productId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // HasAccess added here: this action takes only simple string parameters (no complex-typed POST + // body), so none of the model-level validators discussed above ever run for it. Admin's original + // had no ownership check at all; Vendor's/Store's explicit inline checks are what scope.HasAccess + // now replaces uniformly. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson( + translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var pav = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == pam)?.ProductAttributeValues + .FirstOrDefault(x => x.Id == id); + if (pav == null) + throw new ArgumentException("No product attribute value found with the specified id"); + + if (ModelState.IsValid) + { + await productAttributeService.DeleteProductAttributeValue(pav, productId, pam); + return new JsonResult(""); + } + + return ErrorForKendoGridJson(ModelState); + } + + public async Task AssociateProductToAttributeValuePopup() + { + // PrepareAssociateProductToAttributeValueModel() now reads scope internally (Task 9): + // StaffStoreId for Store; null for Admin/Vendor (no store concept). + var model = + await productViewModelService.PrepareAssociateProductToAttributeValueModel(); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task AssociateProductToAttributeValuePopupList(DataSourceRequest command, + ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel model) + { + if (scope.DefaultStoreId is not null) model.SearchStoreId = scope.DefaultStoreId; + + var (products, totalCount) = + await productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); + var gridModel = new DataSourceResult { + Data = products.ToList(), + Total = totalCount + }; + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task AssociateProductToAttributeValuePopup( + ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel model) + { + var associatedProduct = await productService.GetProductById(model.AssociatedToProductId); + if (associatedProduct == null) + return Content("Cannot load a product"); + + // HasAccess on the associated product: Admin's original had no check here. Vendor's/Store's + // originals checked ownership of the *associated* product (the one about to be referenced as an + // AssociatedToProductId value) - there is no separate "owning" product in scope for this action, so + // the associated product is the only entity available to check. + if (!await scope.HasAccess(associatedProduct)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + return Content(""); + } + + #endregion +} diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductBidController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductBidController.cs new file mode 100644 index 000000000..73a7a5b3c --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductBidController.cs @@ -0,0 +1,97 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Domain.Permissions; +using Grand.SharedKernel.Extensions; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.AdminShared.Models.Orders; +using Grand.Web.Common.Controllers; +using Grand.Web.Common.DataSource; +using Grand.Web.Common.Localization; +using Grand.Web.Common.Security.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.AdminShared.Controllers; + +[PermissionAuthorize(PermissionSystemName.Products)] +[AutoValidateAntiforgeryToken] +public abstract class BaseProductBidController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IAuctionService auctionService, + IAdminDataScope scope) + : BaseController +{ + #region Bids + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task ListBids(DataSourceRequest command, string productId) + { + var product = await productService.GetProductById(productId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // CanView on this read: a product shared with other stores opens read-only on Store, so its + // tabs must load; the mutating actions below keep the strict HasAccess. History: + // HasAccess: mirrors Store's CanAccessProduct and Vendor's HasAccessToProduct checks; Admin's + // original had no check at all - normalized to scope.HasAccess for all three hosts. + if (!await scope.CanView(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + //a product shared with other stores is shown read-only; its bids and reservations carry + //other stores' customers and orders, so they are listed only for a product this store owns + if (!await scope.HasAccess(product)) + return Json(new DataSourceResult { Data = Array.Empty(), Total = 0 }); + + var (bidModels, totalCount) = + await productViewModelService.PrepareBidMode(productId, command.Page, command.PageSize); + var gridModel = new DataSourceResult { + Data = bidModels.ToList(), + Total = totalCount + }; + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task BidDelete(ProductModel.BidModel model) + { + var product = await productService.GetProductById(model.ProductId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var toDelete = await auctionService.GetBid(model.BidId); + + // Cross-product IDOR closed here, identical shape to ProductReservationDelete above: none of the + // three original hosts verified that the bid being deleted (looked up purely by model.BidId) + // actually belongs to the product just access-checked (model.ProductId). Both are independent, + // attacker-supplied POST fields - Store and Vendor's original code let a caller who owns/has + // access to *any* product pass the access check using that product's id while supplying a BidId + // belonging to a different, unowned product, deleting a bid on a product they have no access to. + if (toDelete != null && toDelete.ProductId != product.Id) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (toDelete != null) + { + if (string.IsNullOrEmpty(toDelete.OrderId)) + { + //delete bid + await auctionService.DeleteBid(toDelete); + return Json(""); + } + + return Json(new DataSourceResult { + Errors = translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Bids.CantDeleteWithOrder") + }); + } + + return Json(new DataSourceResult { Errors = "Bid not exists" }); + } + + #endregion +} diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductBundleController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductBundleController.cs new file mode 100644 index 000000000..858df800c --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductBundleController.cs @@ -0,0 +1,159 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Domain.Permissions; +using Grand.SharedKernel.Extensions; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.AdminShared.Models.Orders; +using Grand.Web.Common.Controllers; +using Grand.Web.Common.DataSource; +using Grand.Web.Common.Localization; +using Grand.Web.Common.Security.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.AdminShared.Controllers; + +[PermissionAuthorize(PermissionSystemName.Products)] +[AutoValidateAntiforgeryToken] +public abstract class BaseProductBundleController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseController +{ + #region Bundle products + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task BundleProductList(DataSourceRequest command, string productId) + { + var product = await productService.GetProductById(productId); + + // CanView on this read: a product shared with other stores opens read-only on Store, so its + // tabs must load; the mutating actions below keep the strict HasAccess. History: + // HasAccess (strict) has the same shape as "Related products"/"Similar products" above - + // mirrors Store's CanAccessProduct check on this action. Applying it uniformly also closes the + // same kind of gap found in those two regions: Vendor's original BundleProductUpdate/Delete/ + // AddPopup(GET/POST) (below) had no ownership check at all - only List checked (via + // CheckAccessToProduct) - letting any vendor mutate another vendor's bundle-product mappings by id. + if (!await scope.CanView(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var bundleProducts = product.BundleProducts.OrderBy(x => x.DisplayOrder); + var bundleProductsModel = new List(); + foreach (var x in bundleProducts) + bundleProductsModel.Add(new ProductModel.BundleProductModel { + Id = x.Id, + ProductBundleId = productId, + ProductId = x.ProductId, + ProductName = (await productService.GetProductById(x.ProductId))?.Name, + DisplayOrder = x.DisplayOrder, + Quantity = x.Quantity + }); + var gridModel = new DataSourceResult { + Data = bundleProductsModel, + Total = bundleProductsModel.Count + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task BundleProductUpdate(ProductModel.BundleProductModel model) + { + var product = await productService.GetProductById(model.ProductBundleId); + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + await productViewModelService.UpdateBundleProductModel(model); + return new JsonResult(""); + } + + return ErrorForKendoGridJson(ModelState); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task BundleProductDelete(ProductModel.BundleProductModel model) + { + var product = await productService.GetProductById(model.ProductBundleId); + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + await productViewModelService.DeleteBundleProductModel(model); + return new JsonResult(""); + } + + return ErrorForKendoGridJson(ModelState); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task BundleProductAddPopup(string productId) + { + // No access check here in any of the three original hosts (Admin/Store/Vendor all open this + // popup unconditionally once the Edit permission is satisfied) - only the mutating POST below + // ties access to a specific product. PrepareBundleProductModel() now reads scope + // internally (Task 9). + var model = await productViewModelService.PrepareBundleProductModel(); + model.ProductId = productId; + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task BundleProductAddPopupList(DataSourceRequest command, + ProductModel.AddBundleProductModel model) + { + if (scope.DefaultStoreId is not null) model.SearchStoreId = scope.DefaultStoreId; + + var (products, totalCount) = + await productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); + var gridModel = new DataSourceResult { + Data = products.ToList(), + Total = totalCount + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task BundleProductAddPopup(ProductModel.AddBundleProductModel model) + { + var product = await productService.GetProductById(model.ProductId); + // HasAccess (strict): mirrors Store's CanAccessProduct check on this action. Vendor's original + // BundleProductAddPopup(POST) had no check at all, letting any vendor add bundle-product mappings + // onto another vendor's product by posting its id - closed here the same way as the + // List/Update/Delete gap above. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + if (model.SelectedProductIds != null) await productViewModelService.InsertBundleProductModel(model); + return Content(""); + } + + return await InvalidBundleProductAddPopupResult(model); + } + + /// Hook for the host-specific invalid-model-state response of the AddPopup(POST) action + /// above. Admin and Store both re-prepare the popup model and return the View; Vendor instead + /// returns Content(ModelState.GetErrors()) - a Vendor-only extension method that AdminShared cannot + /// reference. Default here matches Admin/Store; a future Vendor subclass overrides it once hosts are + /// subclassed onto BaseProductController (Task 11). + protected virtual async Task InvalidBundleProductAddPopupResult(ProductModel.AddBundleProductModel model) + { + Error(ModelState); + model = await productViewModelService.PrepareBundleProductModel(); + return View(model); + } + + #endregion +} diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductCategoryController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductCategoryController.cs new file mode 100644 index 000000000..1c9eba016 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductCategoryController.cs @@ -0,0 +1,121 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Domain.Permissions; +using Grand.SharedKernel.Extensions; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.AdminShared.Models.Orders; +using Grand.Web.Common.Controllers; +using Grand.Web.Common.DataSource; +using Grand.Web.Common.Localization; +using Grand.Web.Common.Security.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.AdminShared.Controllers; + +[PermissionAuthorize(PermissionSystemName.Products)] +[AutoValidateAntiforgeryToken] +public abstract class BaseProductCategoryController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseController +{ + #region Product categories + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task ProductCategoryList(DataSourceRequest command, string productId) + { + var product = await productService.GetProductById(productId); + + // CanView on this read: a product shared with other stores opens read-only on Store, so its + // tabs must load; the mutating actions below keep the strict HasAccess. History: + // HasAccess (strict) mirrors Store's CanAccessProduct (AccessToEntityByStore) and + // Vendor's CheckAccessToProduct (VendorId equality) - both strict rules, both gate this same + // action on their respective hosts. Applying it uniformly also closes a real gap: Vendor's + // original ProductCategoryInsert/Update/Delete (below) had no ownership check at all, letting + // any vendor mutate another vendor's product-category mappings by id. + if (!await scope.CanView(product)) + // Templated, not the literal "Admin.Catalog.Products.Permissions": Task 6's audit (the header + // comment above) only covered the files under migration (the 2 ProductControllers + 2 + // ProductViewModelServices) and found no "Permissions"-suffixed GetResource call in Vendor's + // copies of *those* files - but "Vendor.Catalog.Products.Permissions" genuinely exists at the + // XML resource layer (src/Web/Grand.Web/App_Data/Resources/Upgrade/en_220.xml, consumed by + // Grand.Web.Vendor's ProductValidVendor/BundleProductModelValidator). That audit's scope was + // narrower than "Admin-only" - don't cite it as precedent for skipping templating elsewhere. + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var productCategoriesModel = await productViewModelService.PrepareProductCategoryModel(product); + var gridModel = new DataSourceResult { + Data = productCategoriesModel, + Total = productCategoriesModel.Count + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductCategoryInsert(ProductModel.ProductCategoryModel model) + { + var product = await productService.GetProductById(model.ProductId); + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + try + { + await productViewModelService.InsertProductCategoryModel(model); + return new JsonResult(""); + } + catch (Exception ex) + { + return ErrorForKendoGridJson(ex.Message); + } + + return ErrorForKendoGridJson(ModelState); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductCategoryUpdate(ProductModel.ProductCategoryModel model) + { + var product = await productService.GetProductById(model.ProductId); + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + try + { + await productViewModelService.UpdateProductCategoryModel(model); + return new JsonResult(""); + } + catch (Exception ex) + { + return ErrorForKendoGridJson(ex.Message); + } + + return ErrorForKendoGridJson(ModelState); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductCategoryDelete(ProductModel.ProductCategoryModel model) + { + var product = await productService.GetProductById(model.ProductId); + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + await productViewModelService.DeleteProductCategory(model.Id, model.ProductId); + return new JsonResult(""); + } + + return ErrorForKendoGridJson(ModelState); + } + + #endregion +} diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductCollectionController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductCollectionController.cs new file mode 100644 index 000000000..25de437be --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductCollectionController.cs @@ -0,0 +1,114 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Domain.Permissions; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.AdminShared.Models.Orders; +using Grand.Web.Common.Controllers; +using Grand.Web.Common.DataSource; +using Grand.Web.Common.Localization; +using Grand.Web.Common.Security.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.AdminShared.Controllers; + +[PermissionAuthorize(PermissionSystemName.Products)] +[AutoValidateAntiforgeryToken] +public abstract class BaseProductCollectionController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseController +{ + #region Product collections + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task ProductCollectionList(DataSourceRequest command, string productId) + { + var product = await productService.GetProductById(productId); + + // CanView on this read: a product shared with other stores opens read-only on Store, so its + // tabs must load; the mutating actions below keep the strict HasAccess. History: + // HasAccess (strict) has the same shape as "Product categories" above - mirrors Store's + // CanAccessProduct and Vendor's CheckAccessToProduct gating this action on both hosts. Applying + // it uniformly also closes the same kind of gap found in "Product categories": Store's and + // Vendor's original ProductCollectionInsert/Update/Delete (below) had no ownership check at all + // - only List checked - letting any store manager or vendor mutate another party's + // product-collection mappings by id. + if (!await scope.CanView(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var productCollectionsModel = await productViewModelService.PrepareProductCollectionModel(product); + var gridModel = new DataSourceResult { + Data = productCollectionsModel, + Total = productCollectionsModel.Count + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductCollectionInsert(ProductModel.ProductCollectionModel model) + { + var product = await productService.GetProductById(model.ProductId); + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + try + { + await productViewModelService.InsertProductCollection(model); + return new JsonResult(""); + } + catch (Exception ex) + { + return ErrorForKendoGridJson(ex.Message); + } + + return ErrorForKendoGridJson(ModelState); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductCollectionUpdate(ProductModel.ProductCollectionModel model) + { + var product = await productService.GetProductById(model.ProductId); + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + try + { + await productViewModelService.UpdateProductCollection(model); + return new JsonResult(""); + } + catch (Exception ex) + { + return ErrorForKendoGridJson(ex.Message); + } + + return ErrorForKendoGridJson(ModelState); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductCollectionDelete(ProductModel.ProductCollectionModel model) + { + var product = await productService.GetProductById(model.ProductId); + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + await productViewModelService.DeleteProductCollection(model.Id, model.ProductId); + return new JsonResult(""); + } + + return ErrorForKendoGridJson(ModelState); + } + + #endregion +} diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs index 15eb5161c..754402b01 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductController.cs @@ -8,7 +8,6 @@ using Grand.Business.Core.Utilities.Catalog; using Grand.Domain.Catalog; using Grand.Domain.Common; -using Grand.Domain.Media; using Grand.Domain.Permissions; using Grand.SharedKernel.Extensions; using Grand.Web.AdminShared.Extensions.Mapping; @@ -19,13 +18,10 @@ using Grand.Web.Common.DataSource; using Grand.Web.Common.Extensions; using Grand.Web.Common.Filters; -using Grand.Web.Common.Helpers; using Grand.Web.Common.Localization; using Grand.Web.Common.Security.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Rendering; -using Microsoft.AspNetCore.StaticFiles; namespace Grand.Web.AdminShared.Controllers; @@ -34,14 +30,10 @@ namespace Grand.Web.AdminShared.Controllers; public abstract class BaseProductController( IProductViewModelService productViewModelService, IProductService productService, - IInventoryManageService inventoryManageService, ILanguageService languageService, IRequestTranslationService translationService, - IProductReservationService productReservationService, - IAuctionService auctionService, IDateTimeService dateTimeService, IRequestPermissionService permissionService, - IEnumTranslationService enumTranslationService, IAdminDataScope scope) : BaseController { @@ -362,3034 +354,331 @@ public async Task RequiredProductAddPopupList(DataSourceRequest c #endregion - #region Product categories + #region Purchased with order + // Type note: this method covers Admin and Store only. Both already share + // Grand.Web.AdminShared's IOrderViewModelService and Models.Orders.OrderListModel (see the usings + // at the top of this file). Vendor defines its own, structurally different + // Grand.Web.Vendor.Interfaces.IOrderViewModelService and Grand.Web.Vendor.Models.Orders.OrderListModel + // (no StoreId property at all - Vendor's PrepareOrderModel scopes by + // _contextAccessor.WorkContext.CurrentVendor.Id internally, not via any model field), so it cannot + // bind to this signature. That vendor-id scoping lives entirely inside Vendor's own + // OrderViewModelService, outside anything IAdminDataScope expresses - flagging as a concern + // per the task brief rather than inventing a shared model/service pair. Not virtual: C#'s override + // rules require an exact parameter-type match, so a Vendor override using + // Grand.Web.Vendor.Interfaces.IOrderViewModelService/Models.Orders.OrderListModel could never compile + // as an override of this signature anyway. When Vendor is wired onto this base controller (Task 11), + // its subclass will declare its own PurchasedWithOrders action with `new` to shadow this one (a + // standard, valid ASP.NET Core MVC pattern for a derived controller needing an incompatible signature + // under the same action name), not override it. [PermissionAuthorizeAction(PermissionActionName.Preview)] [HttpPost] - public async Task ProductCategoryList(DataSourceRequest command, string productId) + public async Task PurchasedWithOrders(DataSourceRequest command, string productId, + [FromServices] IOrderViewModelService orderViewModelService) { + if (!await permissionService.Authorize(StandardPermission.ManageOrders)) + return Json(new DataSourceResult { + Data = null, + Total = 0 + }); + var product = await productService.GetProductById(productId); // CanView on this read: a product shared with other stores opens read-only on Store, so its // tabs must load; the mutating actions below keep the strict HasAccess. History: - // HasAccess (strict) mirrors Store's CanAccessProduct (AccessToEntityByStore) and - // Vendor's CheckAccessToProduct (VendorId equality) - both strict rules, both gate this same - // action on their respective hosts. Applying it uniformly also closes a real gap: Vendor's - // original ProductCategoryInsert/Update/Delete (below) had no ownership check at all, letting - // any vendor mutate another vendor's product-category mappings by id. + // HasAccess (strict): mirrors Store's CanAccessProduct check on this action. Admin's original had + // no check at all - GlobalAdminDataScope.HasAccess is a no-op there, so this closes that gap the + // same way as every other row in this task. if (!await scope.CanView(product)) - // Templated, not the literal "Admin.Catalog.Products.Permissions": Task 6's audit (the header - // comment above) only covered the files under migration (the 2 ProductControllers + 2 - // ProductViewModelServices) and found no "Permissions"-suffixed GetResource call in Vendor's - // copies of *those* files - but "Vendor.Catalog.Products.Permissions" genuinely exists at the - // XML resource layer (src/Web/Grand.Web/App_Data/Resources/Upgrade/en_220.xml, consumed by - // Grand.Web.Vendor's ProductValidVendor/BundleProductModelValidator). That audit's scope was - // narrower than "Admin-only" - don't cite it as precedent for skipping templating elsewhere. return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - var productCategoriesModel = await productViewModelService.PrepareProductCategoryModel(product); - var gridModel = new DataSourceResult { - Data = productCategoriesModel, - Total = productCategoriesModel.Count + var model = new OrderListModel { + ProductId = productId, + // DefaultStoreId is the staff member's store for Store (matches its original + // model.StoreId = StaffStoreId) and null for Admin (matches its original, which never set + // StoreId at all). + StoreId = scope.DefaultStoreId }; + var (orderModels, totalCount) = + await orderViewModelService.PrepareOrderModel(model, command.Page, command.PageSize); + var gridModel = new DataSourceResult { + Data = orderModels.ToList(), + Total = totalCount + }; return Json(gridModel); } - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductCategoryInsert(ProductModel.ProductCategoryModel model) - { - var product = await productService.GetProductById(model.ProductId); - if (!await scope.HasAccess(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - try - { - await productViewModelService.InsertProductCategoryModel(model); - return new JsonResult(""); - } - catch (Exception ex) - { - return ErrorForKendoGridJson(ex.Message); - } + #endregion - return ErrorForKendoGridJson(ModelState); - } + #region Reviews - [PermissionAuthorizeAction(PermissionActionName.Edit)] + [PermissionAuthorizeAction(PermissionActionName.Preview)] [HttpPost] - public async Task ProductCategoryUpdate(ProductModel.ProductCategoryModel model) + public async Task Reviews(DataSourceRequest command, string productId, + [FromServices] IProductReviewService productReviewService) { - var product = await productService.GetProductById(model.ProductId); - if (!await scope.HasAccess(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + var product = await productService.GetProductById(productId); - if (ModelState.IsValid) - try - { - await productViewModelService.UpdateProductCategoryModel(model); - return new JsonResult(""); - } - catch (Exception ex) - { - return ErrorForKendoGridJson(ex.Message); - } + // CanView on this read: a product shared with other stores opens read-only on Store, so its + // tabs must load; the mutating actions below keep the strict HasAccess. History: + // HasAccess (strict): mirrors Store's CanAccessProduct and Vendor's CheckAccessToProduct gating + // this action on both hosts. Admin's original had no check at all - GlobalAdminDataScope.HasAccess + // is a no-op there, so this closes that gap the same way as every other row in this task. + if (!await scope.CanView(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - return ErrorForKendoGridJson(ModelState); - } + // DefaultStoreId is the staff member's store for Store (matches its original storeId argument, + // which filtered reviews to the staff member's store) and null - normalized to "" here, matching + // GetAllProductReviews's expected "no filter" value - for both Admin and Vendor (matches their + // originals, which both passed "" literally; Vendor scopes by VendorId via the HasAccess check + // above, not by store, since VendorProductDataScope.DefaultStoreId is null). + var storeId = scope.DefaultStoreId ?? ""; - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductCategoryDelete(ProductModel.ProductCategoryModel model) - { - var product = await productService.GetProductById(model.ProductId); - if (!await scope.HasAccess(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + var productReviews = await productReviewService.GetAllProductReviews("", null, + null, null, "", storeId, productId); - if (ModelState.IsValid) + var items = new List(); + foreach (var item in productReviews.PagedForCommand(command)) { - await productViewModelService.DeleteProductCategory(model.Id, model.ProductId); - return new JsonResult(""); + var m = new ProductReviewModel(); + await productViewModelService.PrepareProductReviewModel(m, item, false, true); + items.Add(m); } - return ErrorForKendoGridJson(ModelState); + var gridModel = new DataSourceResult { + Data = items, + Total = productReviews.Count + }; + + return Json(gridModel); } #endregion - #region Product collections + #region Export / Import - [PermissionAuthorizeAction(PermissionActionName.Preview)] + [PermissionAuthorizeAction(PermissionActionName.Export)] [HttpPost] - public async Task ProductCollectionList(DataSourceRequest command, string productId) + public async Task ExportExcelAll(ProductListModel model, + [FromServices] IExportManager exportManager) { - var product = await productService.GetProductById(productId); - - // CanView on this read: a product shared with other stores opens read-only on Store, so its - // tabs must load; the mutating actions below keep the strict HasAccess. History: - // HasAccess (strict) has the same shape as "Product categories" above - mirrors Store's - // CanAccessProduct and Vendor's CheckAccessToProduct gating this action on both hosts. Applying - // it uniformly also closes the same kind of gap found in "Product categories": Store's and - // Vendor's original ProductCollectionInsert/Update/Delete (below) had no ownership check at all - // - only List checked - letting any store manager or vendor mutate another party's - // product-collection mappings by id. - if (!await scope.CanView(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var productCollectionsModel = await productViewModelService.PrepareProductCollectionModel(product); - var gridModel = new DataSourceResult { - Data = productCollectionsModel, - Total = productCollectionsModel.Count - }; + // vendorId is forced inside PrepareProducts itself (scope.DefaultVendorId ?? model.SearchVendorId), + // but storeId is not - every other caller of PrepareProductsModel/PrepareProducts in this file + // forces it here, at the call site, the same way ProductList does above. This one was missing it: + // a Store-scoped user could POST an arbitrary (or blank) SearchStoreId and export every store's + // products, not just their own. Found during a post-Phase-2 tenant-isolation audit. + if (scope.DefaultStoreId is not null) model.SearchStoreId = scope.DefaultStoreId; - return Json(gridModel); + var products = await productViewModelService.PrepareProducts(model); + try + { + var bytes = await exportManager.Export(products, HttpContext.RequestAborted); + return File(bytes, "text/xls", "products.xlsx"); + } + catch (Exception exc) when (!IsRequestAborted(exc)) + { + Error(exc); + return RedirectToAction("List"); + } } - [PermissionAuthorizeAction(PermissionActionName.Edit)] + [PermissionAuthorizeAction(PermissionActionName.Export)] [HttpPost] - public async Task ProductCollectionInsert(ProductModel.ProductCollectionModel model) + public async Task ExportExcelSelected(string selectedIds, + [FromServices] IExportManager exportManager) { - var product = await productService.GetProductById(model.ProductId); - if (!await scope.HasAccess(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + var products = new List(); + if (selectedIds != null) + { + var ids = selectedIds + .Split([','], StringSplitOptions.RemoveEmptyEntries) + .Select(x => x) + .ToArray(); + products.AddRange(await productService.GetProductsByIds(ids, access: CatalogAccess.IncludingHidden())); + } - if (ModelState.IsValid) - try - { - await productViewModelService.InsertProductCollection(model); - return new JsonResult(""); - } - catch (Exception ex) - { - return ErrorForKendoGridJson(ex.Message); - } + // Unlike ExportExcelAll, selectedIds is caller-supplied and not derived from a scoped search - + // Vendor's original explicitly re-checked HasAccessToProduct per id for exactly this reason + // (a vendor could otherwise pass another vendor's product id and export it). Admin's original had + // no check at all (GlobalAdminDataScope.HasAccess is a no-op there), so applying the filter + // unconditionally closes that gap the same way as every other row in this task, without changing + // Admin's or Store's observable behavior. + var scoped = new List(); + foreach (var product in products) + if (await scope.HasAccess(product)) + scoped.Add(product); - return ErrorForKendoGridJson(ModelState); + var bytes = await exportManager.Export(scoped, HttpContext.RequestAborted); + return File(bytes, "text/xls", "products.xlsx"); } - [PermissionAuthorizeAction(PermissionActionName.Edit)] + // Global scope only, as for brands, categories and collections: the importer is not store- or + // vendor-aware. + // + // Concern (flagged, not fixed - out of scope for this row): ImportExcel only checks + // `importexcelfile.Length > 0` before handing the raw stream to IImportManager.Import. + // There is no file-extension allowlist and no upper bound on Length before the stream is read into + // memory by the importer. Commit a153496a6 hardened exactly this shape of gap (memory DoS + extension + // bypass) for attribute file uploads; this action has the same shape and was not touched by that fix. + // This is pre-existing behavior being ported verbatim, not something introduced by this migration - + // worth a follow-up ticket, not a silent fix here. + [PermissionAuthorizeAction(PermissionActionName.Import)] [HttpPost] - public async Task ProductCollectionUpdate(ProductModel.ProductCollectionModel model) + public async Task ImportExcel(IFormFile importexcelfile, + [FromServices] IImportManager importManager) { - var product = await productService.GetProductById(model.ProductId); - if (!await scope.HasAccess(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + try + { + if (Scope.DefaultStoreId is not null || Scope.DefaultVendorId is not null) + { + Error(translationService.GetResource("Admin.AccessDenied.Title")); + return RedirectToAction("List"); + } - if (ModelState.IsValid) - try + if (importexcelfile is { Length: > 0 }) { - await productViewModelService.UpdateProductCollection(model); - return new JsonResult(""); + await importManager.Import(importexcelfile.OpenReadStream(), HttpContext.RequestAborted); } - catch (Exception ex) + else { - return ErrorForKendoGridJson(ex.Message); + Error(translationService.GetResource("Admin.Common.UploadFile")); + return RedirectToAction("List"); } - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductCollectionDelete(ProductModel.ProductCollectionModel model) - { - var product = await productService.GetProductById(model.ProductId); - if (!await scope.HasAccess(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - if (ModelState.IsValid) + Success(translationService.GetResource("Admin.Catalog.Products.Imported")); + return RedirectToAction("List"); + } + catch (Exception exc) when (!IsRequestAborted(exc)) { - await productViewModelService.DeleteProductCollection(model.Id, model.ProductId); - return new JsonResult(""); + Error(exc); + return RedirectToAction("List"); } - - return ErrorForKendoGridJson(ModelState); } #endregion - #region Related products + #region Bulk editing [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task RelatedProductList(DataSourceRequest command, string productId) + public async Task BulkEdit() { - var product = await productService.GetProductById(productId); - - // CanView on this read: a product shared with other stores opens read-only on Store, so its - // tabs must load; the mutating actions below keep the strict HasAccess. History: - // HasAccess (strict) has the same shape as "Product categories"/"Product collections" - - // mirrors Store's CanAccessProduct and Vendor's CheckAccessToProduct gating this action on both - // hosts. Applying it uniformly also closes the same kind of gap found in those two regions: - // Vendor's original RelatedProductUpdate/Delete/AddPopup(POST) (below) had no ownership check at - // all - only List checked - letting any vendor mutate another vendor's related-product mappings - // by id. - if (!await scope.CanView(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + // PrepareBulkEditListModel() now reads scope internally (Task 9): null for Admin/Vendor + // (not store-scoped), StaffStoreId for Store. + var model = await productViewModelService.PrepareBulkEditListModel(); + return View(model); + } - var relatedProducts = product.RelatedProducts.OrderBy(x => x.DisplayOrder); - var relatedProductsModel = new List(); - foreach (var x in relatedProducts) - relatedProductsModel.Add(new ProductModel.RelatedProductModel { - Id = x.Id, - ProductId1 = productId, - ProductId2 = x.ProductId2, - Product2Name = (await productService.GetProductById(x.ProductId2))?.Name, - DisplayOrder = x.DisplayOrder - }); + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task BulkEditSelect(DataSourceRequest command, BulkEditListModel model) + { + if (scope.DefaultStoreId is not null) model.SearchStoreId = scope.DefaultStoreId; + // BLOCKING PREREQUISITE for Task 11 (see plan's Task 10 PrepareBulkEditProductModel row and + // Task 11's blocking-prerequisite note, added in commit a8def835a after this gap was flagged and + // verified during review): Vendor's original bulk-edit grid was vendor-scoped - + // PrepareBulkEditProductModel passed vendorId: CurrentVendor.Id into productService.SearchProducts, + // so the grid only ever listed the vendor's own products. AdminShared's version isn't - + // IProductViewModelService.PrepareBulkEditProductModel used here has no vendorId parameter (unlike + // PrepareProductModel(AddProductModel), which supports SearchVendorId - see + // AssociatedProductVendorId above) - and BulkEditListModel carries no SearchVendorId field either. + // Closing this requires a ProductViewModelService/BulkEditListModel change, which this task's + // per-row scope (BaseProductController.cs + tests only) does not permit. Not a security gap by + // itself (a wider listing, not a mutation) - the mutate endpoints below (BulkEditUpdate/ + // BulkEditDelete) are scope-checked per item regardless and never leak another party's product - + // but Task 11 must NOT subclass Vendor onto this base controller until Task 10 adds vendor-scoped + // filtering here, or a vendor would see every vendor's products in this grid. + var (bulkEditProductModels, totalCount) = + await productViewModelService.PrepareBulkEditProductModel(model, command.Page, command.PageSize); var gridModel = new DataSourceResult { - Data = relatedProductsModel, - Total = relatedProductsModel.Count + Data = bulkEditProductModels.ToList(), + Total = totalCount }; - return Json(gridModel); } [PermissionAuthorizeAction(PermissionActionName.Edit)] [HttpPost] - public async Task RelatedProductUpdate(ProductModel.RelatedProductModel model) + public async Task BulkEditUpdate(IEnumerable products) { - var product = await productService.GetProductById(model.ProductId1); - if (!await scope.HasAccess(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - { - await productViewModelService.UpdateRelatedProductModel(model); - return new JsonResult(""); - } + var validProducts = await FilterBulkEditProductsByAccess(products); + if (validProducts.Count > 0) await productViewModelService.UpdateBulkEdit(validProducts); - return ErrorForKendoGridJson(ModelState); + return new JsonResult(""); } - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task RelatedProductDelete(ProductModel.RelatedProductModel model) - { - var product = await productService.GetProductById(model.ProductId1); - if (!await scope.HasAccess(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - { - await productViewModelService.DeleteRelatedProductModel(model); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task RelatedProductAddPopup(string productId) - { - // No access check here in any of the three original hosts (Admin/Store/Vendor all open this - // popup unconditionally once the Edit permission is satisfied) - only the mutating POST below - // ties access to a specific product. PrepareRelatedProductModel() now reads scope - // internally (Task 9). - var model = await productViewModelService.PrepareRelatedProductModel(); - model.ProductId = productId; - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task RelatedProductAddPopupList(DataSourceRequest command, - ProductModel.AddRelatedProductModel model) - { - if (scope.DefaultStoreId is not null) model.SearchStoreId = scope.DefaultStoreId; - - var (products, totalCount) = - await productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = products.ToList(), - Total = totalCount - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task RelatedProductAddPopup(ProductModel.AddRelatedProductModel model) - { - var product = await productService.GetProductById(model.ProductId); - // HasAccess (strict): mirrors Store's CanAccessProduct check on this action. Vendor's original - // RelatedProductAddPopup(POST) had no check at all, letting any vendor add related-product - // mappings onto another vendor's product by posting its id - closed here the same way as the - // List/Update/Delete gap above. - if (!await scope.HasAccess(product)) - return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - { - if (model.SelectedProductIds != null) await productViewModelService.InsertRelatedProductModel(model); - return Content(""); - } - - return await InvalidRelatedProductAddPopupResult(model); - } - - /// Hook for the host-specific invalid-model-state response of the AddPopup(POST) action - /// above. Admin and Store both re-prepare the popup model and return the View; Vendor instead - /// returns Content(ModelState.GetErrors()) - a Vendor-only extension method that AdminShared cannot - /// reference. Default here matches Admin/Store; a future Vendor subclass overrides it once hosts are - /// subclassed onto BaseProductController (Task 11). - protected virtual async Task InvalidRelatedProductAddPopupResult(ProductModel.AddRelatedProductModel model) - { - Error(ModelState); - model = await productViewModelService.PrepareRelatedProductModel(); - return View(model); - } - - #endregion - - #region Similar products - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task SimilarProductList(DataSourceRequest command, string productId) - { - var product = await productService.GetProductById(productId); - - // CanView on this read: a product shared with other stores opens read-only on Store, so its - // tabs must load; the mutating actions below keep the strict HasAccess. History: - // HasAccess (strict) has the same shape as "Related products" above - mirrors Store's - // CanAccessProduct check on this action. Applying it uniformly also closes a real gap: Vendor's - // original SimilarProductUpdate/Delete/AddPopup(GET/POST) (below) had no ownership check at all - - // only List checked (via CheckAccessToProduct) - letting any vendor mutate another vendor's - // similar-product mappings by id. - if (!await scope.CanView(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var similarProducts = product.SimilarProducts.OrderBy(x => x.DisplayOrder); - var similarProductsModel = new List(); - foreach (var x in similarProducts) - similarProductsModel.Add(new ProductModel.SimilarProductModel { - Id = x.Id, - ProductId1 = productId, - ProductId2 = x.ProductId2, - Product2Name = (await productService.GetProductById(x.ProductId2))?.Name, - DisplayOrder = x.DisplayOrder - }); - - var gridModel = new DataSourceResult { - Data = similarProductsModel, - Total = similarProductsModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task SimilarProductUpdate(ProductModel.SimilarProductModel model) - { - var product = await productService.GetProductById(model.ProductId1); - if (!await scope.HasAccess(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - { - await productViewModelService.UpdateSimilarProductModel(model); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task SimilarProductDelete(ProductModel.SimilarProductModel model) - { - var product = await productService.GetProductById(model.ProductId1); - if (!await scope.HasAccess(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - { - await productViewModelService.DeleteSimilarProductModel(model); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task SimilarProductAddPopup(string productId) - { - // No access check here in any of the three original hosts (Admin/Store/Vendor all open this - // popup unconditionally once the Edit permission is satisfied) - only the mutating POST below - // ties access to a specific product. PrepareSimilarProductModel() now reads scope - // internally (Task 9). - var model = await productViewModelService.PrepareSimilarProductModel(); - model.ProductId = productId; - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task SimilarProductAddPopupList(DataSourceRequest command, - ProductModel.AddSimilarProductModel model) - { - if (scope.DefaultStoreId is not null) model.SearchStoreId = scope.DefaultStoreId; - - var (products, totalCount) = - await productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = products.ToList(), - Total = totalCount - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task SimilarProductAddPopup(ProductModel.AddSimilarProductModel model) - { - var product = await productService.GetProductById(model.ProductId); - // HasAccess (strict): mirrors Store's CanAccessProduct check on this action. Vendor's original - // SimilarProductAddPopup(POST) had no check at all, letting any vendor add similar-product - // mappings onto another vendor's product by posting its id - closed here the same way as the - // List/Update/Delete gap above. - if (!await scope.HasAccess(product)) - return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - { - if (model.SelectedProductIds != null) await productViewModelService.InsertSimilarProductModel(model); - return Content(""); - } - - return await InvalidSimilarProductAddPopupResult(model); - } - - /// Hook for the host-specific invalid-model-state response of the AddPopup(POST) action - /// above. Admin and Store both re-prepare the popup model and return the View; Vendor instead - /// returns Content(ModelState.GetErrors()) - a Vendor-only extension method that AdminShared cannot - /// reference. Default here matches Admin/Store; a future Vendor subclass overrides it once hosts are - /// subclassed onto BaseProductController (Task 11). - protected virtual async Task InvalidSimilarProductAddPopupResult(ProductModel.AddSimilarProductModel model) - { - Error(ModelState); - model = await productViewModelService.PrepareSimilarProductModel(); - return View(model); - } - - #endregion - - #region Bundle products - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task BundleProductList(DataSourceRequest command, string productId) - { - var product = await productService.GetProductById(productId); - - // CanView on this read: a product shared with other stores opens read-only on Store, so its - // tabs must load; the mutating actions below keep the strict HasAccess. History: - // HasAccess (strict) has the same shape as "Related products"/"Similar products" above - - // mirrors Store's CanAccessProduct check on this action. Applying it uniformly also closes the - // same kind of gap found in those two regions: Vendor's original BundleProductUpdate/Delete/ - // AddPopup(GET/POST) (below) had no ownership check at all - only List checked (via - // CheckAccessToProduct) - letting any vendor mutate another vendor's bundle-product mappings by id. - if (!await scope.CanView(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var bundleProducts = product.BundleProducts.OrderBy(x => x.DisplayOrder); - var bundleProductsModel = new List(); - foreach (var x in bundleProducts) - bundleProductsModel.Add(new ProductModel.BundleProductModel { - Id = x.Id, - ProductBundleId = productId, - ProductId = x.ProductId, - ProductName = (await productService.GetProductById(x.ProductId))?.Name, - DisplayOrder = x.DisplayOrder, - Quantity = x.Quantity - }); - var gridModel = new DataSourceResult { - Data = bundleProductsModel, - Total = bundleProductsModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task BundleProductUpdate(ProductModel.BundleProductModel model) - { - var product = await productService.GetProductById(model.ProductBundleId); - if (!await scope.HasAccess(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - { - await productViewModelService.UpdateBundleProductModel(model); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task BundleProductDelete(ProductModel.BundleProductModel model) - { - var product = await productService.GetProductById(model.ProductBundleId); - if (!await scope.HasAccess(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - { - await productViewModelService.DeleteBundleProductModel(model); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task BundleProductAddPopup(string productId) - { - // No access check here in any of the three original hosts (Admin/Store/Vendor all open this - // popup unconditionally once the Edit permission is satisfied) - only the mutating POST below - // ties access to a specific product. PrepareBundleProductModel() now reads scope - // internally (Task 9). - var model = await productViewModelService.PrepareBundleProductModel(); - model.ProductId = productId; - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task BundleProductAddPopupList(DataSourceRequest command, - ProductModel.AddBundleProductModel model) - { - if (scope.DefaultStoreId is not null) model.SearchStoreId = scope.DefaultStoreId; - - var (products, totalCount) = - await productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = products.ToList(), - Total = totalCount - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task BundleProductAddPopup(ProductModel.AddBundleProductModel model) - { - var product = await productService.GetProductById(model.ProductId); - // HasAccess (strict): mirrors Store's CanAccessProduct check on this action. Vendor's original - // BundleProductAddPopup(POST) had no check at all, letting any vendor add bundle-product mappings - // onto another vendor's product by posting its id - closed here the same way as the - // List/Update/Delete gap above. - if (!await scope.HasAccess(product)) - return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - { - if (model.SelectedProductIds != null) await productViewModelService.InsertBundleProductModel(model); - return Content(""); - } - - return await InvalidBundleProductAddPopupResult(model); - } - - /// Hook for the host-specific invalid-model-state response of the AddPopup(POST) action - /// above. Admin and Store both re-prepare the popup model and return the View; Vendor instead - /// returns Content(ModelState.GetErrors()) - a Vendor-only extension method that AdminShared cannot - /// reference. Default here matches Admin/Store; a future Vendor subclass overrides it once hosts are - /// subclassed onto BaseProductController (Task 11). - protected virtual async Task InvalidBundleProductAddPopupResult(ProductModel.AddBundleProductModel model) - { - Error(ModelState); - model = await productViewModelService.PrepareBundleProductModel(); - return View(model); - } - - #endregion - - #region Cross-sell products - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task CrossSellProductList(DataSourceRequest command, string productId) - { - var product = await productService.GetProductById(productId); - - // CanView on this read: a product shared with other stores opens read-only on Store, so its - // tabs must load; the mutating actions below keep the strict HasAccess. History: - // HasAccess (strict) has the same shape as "Related products"/"Bundle products" above - - // mirrors Store's CanAccessProduct and Vendor's CheckAccessToProduct gating this action on both - // hosts. Admin's original CrossSellProductList had no check at all - applying HasAccess uniformly - // also closes that gap without changing Admin's superuser behaviour (HasAccess is a no-op for - // Admin's scope). - if (!await scope.CanView(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var crossSellProducts = product.CrossSellProduct; - var crossSellProductsModel = new List(); - foreach (var x in crossSellProducts) - crossSellProductsModel.Add(new ProductModel.CrossSellProductModel { - Id = x, - ProductId = product.Id, - Product2Name = (await productService.GetProductById(x))?.Name - }); - var gridModel = new DataSourceResult { - Data = crossSellProductsModel, - Total = crossSellProductsModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task CrossSellProductDelete(ProductModel.CrossSellProductModel model) - { - var product = await productService.GetProductById(model.ProductId); - if (product == null) throw new ArgumentException("Product not exists"); - - // HasAccess (strict): mirrors Store's CanAccessProduct check on this action. Vendor's original - // CrossSellProductDelete had no check at all, letting any vendor delete another vendor's - // cross-sell-product mappings by id - closed here the same way as the List gap above. - if (!await scope.HasAccess(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var crossSellProduct = product.CrossSellProduct.FirstOrDefault(x => x == model.Id); - if (string.IsNullOrEmpty(crossSellProduct)) - throw new ArgumentException("No cross-sell product found with the specified id"); - - if (ModelState.IsValid) - { - await productViewModelService.DeleteCrossSellProduct(product.Id, crossSellProduct); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task CrossSellProductAddPopup(string productId) - { - // No access check here in any of the three original hosts (Admin/Store/Vendor all open this - // popup unconditionally once the Edit permission is satisfied) - only the mutating POST below - // ties access to a specific product. PrepareCrossSellProductModel() now reads scope - // internally (Task 9). - var model = await productViewModelService.PrepareCrossSellProductModel(); - model.ProductId = productId; - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task CrossSellProductAddPopupList(DataSourceRequest command, - ProductModel.AddCrossSellProductModel model) - { - if (scope.DefaultStoreId is not null) model.SearchStoreId = scope.DefaultStoreId; - - var (products, totalCount) = - await productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = products.ToList(), - Total = totalCount - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task CrossSellProductAddPopup(ProductModel.AddCrossSellProductModel model) - { - var product = await productService.GetProductById(model.ProductId); - // HasAccess (strict): mirrors Store's CanAccessProduct check on this action. Vendor's original - // CrossSellProductAddPopup(POST) had no check at all, letting any vendor add cross-sell-product - // mappings onto another vendor's product by posting its id - closed here the same way as the - // List/Delete gap above. - if (!await scope.HasAccess(product)) - return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - { - if (model.SelectedProductIds != null) await productViewModelService.InsertCrossSellProductModel(model); - return Content(""); - } - - return await InvalidCrossSellProductAddPopupResult(model); - } - - /// Hook for the host-specific invalid-model-state response of the AddPopup(POST) action - /// above. Admin and Store both re-prepare the popup model and return the View; Vendor instead - /// returns Content(ModelState.GetErrors()) - a Vendor-only extension method that AdminShared cannot - /// reference. Default here matches Admin/Store; a future Vendor subclass overrides it once hosts are - /// subclassed onto BaseProductController (Task 11). - protected virtual async Task InvalidCrossSellProductAddPopupResult(ProductModel.AddCrossSellProductModel model) - { - Error(ModelState); - model = await productViewModelService.PrepareCrossSellProductModel(); - return View(model); - } - - #endregion - - #region Recommended products - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task RecommendedProductList(DataSourceRequest command, string productId) - { - var product = await productService.GetProductById(productId); - - // CanView on this read: a product shared with other stores opens read-only on Store, so its - // tabs must load; the mutating actions below keep the strict HasAccess. History: - // HasAccess (strict) has the same shape as "Cross-sell products" above - mirrors Store's - // CanAccessProduct and Vendor's CheckAccessToProduct gating this action on both hosts. Admin's - // original RecommendedProductList had no check at all - applying HasAccess uniformly also closes - // that gap without changing Admin's superuser behaviour (HasAccess is a no-op for Admin's scope). - // Vendor's original signature also dropped the DataSourceRequest command parameter entirely - // (unused by the body on any host either way) - kept here for parity with Admin/Store. - if (!await scope.CanView(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var recommendedProductsModel = new List(); - foreach (var x in product.RecommendedProduct) - recommendedProductsModel.Add(new ProductModel.RecommendedProductModel { - Id = x, - ProductId = product.Id, - Product2Name = (await productService.GetProductById(x))?.Name - }); - var gridModel = new DataSourceResult { - Data = recommendedProductsModel, - Total = recommendedProductsModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task RecommendedProductDelete(ProductModel.RecommendedProductModel model) - { - var product = await productService.GetProductById(model.ProductId); - if (product == null) throw new ArgumentException("Product not exists"); - - // HasAccess (strict): mirrors Store's CanAccessProduct check on this action. Vendor's original - // RecommendedProductDelete had no check at all, letting any vendor delete another vendor's - // recommended-product mappings by id - closed here the same way as the List gap above. - if (!await scope.HasAccess(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var recommendedProduct = product.RecommendedProduct.FirstOrDefault(x => x == model.Id); - if (string.IsNullOrEmpty(recommendedProduct)) - throw new ArgumentException("No recommended product found with the specified id"); - - if (ModelState.IsValid) - { - await productViewModelService.DeleteRecommendedProduct(product.Id, recommendedProduct); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task RecommendedProductAddPopup(string productId) - { - // No access check here in any of the three original hosts (Admin/Store/Vendor all open this - // popup unconditionally once the Edit permission is satisfied) - only the mutating POST below - // ties access to a specific product. PrepareRecommendedProductModel() now reads scope - // internally (Task 9). - var model = await productViewModelService.PrepareRecommendedProductModel(); - model.ProductId = productId; - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task RecommendedProductAddPopupList(DataSourceRequest command, - ProductModel.AddRecommendedProductModel model) - { - if (scope.DefaultStoreId is not null) model.SearchStoreId = scope.DefaultStoreId; - - var (products, totalCount) = - await productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = products.ToList(), - Total = totalCount - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task RecommendedProductAddPopup(ProductModel.AddRecommendedProductModel model) - { - var product = await productService.GetProductById(model.ProductId); - // HasAccess (strict): mirrors Store's CanAccessProduct check on this action. Vendor's original - // RecommendedProductAddPopup(POST) had no check at all, letting any vendor add recommended-product - // mappings onto another vendor's product by posting its id - closed here the same way as the - // List/Delete gap above. - if (!await scope.HasAccess(product)) - return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - { - if (model.SelectedProductIds != null) await productViewModelService.InsertRecommendedProductModel(model); - return Content(""); - } - - return await InvalidRecommendedProductAddPopupResult(model); - } - - /// Hook for the host-specific invalid-model-state response of the AddPopup(POST) action - /// above. Admin and Store both re-prepare the popup model and return the View; Vendor instead - /// returns Content(ModelState.GetErrors()) - a Vendor-only extension method that AdminShared cannot - /// reference. Default here matches Admin/Store; a future Vendor subclass overrides it once hosts are - /// subclassed onto BaseProductController (Task 11). - protected virtual async Task InvalidRecommendedProductAddPopupResult(ProductModel.AddRecommendedProductModel model) - { - Error(ModelState); - model = await productViewModelService.PrepareRecommendedProductModel(); - return View(model); - } - - #endregion - - #region Associated products - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task AssociatedProductList(DataSourceRequest command, string productId) - { - var product = await productService.GetProductById(productId); - - // CanView on this read: a product shared with other stores opens read-only on Store, so its - // tabs must load; the mutating actions below keep the strict HasAccess. History: - // HasAccess (strict): mirrors Store's CanAccessProduct and Vendor's CheckAccessToProduct gating - // this action on both hosts. Admin's original had no check at all - GlobalAdminDataScope.HasAccess - // is a no-op there, so this closes that gap the same way as every other row in this task. - if (!await scope.CanView(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - // AssociatedProductVendorId (hook below): Vendor's original also passed CurrentVendor.Id into - // GetAssociatedProducts(vendorId:) so a vendor only sees, among a grouped product's full - // associated-product set, the ones they themselves own. That vendor filter is unconditional in - // GetAssociatedProducts regardless of showHidden, unlike its storeId parameter (which only applies - // when showHidden is false - moot here since all three hosts pass showHidden: true). - var associatedProducts = await productService.GetAssociatedProducts(productId, - access: CatalogAccess.IncludingHidden(), - vendorId: AssociatedProductVendorId); - var associatedProductsModel = associatedProducts - .Select(x => new ProductModel.AssociatedProductModel { - Id = x.Id, - ProductId = productId, - ProductName = x.Name, - DisplayOrder = x.DisplayOrder - }) - .ToList(); - - var gridModel = new DataSourceResult { - Data = associatedProductsModel, - Total = associatedProductsModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task AssociatedProductUpdate(ProductModel.AssociatedProductModel model) - { - var associatedProduct = await productService.GetProductById(model.Id); - if (associatedProduct == null) - throw new ArgumentException("No associated product found with the specified id"); - - // HasAccess (strict): mirrors Vendor's inline HasAccessToProduct(associatedProduct) check and - // Store's CanAccessProduct. Admin's original had no check at all. - if (!await scope.HasAccess(associatedProduct)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - { - associatedProduct.DisplayOrder = model.DisplayOrder; - await productService.UpdateAssociatedProduct(associatedProduct); - - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task AssociatedProductDelete(ProductModel.AssociatedProductModel model) - { - var product = await productService.GetProductById(model.Id); - if (product == null) - throw new ArgumentException("No associated product found with the specified id"); - - // HasAccess (strict): mirrors Vendor's inline HasAccessToProduct(product) check and Store's - // CanAccessProduct. Admin's original had no check at all. - if (!await scope.HasAccess(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - { - await productViewModelService.DeleteAssociatedProduct(product); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task AssociatedProductAddPopup(string productId) - { - // No access check here in any of the three original hosts (all open this popup unconditionally - // once the Edit permission is satisfied) - only the mutating actions below tie access to a - // specific product. PrepareAssociatedProductModel() now reads scope internally (Task 9). - var model = await productViewModelService.PrepareAssociatedProductModel(); - model.ProductId = productId; - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task AssociatedProductAddPopupList(DataSourceRequest command, - ProductModel.AddAssociatedProductModel model) - { - if (scope.DefaultStoreId is not null) model.SearchStoreId = scope.DefaultStoreId; - - var (products, totalCount) = - await productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = products.ToList(), - Total = totalCount - }; - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task AssociatedProductAddPopup(ProductModel.AddAssociatedProductModel model) - { - var parentProduct = await productService.GetProductById(model.ProductId); - // HasAccess (strict): mirrors Store's CanAccessProduct(parentProduct) check on this action. - // Vendor's controller had NO check on the parent product at all (see below for the selected-ids - // side). Vendor's own service-layer InsertAssociatedProductModel already filtered each selected - // product via HasAccessToProduct before reparenting it, but never checked the parent - so a vendor - // could attach their own products under another vendor's grouped product (cross-vendor storefront - // pollution / data-integrity issue, not a write to someone else's product record). Closed here the - // same way as every other AddPopup(POST) in this task. - if (!await scope.HasAccess(parentProduct)) - return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - { - // InsertAssociatedProductModel reparents each selected product - it writes - // ParentGroupedProductId directly onto that product's own record - unlike - // Related/Similar/Bundle/Cross-sell/Recommended, whose Insert*ProductModel only adds a mapping - // entry that references the selected product's id from the parent's own list; the selected - // product's record is never itself modified there. So here, unlike those simpler regions, - // every selected id must independently pass HasAccess too - the same per-id filtering Store's - // controller-level original already did, and which Vendor's original also had, but only at the - // service layer (Grand.Web.Vendor/Services/ProductViewModelService.cs InsertAssociatedProductModel: - // `if (product == null || !HasAccessToProduct(product)) continue;`). BaseProductController - // injects AdminShared's IProductViewModelService - the unfiltered variant - not Vendor's own, so - // once Vendor is subclassed onto this base (Task 11) it loses that service-layer filter - // entirely. Enforcing the per-id check here at the controller level is what preserves the - // invariant going forward. - if (model.SelectedProductIds != null) - { - var validIds = new List(); - foreach (var id in model.SelectedProductIds) - { - var selected = await productService.GetProductById(id); - if (await scope.HasAccess(selected)) validIds.Add(id); - } - - model.SelectedProductIds = validIds.ToArray(); - if (validIds.Count > 0) await productViewModelService.InsertAssociatedProductModel(model); - } - - return Content(""); - } - - // Unlike Related/Similar/Bundle/Cross-sell/Recommended, all three original hosts share the exact - // same invalid-model-state handling here (Error(ModelState) + re-prepare + View) - Vendor's - // AssociatedProductAddPopup(POST) does not use the Content(ModelState.GetErrors()) shortcut it - // uses in those other regions, so no host-specific hook is needed for this action. - Error(ModelState); - model = await productViewModelService.PrepareAssociatedProductModel(); - return View(model); - } - - /// Vendor id to filter the associated-products grid by, in addition to the HasAccess gate in - /// AssociatedProductList above. Vendor's original passed CurrentVendor.Id into - /// GetAssociatedProducts(vendorId:) so a vendor only sees the subset of a grouped product's associated - /// products that they themselves own. Admin/Store passed no vendorId (both show every associated - /// product on the parent). Empty here, matching Admin/Store; a future Vendor subclass overrides it - /// once hosts are subclassed onto BaseProductController (Task 11). - protected virtual string AssociatedProductVendorId => ""; - - #endregion - - #region Product pictures - - [HttpPost] - public async Task ProductPictureAdd( - IFormFileCollection files, - Reference reference, string objectId, - [FromServices] IPictureService pictureService, - [FromServices] MediaSettings mediaSettings) - { - if (!await permissionService.Authorize(PermissionSystemName.Pictures)) - return Json(new { - success = false, - message = "Access denied - picture permissions" - }); - - if (reference != Reference.Product || string.IsNullOrEmpty(objectId)) - return Json(new { - success = false, - message = "Please save form before upload new pictures" - }); - - if (!files.Any()) - return Json(new { - success = false, - message = "No files uploaded" - }); - - var product = await productService.GetProductById(objectId); - - if (!await scope.HasAccess(product)) - return Json(new { - success = false, - message = translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions") - }); - - var values = new List<(string pictureUrl, string pictureId)>(); - foreach (var file in files) - { - var fileName = Path.GetFileName(file.FileName); - var contentType = file.ContentType; - var fileExtension = Path.GetExtension(fileName); - if (string.IsNullOrEmpty(contentType)) - _ = new FileExtensionContentTypeProvider().TryGetContentType(fileName, out contentType); - - if (FileExtensions.GetAllowedMediaFileTypes(mediaSettings.AllowedFileTypes).IsAllowedMediaFileType(fileExtension)) - { - var fileBinary = file.GetDownloadBits(); - //insert picture - var picture = await pictureService.InsertPicture(fileBinary, contentType, null, reference: reference, - objectId: objectId); - var pictureUrl = await pictureService.GetPictureUrl(picture); - - values.Add((pictureUrl, picture.Id)); - //assign picture to the product - await productViewModelService.InsertProductPicture(product, picture, 0); - } - } - - return Json(new { success = values.Any(), data = values }); - } - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ProductPictureList(DataSourceRequest command, string productId) - { - var product = await productService.GetProductById(productId); - - // CanView on this read: a product shared with other stores opens read-only on Store, so its - // tabs must load; the mutating actions below keep the strict HasAccess. History: - // HasAccess (strict): mirrors Store's CanAccessProduct and Vendor's CheckAccessToProduct gating - // this action on both hosts. Admin's original had no check at all. Vendor's original signature - // also lacked the unused `DataSourceRequest command` parameter that Admin/Store both bind (Kendo - // posts it, but no host ever reads it) - kept here to match the two-of-three shape; harmless for - // Vendor since an unused extra bound parameter changes nothing about the response. - if (!await scope.CanView(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var productPicturesModel = await productViewModelService.PrepareProductPicturesModel(product); - var gridModel = new DataSourceResult { - Data = productPicturesModel, - Total = productPicturesModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - public async Task ProductPicturePopup(string productId, string id) - { - var product = await productService.GetProductById(productId); - if (product == null) - return Content("Product not exist"); - - // HasAccess (strict): mirrors Store's CanAccessProduct and Vendor's CheckAccessToProduct gating - // this action on both hosts. Admin's original had no check at all. - if (!await scope.HasAccess(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var pp = product.ProductPictures.FirstOrDefault(x => x.Id == id); - if (pp == null) - return Content("Product picture not exist"); - - var (model, picture) = await productViewModelService.PrepareProductPictureModel(product, pp); - //locales - await AddLocales(languageService, model.Locales, (locale, languageId) => - { - locale.AltAttribute = picture?.GetTranslation(x => x.AltAttribute, languageId, false); - locale.TitleAttribute = picture?.GetTranslation(x => x.TitleAttribute, languageId, false); - }); - - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductPicturePopup(ProductModel.ProductPictureModel model) - { - if (ModelState.IsValid) - { - var product = await productService.GetProductById(model.ProductId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - // HasAccess (strict): mirrors Store's CanAccessProduct check on this action. Vendor's original - // ProductPicturePopup(POST) had no check at all, letting any vendor rename/re-alt-text another - // vendor's product picture by posting its productId/model.Id - closed here the same way as the - // other rows in this task. - if (!await scope.HasAccess(product)) - throw new ArgumentException(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - if (product.ProductPictures.FirstOrDefault(x => x.Id == model.Id) == null) - throw new ArgumentException("No product picture found with the specified id"); - - await productViewModelService.UpdateProductPicture(model); - - return Content(""); - } - - Error(ModelState); - - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductPictureDelete(ProductModel.ProductPictureModel model) - { - var product = await productService.GetProductById(model.ProductId); - - // HasAccess (strict): mirrors Store's CanAccessProduct check on this action. Vendor's original - // ProductPictureDelete had no check at all, letting any vendor delete another vendor's product - // picture by posting its productId/model.Id - closed here the same way as ProductPicturePopup(POST) - // above. - if (!await scope.HasAccess(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - { - await productViewModelService.DeleteProductPicture(model); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - #endregion - - #region Product specification attributes - - //ajax - [AcceptVerbs("GET")] - public async Task GetOptionsByAttributeId(string attributeId, - [FromServices] ISpecificationAttributeService specificationAttributeService) - { - if (string.IsNullOrEmpty(attributeId)) - return Json(""); - - var options = - (await specificationAttributeService.GetSpecificationAttributeById(attributeId)) - .SpecificationAttributeOptions.OrderBy(x => x.DisplayOrder); - var result = (from o in options - select new { id = o.Id, name = o.Name }).ToList(); - return Json(result); - } - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ProductSpecAttrList(DataSourceRequest command, string productId) - { - var product = await productService.GetProductById(productId); - - // CanView on this read: a product shared with other stores opens read-only on Store, so its - // tabs must load; the mutating actions below keep the strict HasAccess. History: - // HasAccess (strict): mirrors Store's CanAccessProduct and Vendor's CheckAccessToProduct gating - // this action on both hosts. Admin's original ProductSpecAttrList had no check at all - applying - // HasAccess uniformly also closes that gap without changing Admin's superuser behaviour (HasAccess - // is a no-op for Admin's scope). Vendor's original signature also dropped the DataSourceRequest - // command parameter entirely (unused by the body on any host either way) - kept here for parity - // with Admin/Store. - if (!await scope.CanView(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var productrSpecsModel = await productViewModelService.PrepareProductSpecificationAttributeModel(product); - var gridModel = new DataSourceResult { - Data = productrSpecsModel, - Total = productrSpecsModel.Count - }; - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ProductSpecAttrPopup( - [FromServices] ISpecificationAttributeService specificationAttributeService, - string productId, string id) - { - var product = await productService.GetProductById(productId); - - // HasAccess (strict): mirrors Store's CanAccessProduct and Vendor's CheckAccessToProduct check on - // this action. Admin's original ProductSpecAttrPopup(GET) had no check at all - closed the same - // way as ProductSpecAttrList above. - if (!await scope.HasAccess(product)) - return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var model = new ProductModel.AddProductSpecificationAttributeModel { - //default specs values - ShowOnProductPage = true - }; - - if (!string.IsNullOrEmpty(id)) - { - var specification = product.ProductSpecificationAttributes.FirstOrDefault(x => x.Id == id); - if (specification != null) model = specification.ToModel(); - } - - model.AvailableAttributes = await PrepareAvailableAttributes(specificationAttributeService); - - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductSpecAttrPopup( - [FromServices] ISpecificationAttributeService specificationAttributeService, - ProductModel.AddProductSpecificationAttributeModel model) - { - if (ModelState.IsValid) - { - var product = await productService.GetProductById(model.ProductId); - if (product == null) - return Content("Product not exists"); - - if (!await scope.HasAccess(product)) - return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var psa = product.ProductSpecificationAttributes.FirstOrDefault(x => x.Id == model.Id); - if (psa == null) - await productViewModelService.InsertProductSpecificationAttributeModel(model, product); - else - await productViewModelService.UpdateProductSpecificationAttributeModel(psa, model); - - return new JsonResult(""); - } - - Error(ModelState); - model.AvailableAttributes = await PrepareAvailableAttributes(specificationAttributeService); - - return View(model); - } - - /// scope.DefaultStoreId ?? "" matches Store's PrepareAvailableAttributes(StaffStoreId) call - /// and Admin/Vendor's parameterless call (both pass an empty storeId, seeing every specification - /// attribute regardless of store). - private async Task> PrepareAvailableAttributes( - ISpecificationAttributeService specificationAttributeService) - { - var availableSpecificationAttributes = new List(); - foreach (var sa in await specificationAttributeService.GetSpecificationAttributes(scope.DefaultStoreId ?? "")) - availableSpecificationAttributes.Add(new SelectListItem { - Text = sa.Name, - Value = sa.Id - }); - return availableSpecificationAttributes; - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductSpecAttrDelete(ProductSpecificationAttributeModel model) - { - if (ModelState.IsValid) - { - var product = await productService.GetProductById(model.ProductId); - if (product == null) - return Content("Product not exists"); - - // HasAccess (strict): mirrors Store's CanAccessProduct check on this action. Vendor's original - // ProductSpecAttrDelete had no check at all, letting any vendor delete another vendor's - // specification attribute mapping by posting its productId/model.Id - closed here the same - // way as ProductSpecAttrPopup(POST) above. - if (!await scope.HasAccess(product)) - return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var psa = product.ProductSpecificationAttributes.FirstOrDefault(x => x.Id == model.Id); - if (psa == null) - throw new ArgumentException("No specification attribute found with the specified id"); - - await productViewModelService.DeleteProductSpecificationAttribute(product, psa); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - #endregion - - #region Purchased with order - - // Type note: this method covers Admin and Store only. Both already share - // Grand.Web.AdminShared's IOrderViewModelService and Models.Orders.OrderListModel (see the usings - // at the top of this file). Vendor defines its own, structurally different - // Grand.Web.Vendor.Interfaces.IOrderViewModelService and Grand.Web.Vendor.Models.Orders.OrderListModel - // (no StoreId property at all - Vendor's PrepareOrderModel scopes by - // _contextAccessor.WorkContext.CurrentVendor.Id internally, not via any model field), so it cannot - // bind to this signature. That vendor-id scoping lives entirely inside Vendor's own - // OrderViewModelService, outside anything IAdminDataScope expresses - flagging as a concern - // per the task brief rather than inventing a shared model/service pair. Not virtual: C#'s override - // rules require an exact parameter-type match, so a Vendor override using - // Grand.Web.Vendor.Interfaces.IOrderViewModelService/Models.Orders.OrderListModel could never compile - // as an override of this signature anyway. When Vendor is wired onto this base controller (Task 11), - // its subclass will declare its own PurchasedWithOrders action with `new` to shadow this one (a - // standard, valid ASP.NET Core MVC pattern for a derived controller needing an incompatible signature - // under the same action name), not override it. - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task PurchasedWithOrders(DataSourceRequest command, string productId, - [FromServices] IOrderViewModelService orderViewModelService) - { - if (!await permissionService.Authorize(StandardPermission.ManageOrders)) - return Json(new DataSourceResult { - Data = null, - Total = 0 - }); - - var product = await productService.GetProductById(productId); - - // CanView on this read: a product shared with other stores opens read-only on Store, so its - // tabs must load; the mutating actions below keep the strict HasAccess. History: - // HasAccess (strict): mirrors Store's CanAccessProduct check on this action. Admin's original had - // no check at all - GlobalAdminDataScope.HasAccess is a no-op there, so this closes that gap the - // same way as every other row in this task. - if (!await scope.CanView(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var model = new OrderListModel { - ProductId = productId, - // DefaultStoreId is the staff member's store for Store (matches its original - // model.StoreId = StaffStoreId) and null for Admin (matches its original, which never set - // StoreId at all). - StoreId = scope.DefaultStoreId - }; - - var (orderModels, totalCount) = - await orderViewModelService.PrepareOrderModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = orderModels.ToList(), - Total = totalCount - }; - return Json(gridModel); - } - - #endregion - - #region Reviews - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task Reviews(DataSourceRequest command, string productId, - [FromServices] IProductReviewService productReviewService) - { - var product = await productService.GetProductById(productId); - - // CanView on this read: a product shared with other stores opens read-only on Store, so its - // tabs must load; the mutating actions below keep the strict HasAccess. History: - // HasAccess (strict): mirrors Store's CanAccessProduct and Vendor's CheckAccessToProduct gating - // this action on both hosts. Admin's original had no check at all - GlobalAdminDataScope.HasAccess - // is a no-op there, so this closes that gap the same way as every other row in this task. - if (!await scope.CanView(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - // DefaultStoreId is the staff member's store for Store (matches its original storeId argument, - // which filtered reviews to the staff member's store) and null - normalized to "" here, matching - // GetAllProductReviews's expected "no filter" value - for both Admin and Vendor (matches their - // originals, which both passed "" literally; Vendor scopes by VendorId via the HasAccess check - // above, not by store, since VendorProductDataScope.DefaultStoreId is null). - var storeId = scope.DefaultStoreId ?? ""; - - var productReviews = await productReviewService.GetAllProductReviews("", null, - null, null, "", storeId, productId); - - var items = new List(); - foreach (var item in productReviews.PagedForCommand(command)) - { - var m = new ProductReviewModel(); - await productViewModelService.PrepareProductReviewModel(m, item, false, true); - items.Add(m); - } - - var gridModel = new DataSourceResult { - Data = items, - Total = productReviews.Count - }; - - return Json(gridModel); - } - - #endregion - - #region Export / Import - - [PermissionAuthorizeAction(PermissionActionName.Export)] - [HttpPost] - public async Task ExportExcelAll(ProductListModel model, - [FromServices] IExportManager exportManager) - { - // vendorId is forced inside PrepareProducts itself (scope.DefaultVendorId ?? model.SearchVendorId), - // but storeId is not - every other caller of PrepareProductsModel/PrepareProducts in this file - // forces it here, at the call site, the same way ProductList does above. This one was missing it: - // a Store-scoped user could POST an arbitrary (or blank) SearchStoreId and export every store's - // products, not just their own. Found during a post-Phase-2 tenant-isolation audit. - if (scope.DefaultStoreId is not null) model.SearchStoreId = scope.DefaultStoreId; - - var products = await productViewModelService.PrepareProducts(model); - try - { - var bytes = await exportManager.Export(products, HttpContext.RequestAborted); - return File(bytes, "text/xls", "products.xlsx"); - } - catch (Exception exc) when (!IsRequestAborted(exc)) - { - Error(exc); - return RedirectToAction("List"); - } - } - - [PermissionAuthorizeAction(PermissionActionName.Export)] - [HttpPost] - public async Task ExportExcelSelected(string selectedIds, - [FromServices] IExportManager exportManager) - { - var products = new List(); - if (selectedIds != null) - { - var ids = selectedIds - .Split([','], StringSplitOptions.RemoveEmptyEntries) - .Select(x => x) - .ToArray(); - products.AddRange(await productService.GetProductsByIds(ids, access: CatalogAccess.IncludingHidden())); - } - - // Unlike ExportExcelAll, selectedIds is caller-supplied and not derived from a scoped search - - // Vendor's original explicitly re-checked HasAccessToProduct per id for exactly this reason - // (a vendor could otherwise pass another vendor's product id and export it). Admin's original had - // no check at all (GlobalAdminDataScope.HasAccess is a no-op there), so applying the filter - // unconditionally closes that gap the same way as every other row in this task, without changing - // Admin's or Store's observable behavior. - var scoped = new List(); - foreach (var product in products) - if (await scope.HasAccess(product)) - scoped.Add(product); - - var bytes = await exportManager.Export(scoped, HttpContext.RequestAborted); - return File(bytes, "text/xls", "products.xlsx"); - } - - // Global scope only, as for brands, categories and collections: the importer is not store- or - // vendor-aware. - // - // Concern (flagged, not fixed - out of scope for this row): ImportExcel only checks - // `importexcelfile.Length > 0` before handing the raw stream to IImportManager.Import. - // There is no file-extension allowlist and no upper bound on Length before the stream is read into - // memory by the importer. Commit a153496a6 hardened exactly this shape of gap (memory DoS + extension - // bypass) for attribute file uploads; this action has the same shape and was not touched by that fix. - // This is pre-existing behavior being ported verbatim, not something introduced by this migration - - // worth a follow-up ticket, not a silent fix here. - [PermissionAuthorizeAction(PermissionActionName.Import)] - [HttpPost] - public async Task ImportExcel(IFormFile importexcelfile, - [FromServices] IImportManager importManager) - { - try - { - if (Scope.DefaultStoreId is not null || Scope.DefaultVendorId is not null) - { - Error(translationService.GetResource("Admin.AccessDenied.Title")); - return RedirectToAction("List"); - } - - if (importexcelfile is { Length: > 0 }) - { - await importManager.Import(importexcelfile.OpenReadStream(), HttpContext.RequestAborted); - } - else - { - Error(translationService.GetResource("Admin.Common.UploadFile")); - return RedirectToAction("List"); - } - - Success(translationService.GetResource("Admin.Catalog.Products.Imported")); - return RedirectToAction("List"); - } - catch (Exception exc) when (!IsRequestAborted(exc)) - { - Error(exc); - return RedirectToAction("List"); - } - } - - #endregion - - #region Bulk editing - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - public async Task BulkEdit() - { - // PrepareBulkEditListModel() now reads scope internally (Task 9): null for Admin/Vendor - // (not store-scoped), StaffStoreId for Store. - var model = await productViewModelService.PrepareBulkEditListModel(); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task BulkEditSelect(DataSourceRequest command, BulkEditListModel model) - { - if (scope.DefaultStoreId is not null) model.SearchStoreId = scope.DefaultStoreId; - - // BLOCKING PREREQUISITE for Task 11 (see plan's Task 10 PrepareBulkEditProductModel row and - // Task 11's blocking-prerequisite note, added in commit a8def835a after this gap was flagged and - // verified during review): Vendor's original bulk-edit grid was vendor-scoped - - // PrepareBulkEditProductModel passed vendorId: CurrentVendor.Id into productService.SearchProducts, - // so the grid only ever listed the vendor's own products. AdminShared's version isn't - - // IProductViewModelService.PrepareBulkEditProductModel used here has no vendorId parameter (unlike - // PrepareProductModel(AddProductModel), which supports SearchVendorId - see - // AssociatedProductVendorId above) - and BulkEditListModel carries no SearchVendorId field either. - // Closing this requires a ProductViewModelService/BulkEditListModel change, which this task's - // per-row scope (BaseProductController.cs + tests only) does not permit. Not a security gap by - // itself (a wider listing, not a mutation) - the mutate endpoints below (BulkEditUpdate/ - // BulkEditDelete) are scope-checked per item regardless and never leak another party's product - - // but Task 11 must NOT subclass Vendor onto this base controller until Task 10 adds vendor-scoped - // filtering here, or a vendor would see every vendor's products in this grid. - var (bulkEditProductModels, totalCount) = - await productViewModelService.PrepareBulkEditProductModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = bulkEditProductModels.ToList(), - Total = totalCount - }; - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task BulkEditUpdate(IEnumerable products) - { - var validProducts = await FilterBulkEditProductsByAccess(products); - if (validProducts.Count > 0) await productViewModelService.UpdateBulkEdit(validProducts); - - return new JsonResult(""); - } - - /// - /// Rows added in the bulk-edit grid: a simple product each, with the fields of the row. The ids - /// are the grid's empty ones, so there is nothing to scope-check; ownership of the new product - /// is set by the service (store for Store, vendor for Vendor) the way the Create screen sets it. - /// - [PermissionAuthorizeAction(PermissionActionName.Create)] - [HttpPost] - public async Task BulkEditCreate(IEnumerable products) - { - var newProducts = products?.Where(x => !string.IsNullOrWhiteSpace(x.Name)).ToList() ?? []; - if (newProducts.Count > 0) await productViewModelService.InsertBulkEdit(newProducts); - - return new JsonResult(""); - } - - [PermissionAuthorizeAction(PermissionActionName.Delete)] - [HttpPost] - public async Task BulkEditDelete(IEnumerable products) - { - var validProducts = await FilterBulkEditProductsByAccess(products); - if (validProducts.Count > 0) await productViewModelService.DeleteBulkEdit(validProducts); - - return new JsonResult(""); - } - - /// - /// Filters a caller-supplied bulk-edit product list (BulkEditUpdate/BulkEditDelete) down to the ones - /// the current user may mutate. Same shape as the DeleteSelected gap above: this is a caller-supplied - /// list of ids being mutated in one request, and each id must be scope-checked individually before - /// mutation, not just implicitly trusted because it appeared in a grid the user was shown. - /// - /// Admin's original had NO check at all here - both BulkEditUpdate and BulkEditDelete accepted a - /// client-supplied list of ids and mutated/deleted every one of them unconditionally, a real unscoped - /// bulk-mutate/bulk-delete IDOR (any authenticated admin user hitting these actions directly - not - /// through the grid - could update or delete any product in the system by id). - /// - /// Store's original had this exact check (FilterValidProductsForStore, via CanAccessProduct / - /// AccessToEntityByStore) - HasAccess (strict), matching CanAccessProduct's strict rule, not CanView. - /// - /// Vendor's original enforced the equivalent strict check (HasAccessToProduct / VendorId equality) - /// inside its own separate service (Grand.Web.Vendor's ProductViewModelService.UpdateBulkEdit/ - /// DeleteBulkEdit), not in the controller - functionally the same per-item gate, just placed one layer - /// down. Routing it through scope.HasAccess here reproduces that gate at the controller layer, where - /// the shared IProductViewModelService.UpdateBulkEdit/DeleteBulkEdit used by this class does not - /// filter internally (verified: both loop and mutate/delete every product they're given, no ownership - /// check). - /// - /// No-op for Admin (GlobalAdminDataScope.HasAccess is always true, so validProducts == products). - /// Silently drops missing/inaccessible ids rather than throwing (matches Admin's/Vendor's - /// null-then-skip behavior, not Store's original throw-on-missing-id - the majority behavior, and a - /// single bad id in a bulk request shouldn't fail the whole batch). - /// - private async Task> FilterBulkEditProductsByAccess( - IEnumerable products) - { - if (products == null) return []; - - var byId = products - .Where(x => !string.IsNullOrEmpty(x.Id)) - .GroupBy(x => x.Id) - .ToDictionary(g => g.Key, g => g.First()); - if (byId.Count == 0) return []; - - var loadedProducts = await productService.GetProductsByIds(byId.Keys.ToArray(), - access: CatalogAccess.IncludingHidden()); - var validProducts = new List(); - foreach (var product in loadedProducts) - if (await scope.HasAccess(product)) - validProducts.Add(byId[product.Id]); - - return validProducts; - } - - #endregion - - #region Product currency price - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ProductPriceList(DataSourceRequest command, string productId) - { - var product = await productService.GetProductById(productId); - - // CanView on this read: a product shared with other stores opens read-only on Store, so its - // tabs must load; the mutating actions below keep the strict HasAccess. History: - // HasAccess (strict) mirrors Store's CanAccessProduct (AccessToEntityByStore) check - // on this action. Applying it uniformly also closes real gaps on the mutate actions below: Store's - // original checked access only on List/Insert (ProductPriceUpdate/ProductPriceDelete had no check - // at all), and Vendor's original checked access only on List (ProductPriceInsert/Update/Delete had - // no check at all) - both let another party's product prices be updated/deleted by id. - if (!await scope.CanView(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var items = new List(); - foreach (var item in product.ProductPrices) - items.Add(new ProductModel.ProductPriceModel { - Id = item.Id, - CurrencyCode = item.CurrencyCode, - Price = item.Price, - ProductId = product.Id - }); - - var gridModel = new DataSourceResult { - Data = items, - Total = items.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductPriceInsert(ProductModel.ProductPriceModel model) - { - var product = await productService.GetProductById(model.ProductId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - if (!await scope.HasAccess(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - if (product.ProductPrices.Any(x => x.CurrencyCode == model.CurrencyCode)) - throw new ArgumentException("Currency code exists"); - - if (ModelState.IsValid) - try - { - await productService.InsertProductPrice(new ProductPrice { - ProductId = product.Id, - CurrencyCode = model.CurrencyCode, - Price = model.Price - }); - return new JsonResult(""); - } - catch (Exception ex) - { - return ErrorForKendoGridJson(ex.Message); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductPriceUpdate(ProductModel.ProductPriceModel model) - { - var product = await productService.GetProductById(model.ProductId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - if (!await scope.HasAccess(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var productPrice = product.ProductPrices.FirstOrDefault(x => x.Id == model.Id); - if (productPrice == null) - throw new ArgumentException("Product price model not exists"); - - if (product.ProductPrices.Any(x => x.Id != model.Id && x.CurrencyCode == model.CurrencyCode)) - throw new ArgumentException("You can't use this currency code"); - - if (ModelState.IsValid) - try - { - productPrice!.CurrencyCode = model.CurrencyCode; - productPrice.Price = model.Price; - productPrice.ProductId = model.ProductId; - - await productService.UpdateProductPrice(productPrice); - - return new JsonResult(""); - } - catch (Exception ex) - { - return ErrorForKendoGridJson(ex.Message); - } - - return ErrorForKendoGridJson(ModelState); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductPriceDelete(ProductModel.ProductPriceModel model) - { - var product = await productService.GetProductById(model.ProductId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - if (!await scope.HasAccess(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var productPrice = product.ProductPrices.FirstOrDefault(x => x.Id == model.Id); - if (productPrice == null) - throw new ArgumentException("Product price model not exists"); - - if (ModelState.IsValid) - { - productPrice!.ProductId = model.ProductId; - await productService.DeleteProductPrice(productPrice); - - return new JsonResult(""); - } - - // ErrorForKendoGridJson(ModelState), not Content(ModelState.GetErrors()): matches Admin/Store. - // Vendor's original used the Vendor-only GetErrors() extension here (and inconsistently, since - // several of Vendor's *other* grid actions in this same file already use ErrorForKendoGridJson) - - // not a deliberate host-specific contract, just Vendor's own inconsistency. AdminShared cannot - // reference Grand.Web.Vendor.Extensions.ModelStateExtensions.GetErrors() from this project anyway. - return ErrorForKendoGridJson(ModelState); - } - - #endregion - - #region Tier prices - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task TierPriceList(DataSourceRequest command, string productId) - { - var product = await productService.GetProductById(productId); - - // CanView on this read: a product shared with other stores opens read-only on Store, so its - // tabs must load; the mutating actions below keep the strict HasAccess. History: - // HasAccess on List (Store/Vendor both checked here; Admin's Global scope is a no-op). Closes the - // same class of gap as "Product currency price": Vendor's original checked ownership only on List - // and TierPriceEditPopup(GET) - TierPriceCreatePopup(POST), TierPriceEditPopup(POST) and - // TierPriceDelete had NO ownership check at all, so a vendor could create/update/delete a tier - // price on any product (not just their own) by posting a known productId. Applying scope.HasAccess - // uniformly on every mutating action below closes that. - if (!await scope.CanView(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - // PrepareTierPriceModel(product) now reads scope internally (Task 9); scope.DefaultStoreId - // is null for Admin/Vendor (Global/VendorProduct scopes) and the staff store for Store. - var tierPricesModel = await productViewModelService.PrepareTierPriceModel(product); - var gridModel = new DataSourceResult { - Data = tierPricesModel, - Total = tierPricesModel.Count - }; - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task TierPriceCreatePopup(string productId) - { - var model = new ProductModel.TierPriceModel { - ProductId = productId - }; - await productViewModelService.PrepareTierPriceModel(model); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task TierPriceCreatePopup(ProductModel.TierPriceModel model) - { - if (ModelState.IsValid) - { - var product = await productService.GetProductById(model.ProductId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - // Vendor's original never even loaded the product here - it inserted straight off - // model.ProductId with no ownership check at all. See the List-action comment above. - if (!await scope.HasAccess(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var tierPrice = model.ToEntity(dateTimeService); - await productService.InsertTierPrice(tierPrice, product.Id); - - return Content(""); - } - - Error(ModelState); - //If we got this far, something failed, redisplay form - await productViewModelService.PrepareTierPriceModel(model); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task TierPriceEditPopup(string id, string productId) - { - var product = await productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - // HasAccess here too: Store's original never checked ownership on this GET action (only on the - // List/Create-POST/Edit-POST/Delete siblings), which would let store staff open (read-only, - // via this popup) the tier-price edit view for a product outside their store. Vendor's original did - // check (HasAccessToProduct), so this closes Store's gap while keeping Vendor's existing behavior. - if (!await scope.HasAccess(product)) - return Content("This is not your product"); - - var tierPrice = product.TierPrices.FirstOrDefault(x => x.Id == id); - if (tierPrice == null) - return Content("Empty tier price"); - - var model = tierPrice.ToModel(dateTimeService); - model.ProductId = productId; - await productViewModelService.PrepareTierPriceModel(model); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task TierPriceEditPopup(string productId, ProductModel.TierPriceModel model) - { - if (ModelState.IsValid) - { - var product = await productService.GetProductById(productId, true); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - // See the List-action comment: Vendor's original had no ownership check on this POST at all. - if (!await scope.HasAccess(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var tierPrice = product.TierPrices.FirstOrDefault(x => x.Id == model.Id); - if (tierPrice == null) - return Content("Empty tier price"); - - tierPrice = model.ToEntity(tierPrice, dateTimeService); - await productService.UpdateTierPrice(tierPrice, product.Id); - - return Content(""); - } - - Error(ModelState); - //stores - await productViewModelService.PrepareTierPriceModel(model); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task TierPriceDelete(ProductModel.TierPriceDeleteModel model) - { - if (ModelState.IsValid) - { - var product = await productService.GetProductById(model.ProductId, true); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - // See the List-action comment: Vendor's original had no ownership check on Delete at all. - if (!await scope.HasAccess(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var tierPrice = product.TierPrices.FirstOrDefault(x => x.Id == model.Id); - if (tierPrice == null) - throw new ArgumentException("No tier price found with the specified id"); - - await productService.DeleteTierPrice(tierPrice, product.Id); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - #endregion - - #region Product attributes - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ProductAttributeMappingList(DataSourceRequest command, string productId) - { - var product = await productService.GetProductById(productId); - - // CanView on this read: a product shared with other stores opens read-only on Store, so its - // tabs must load; the mutating actions below keep the strict HasAccess. History: - // HasAccess applied uniformly across this region (Admin's Global scope is a no-op). Store originally - // checked ownership here (CanAccessProduct) and Vendor did too (CheckAccessToProduct), but neither - // host checked ProductAttributeMappingPopup(POST) or ProductAttributeValidationRulesPopup(POST) at - // all - a store/vendor user could edit an attribute mapping's name/values or its validation rules on - // any product (not just their own/in-scope one) by posting a known productId. Vendor's - // ProductAttributeMappingPopup(POST) had no check either, despite its own GET sibling checking. - if (!await scope.CanView(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var attributesModel = await productViewModelService.PrepareProductAttributeMappingModels(product); - var gridModel = new DataSourceResult { - Data = attributesModel, - Total = attributesModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ProductAttributeMappingPopup(string productId, string productAttributeMappingId) - { - var product = await productService.GetProductById(productId); - - // See the List-action comment above. - if (!await scope.HasAccess(product)) - return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - if (string.IsNullOrEmpty(productAttributeMappingId)) - { - var model = await productViewModelService.PrepareProductAttributeMappingModel(product); - return View(model); - } - - var productAttributeMapping = - product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); - var editModel = await productViewModelService.PrepareProductAttributeMappingModel(product, - productAttributeMapping); - return View(editModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeMappingPopup(ProductModel.ProductAttributeMappingModel model) - { - if (ModelState.IsValid) - { - var product = await productService.GetProductById(model.ProductId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - // HasAccess here too: Vendor's original never checked ownership on this POST at all (only its - // GET sibling did), letting a vendor insert/update an attribute mapping on any product. See the - // List-action comment. - if (!await scope.HasAccess(product)) - return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - if (string.IsNullOrEmpty(model.Id)) - await productViewModelService.InsertProductAttributeMappingModel(model); - else - await productViewModelService.UpdateProductAttributeMappingModel(model); - - return Content(""); - } - - Error(ModelState); - model = await productViewModelService.PrepareProductAttributeMappingModel(model); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeMappingDelete(string id, string productId, - [FromServices] IProductAttributeService productAttributeService) - { - var product = await productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var productAttributeMapping = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == id); - if (productAttributeMapping == null) - throw new ArgumentException("No product attribute mapping found with the specified id"); - - // See the List-action comment above. - if (!await scope.HasAccess(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - await productAttributeService.DeleteProductAttributeMapping(productAttributeMapping, product.Id); - return new JsonResult(""); - } - - //edit - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ProductAttributeValidationRulesPopup(string id, string productId) - { - var product = await productService.GetProductById(productId); - - // See the List-action comment above. Store's original used ErrorForKendoGridJson here even though - // this action returns a View (not a grid), which would have rendered raw JSON as page content on - // denial - using Content(...) instead, consistent with the other popup GET action in this region. - if (!await scope.HasAccess(product)) - return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var productAttributeMapping = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == id); - if (productAttributeMapping == null) - return Content("No attribute value found with the specified id"); - - var model = await productViewModelService.PrepareProductAttributeMappingModel(productAttributeMapping); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeValidationRulesPopup( - ProductModel.ProductAttributeMappingModel model) - { - var product = await productService.GetProductById(model.ProductId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var productAttributeMapping = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == model.Id); - if (productAttributeMapping == null) - throw new ArgumentException("No attribute value found with the specified id"); - - // HasAccess here too: none of the three original hosts checked ownership on this POST at all - - // a store/vendor user could update an attribute mapping's validation rules (min/max length, - // allowed file extensions, default value) on any product by posting a known productId/model.Id. - if (!await scope.HasAccess(product)) - return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - { - await productViewModelService.UpdateProductAttributeValidationRulesModel(productAttributeMapping, model); - return Content(""); - } - - Error(ModelState); - model = await productViewModelService.PrepareProductAttributeMappingModel(productAttributeMapping); - return View(model); - } - - #endregion - - #region Product attributes. Condition - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ProductAttributeConditionPopup(string productId, string productAttributeMappingId) - { - var product = await productService.GetProductById(productId); - - // HasAccess applied uniformly across this region (Admin's Global scope is a no-op). Admin's original - // had no ownership check on either action of this region. See the "Product attributes" region above - // for the same pattern. - if (!await scope.HasAccess(product)) - return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var productAttributeMapping = - product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); - if (productAttributeMapping == null) - //No attribute value found with the specified id - return Content("No attribute value found with the specified id"); - - var model = await productViewModelService.PrepareProductAttributeConditionModel(product, - productAttributeMapping); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeConditionPopup(ProductAttributeConditionModel model) - { - var product = await productService.GetProductById(model.ProductId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - var productAttributeMapping = - product.ProductAttributeMappings.FirstOrDefault(x => x.Id == model.ProductAttributeMappingId); - if (productAttributeMapping == null) - return Content("No attribute value found with the specified id"); - - // ModelState.IsValid: Vendor's original wrapped this action in a ModelState.IsValid check and - // returned ModelState.GetErrors() on failure; Admin's and Store's originals had no such check. - // That check was not inert: Vendor's ProductAttributeConditionModel implements the - // IProductValidVendor marker interface, which the global ValidationFilter resolves to - // ProductValidVendor (a FluentValidation rule checking product.VendorId == CurrentVendor.Id) - - // real ownership enforcement, not a no-op. Dropping it here is safe because the scope.HasAccess - // (product) call below now performs the equivalent check directly against the loaded entity, - // which HasAccess.cs's own doc comment argues is more robust than re-deriving ownership from a - // request field. - // - // HasAccess here too: Vendor's original never checked ownership on this POST at all (only its GET - // sibling did, via CheckAccessToProduct), letting a vendor update an attribute condition on any - // product by posting a known productId/productAttributeMappingId. Admin's original had no check on - // either action. - if (!await scope.HasAccess(product)) - return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - await productViewModelService.UpdateProductAttributeConditionModel(product, productAttributeMapping, model); - return Content(""); - } - - #endregion - - #region Product attribute values - - //list - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task EditAttributeValues(string productAttributeMappingId, string productId, - [FromServices] IProductAttributeService productAttributeService) - { - var product = await productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - // HasAccess applied uniformly across this region (Admin's Global scope is a no-op). Admin's - // original had no ownership check on this action at all. - if (!await scope.HasAccess(product)) - return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var productAttributeMapping = - product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); - if (productAttributeMapping == null) - throw new ArgumentException("No product attribute mapping found with the specified id"); - - var productAttribute = - await productAttributeService.GetProductAttributeById(productAttributeMapping.ProductAttributeId); - var model = new ProductModel.ProductAttributeValueListModel { - ProductName = product.Name, - ProductId = product.Id, - ProductAttributeName = productAttribute.Name, - ProductAttributeMappingId = productAttributeMappingId - }; - - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeValueList(string productAttributeMappingId, string productId, - DataSourceRequest command) - { - var product = await productService.GetProductById(productId); - - // See the EditAttributeValues comment above. - if (!await scope.HasAccess(product)) - return ErrorForKendoGridJson( - translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var productAttributeMapping = - product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); - if (productAttributeMapping == null) - throw new ArgumentException("No product attribute mapping found with the specified id"); - - var values = - await productViewModelService.PrepareProductAttributeValueModels(product, productAttributeMapping); - var gridModel = new DataSourceResult { - Data = values, - Total = values.Count - }; - return Json(gridModel); - } - - //create - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ProductAttributeValueCreatePopup(string productAttributeMappingId, - string productId) - { - var product = await productService.GetProductById(productId); - - // Content(...), not ErrorForKendoGridJson: Store's original used ErrorForKendoGridJson here even - // though this action returns a View (not a grid), which would have rendered raw JSON as page - // content on denial - using Content(...) instead, consistent with the sibling GET popup actions - // in this region and elsewhere in this file. - if (!await scope.HasAccess(product)) - return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var productAttributeMapping = - product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); - if (productAttributeMapping == null) - throw new ArgumentException("No product attribute mapping found with the specified id"); - - var model = - await productViewModelService.PrepareProductAttributeValueModel(product, productAttributeMapping); - //locales - await AddLocales(languageService, model.Locales); - - return View(model); - } - - [HttpPost] - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ProductAttributeValueCreatePopup(ProductModel.ProductAttributeValueModel model) - { - var product = await productService.GetProductById(model.ProductId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - // HasAccess added explicitly rather than relying on validation-layer side effects. Admin has no - // ownership concept at all (GlobalAdminDataScope.HasAccess is always true), so Admin was never at - // risk here despite having neither an explicit check nor a validator. Store's shared - // ProductAttributeValueModelValidator (BaseStoreAccessValidator<...>) enforces ownership whenever - // StaffStoreId is set, so Store's original had validator-layer coverage. The actual risk this - // guards against is to VENDOR: Vendor's original model implements IProductValidVendor, so the - // global ValidationFilter resolves IValidator (ProductValidVendor) and adds a - // ModelState error when product.VendorId doesn't match the current vendor - a real check, not a - // no-op - but ValidationFilter never short-circuits a non-JSON POST (see - // ValidationFilter.OnActionExecutionAsync), so that protection only actually held because Vendor's - // original action gated the insert behind `if (ModelState.IsValid)`. Once this action moves to the - // shared AdminShared model (which does not implement IProductValidVendor), Vendor would silently - // lose that guard in the merge unless replaced - scope.HasAccess is that replacement, and now - // applies uniformly (a no-op for Admin, equivalent-or-stronger for Store/Vendor) instead of leaning - // on a marker-interface side effect that only one host had. - if (!await scope.HasAccess(product)) - return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var productAttributeMapping = - product.ProductAttributeMappings.FirstOrDefault(x => x.Id == model.ProductAttributeMappingId); - if (productAttributeMapping == null) - //No product attribute found with the specified id - return RedirectToAction("List", "Product"); - - if (ModelState.IsValid) - { - await productViewModelService.InsertProductAttributeValueModel(model); - return Content(""); - } - - //If we got this far, something failed, redisplay form - await productViewModelService.PrepareProductAttributeValueModel(product, model); - return View(model); - } - - //edit - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ProductAttributeValueEditPopup(string id, string productId, - string productAttributeMappingId) - { - var product = await productService.GetProductById(productId); - - // See the EditAttributeValues comment above. - if (!await scope.HasAccess(product)) - return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var pa = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == productAttributeMappingId); - if (pa == null) - return RedirectToAction("List", "Product"); - - var pav = pa.ProductAttributeValues.FirstOrDefault(x => x.Id == id); - if (pav == null) - //No attribute value found with the specified id - return RedirectToAction("List", "Product"); - - var model = await productViewModelService.PrepareProductAttributeValueModel(pa, pav); - //locales - await AddLocales(languageService, model.Locales, (locale, languageId) => - { - locale.Name = pav.GetTranslation(x => x.Name, languageId, false); - }); - //pictures - await productViewModelService.PrepareProductAttributeValueModel(product, model); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeValueEditPopup(string productId, - ProductModel.ProductAttributeValueModel model) - { - var product = await productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - // See the ProductAttributeValueCreatePopup(POST) comment above re: the validator's coverage gap - - // the same reasoning applies here (this action shares the same model type). Unlike CreatePopup(POST), - // Vendor's original for THIS action already had an explicit inline check - // (`if (product == null || !_contextAccessor.WorkContext.HasAccessToProduct(product)) throw ...`), - // so scope.HasAccess is a mechanical substitution there, not a fix. Admin's original had no check - // on either action of this pair. - if (!await scope.HasAccess(product)) - return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var pav = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == model.ProductAttributeMappingId) - ?.ProductAttributeValues.FirstOrDefault(x => x.Id == model.Id); - if (pav == null) - //No attribute value found with the specified id - return RedirectToAction("List", "Product"); - - if (ModelState.IsValid) - { - await productViewModelService.UpdateProductAttributeValueModel(pav, model); - return Content(""); - } - - //If we got this far, something failed, redisplay form - await productViewModelService.PrepareProductAttributeValueModel(product, model); - return View(model); - } - - //delete - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeValueDelete(string id, string pam, string productId, - [FromServices] IProductAttributeService productAttributeService) - { - var product = await productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - // HasAccess added here: this action takes only simple string parameters (no complex-typed POST - // body), so none of the model-level validators discussed above ever run for it. Admin's original - // had no ownership check at all; Vendor's/Store's explicit inline checks are what scope.HasAccess - // now replaces uniformly. - if (!await scope.HasAccess(product)) - return ErrorForKendoGridJson( - translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var pav = product.ProductAttributeMappings.FirstOrDefault(x => x.Id == pam)?.ProductAttributeValues - .FirstOrDefault(x => x.Id == id); - if (pav == null) - throw new ArgumentException("No product attribute value found with the specified id"); - - if (ModelState.IsValid) - { - await productAttributeService.DeleteProductAttributeValue(pav, productId, pam); - return new JsonResult(""); - } - - return ErrorForKendoGridJson(ModelState); - } - - public async Task AssociateProductToAttributeValuePopup() - { - // PrepareAssociateProductToAttributeValueModel() now reads scope internally (Task 9): - // StaffStoreId for Store; null for Admin/Vendor (no store concept). - var model = - await productViewModelService.PrepareAssociateProductToAttributeValueModel(); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task AssociateProductToAttributeValuePopupList(DataSourceRequest command, - ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel model) - { - if (scope.DefaultStoreId is not null) model.SearchStoreId = scope.DefaultStoreId; - - var (products, totalCount) = - await productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = products.ToList(), - Total = totalCount - }; - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task AssociateProductToAttributeValuePopup( - ProductModel.ProductAttributeValueModel.AssociateProductToAttributeValueModel model) - { - var associatedProduct = await productService.GetProductById(model.AssociatedToProductId); - if (associatedProduct == null) - return Content("Cannot load a product"); - - // HasAccess on the associated product: Admin's original had no check here. Vendor's/Store's - // originals checked ownership of the *associated* product (the one about to be referenced as an - // AssociatedToProductId value) - there is no separate "owning" product in scope for this action, so - // the associated product is the only entity available to check. - if (!await scope.HasAccess(associatedProduct)) - return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - return Content(""); - } - - #endregion - - #region Product attribute combinations - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ProductAttributeCombinationList(DataSourceRequest command, string productId) - { - var product = await productService.GetProductById(productId); - - // CanView on this read: a product shared with other stores opens read-only on Store, so its - // tabs must load; the mutating actions below keep the strict HasAccess. History: - // HasAccess added here: Admin's original had no ownership check on this grid at all. Store's - // original used CanAccessProduct + a hardcoded "Admin.Catalog.Products.Permissions" resource key - // (even though it's the Store host); Vendor's original used a local CheckAccessToProduct helper - // that returned a plain hardcoded string ("This is not your product" / "Product not exists") - // rather than a resource key. scope.HasAccess plus the scope.ResourceKeyPrefix-qualified resource - // key normalizes all three to the convention used throughout this file. - if (!await scope.CanView(product)) - return ErrorForKendoGridJson( - translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var combinationsModel = await productViewModelService.PrepareProductAttributeCombinationModel(product); - var gridModel = new DataSourceResult { - Data = combinationsModel, - Total = combinationsModel.Count - }; - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeCombinationDelete(string id, string productId, - [FromServices] IProductAttributeService productAttributeService) - { - var product = await productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - // HasAccess added here, checked before the combination lookup: matches the - // ProductAttributeValueDelete precedent above (deny before revealing whether the sub-entity - // exists). Vendor's original combined the null-check and the ownership check into a single throw - // (an unhandled exception on denial); normalized here to the file's ErrorForKendoGridJson - // convention - this is a Kendo grid row-delete action - matching how Store's original reported - // denial (though Store checked access after the combination lookup, not before). - if (!await scope.HasAccess(product)) - return ErrorForKendoGridJson( - translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var combination = product.ProductAttributeCombinations.FirstOrDefault(x => x.Id == id); - if (combination == null) - throw new ArgumentException("No product attribute combination found with the specified id"); - - await productAttributeService.DeleteProductAttributeCombination(combination, productId); - if (product.ManageInventoryMethodId == ManageInventoryMethod.ManageStockByAttributes) - { - var pr = await productService.GetProductById(productId); - pr.StockQuantity = pr.ProductAttributeCombinations.Sum(x => x.StockQuantity); - pr.ReservedQuantity = pr.ProductAttributeCombinations.Sum(x => x.ReservedQuantity); - await inventoryManageService.UpdateStockProduct(pr, false); - } - - return new JsonResult(""); - } - - //edit - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task AttributeCombinationPopup(string productId, string id) - { - var product = await productService.GetProductById(productId); - - // Content(...), not ErrorForKendoGridJson: Store's original used ErrorForKendoGridJson here even - // though this action returns a View inside a magnificPopup modal (not a grid), which would render - // raw JSON as page content on denial - using Content(...) instead, consistent with the - // EditAttributeValues GET popup correction in region "Product attribute values" above. - if (!await scope.HasAccess(product)) - return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var model = await productViewModelService.PrepareProductAttributeCombinationModel(product, id); - await productViewModelService.PrepareAddProductAttributeCombinationModel(model, product); - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task AttributeCombinationPopup(string productId, - ProductAttributeCombinationModel model) - { - var product = await productService.GetProductById(productId); - if (product == null) - //No product found with the specified id - return RedirectToAction("List", "Product"); - - // Content(...) on denial, matching Store's original. Vendor's original folded denial into the - // same redirect used for a missing product; Content(...) surfaces the permissions message instead - // of silently redirecting, matching every other Edit-scoped action in this region. - // ProductAttributeCombinationModel (Grand.Web.AdminShared.Models.Catalog) does not implement - // IProductValidVendor and has no registered FluentValidation validator, so there is no - // validator-layer ownership check being displaced here - scope.HasAccess is the only guard. - if (!await scope.HasAccess(product)) - return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var warnings = await productViewModelService.InsertOrUpdateProductAttributeCombinationPopup(product, model); - if (!warnings.Any()) return Content(""); - //If we got this far, something failed, redisplay form - await productViewModelService.PrepareAddProductAttributeCombinationModel(model, product); - model.Warnings = warnings; - return View(model); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task GenerateAllAttributeCombinations(string productId) - { - var product = await productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - // ErrorForKendoGridJson(...), not Content(...): this action is invoked via a dataType:'json' ajax - // call (see CreateOrUpdate.ProductAttributes.TabAttributeCombinations.cshtml). Store's original - // returned Content(resource) on denial - a plain string the client's dataType:'json' parser - // cannot parse - which threw a JSON-parse exception and fell into the ajax error callback, - // showing a generic "Error while generating attribute combinations" alert. Vendor's original - // folded denial into the null-product throw (also an unhandled exception). Normalized here to a - // real JSON response, which avoids that parse-exception/generic-alert path - but note the - // .cshtml's ajax success callback for this action ignores the response body entirely (it just - // unconditionally refreshes the grid), so the denial still isn't surfaced to the user; it's a - // silent no-op instead of a visible (if wrong) error. Server-side enforcement is correct either - // way - nothing is generated on denial - this is a pre-existing view-layer gap, out of scope for - // this controller-only row (flagged for Phase 2 view work). - if (!await scope.HasAccess(product)) - return ErrorForKendoGridJson( - translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - await productViewModelService.GenerateAllAttributeCombinations(product); - - return Json(new { Success = true }); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ClearAllAttributeCombinations(string productId) - { - var product = await productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - // See the GenerateAllAttributeCombinations comment above - same dataType:'json' contract, same fix. - if (!await scope.HasAccess(product)) - return ErrorForKendoGridJson( - translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - if (ModelState.IsValid) - { - await productViewModelService.ClearAllAttributeCombinations(product); - - if (product.ManageInventoryMethodId == ManageInventoryMethod.ManageStockByAttributes) - { - product.StockQuantity = 0; - product.ReservedQuantity = 0; - await inventoryManageService.UpdateStockProduct(product, false); - } - - return Json(new { Success = true }); - } - - return ErrorForKendoGridJson(ModelState); - } - - #endregion - - #region Product Attribute combination - tier prices - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeCombinationTierPriceList(DataSourceRequest command, - string productId, string productAttributeCombinationId) - { - var product = await productService.GetProductById(productId); - - // HasAccess added here: Admin's original had no ownership check at all on this region (any of its - // four actions). Store checked CanAccessProduct on every action. Vendor's List/Delete used explicit - // checks (CheckAccessToProduct / HasAccessToProduct); Vendor's Insert/Update relied entirely on - // ProductAttributeCombinationTierPricesModel implementing IProductValidVendor (the global - // ValidationFilter resolves ProductValidVendor, a FluentValidation rule comparing - // product.VendorId to CurrentVendor.Id, and the action only proceeded inside `if - // (ModelState.IsValid)`) - a real check, not a no-op, but the shared AdminShared model used here - // does not implement that marker interface, so merging Vendor's Insert/Update as-is would silently - // drop vendor ownership enforcement. scope.HasAccess replaces it uniformly across all four actions - // (no-op for Admin, equivalent-or-stronger for Store/Vendor). - if (!await scope.HasAccess(product)) - return ErrorForKendoGridJson( - translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var tierPriceModel = - await productViewModelService.PrepareProductAttributeCombinationTierPricesModel(product, - productAttributeCombinationId); - var gridModel = new DataSourceResult { - Data = tierPriceModel, - Total = tierPriceModel.Count - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductAttributeCombinationTierPriceInsert(string productId, - string productAttributeCombinationId, ProductModel.ProductAttributeCombinationTierPricesModel model) - { - var product = await productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - // See the List comment above - this is the action where Vendor's IProductValidVendor-driven check - // would have silently disappeared without an explicit scope.HasAccess call. - if (!await scope.HasAccess(product)) - return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var combination = - product.ProductAttributeCombinations.FirstOrDefault(x => x.Id == productAttributeCombinationId); - if (combination != null) - await productViewModelService.InsertProductAttributeCombinationTierPricesModel(product, combination, - model); - - return new JsonResult(""); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] + /// + /// Rows added in the bulk-edit grid: a simple product each, with the fields of the row. The ids + /// are the grid's empty ones, so there is nothing to scope-check; ownership of the new product + /// is set by the service (store for Store, vendor for Vendor) the way the Create screen sets it. + /// + [PermissionAuthorizeAction(PermissionActionName.Create)] [HttpPost] - public async Task ProductAttributeCombinationTierPriceUpdate(string productId, - string productAttributeCombinationId, ProductModel.ProductAttributeCombinationTierPricesModel model) + public async Task BulkEditCreate(IEnumerable products) { - var product = await productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - // See the List comment above. - if (!await scope.HasAccess(product)) - return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var combination = product.ProductAttributeCombinations.FirstOrDefault(x => x.Id == productAttributeCombinationId); - if (combination != null) - await productViewModelService.UpdateProductAttributeCombinationTierPricesModel(product, combination, - model); + var newProducts = products?.Where(x => !string.IsNullOrWhiteSpace(x.Name)).ToList() ?? []; + if (newProducts.Count > 0) await productViewModelService.InsertBulkEdit(newProducts); return new JsonResult(""); } - [PermissionAuthorizeAction(PermissionActionName.Edit)] + [PermissionAuthorizeAction(PermissionActionName.Delete)] [HttpPost] - public async Task ProductAttributeCombinationTierPriceDelete(string productId, - string productAttributeCombinationId, string id) + public async Task BulkEditDelete(IEnumerable products) { - var product = await productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - // HasAccess here matches Store's CanAccessProduct and Vendor's HasAccessToProduct checks (both - // already present on Delete in the originals); normalized to scope.HasAccess for Admin too. - if (!await scope.HasAccess(product)) - return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var combination = product.ProductAttributeCombinations.FirstOrDefault(x => x.Id == productAttributeCombinationId); - if (combination != null) - { - var tierPrice = combination.TierPrices.FirstOrDefault(x => x.Id == id); - if (tierPrice != null) - await productViewModelService.DeleteProductAttributeCombinationTierPrices(product, combination, - tierPrice); - } + var validProducts = await FilterBulkEditProductsByAccess(products); + if (validProducts.Count > 0) await productViewModelService.DeleteBulkEdit(validProducts); return new JsonResult(""); } - #endregion - - #region Reservation - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ListReservations(DataSourceRequest command, string productId) - { - var product = await productService.GetProductById(productId); - - // CanView on this read: a product shared with other stores opens read-only on Store, so its - // tabs must load; the mutating actions below keep the strict HasAccess. History: - // HasAccess (strict): mirrors Store's CanAccessProduct and Vendor's CheckAccessToProduct checks; - // Admin's original had no check at all - normalized to scope.HasAccess for all three hosts. - if (!await scope.CanView(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - //a product shared with other stores is shown read-only; its bids and reservations carry - //other stores' customers and orders, so they are listed only for a product this store owns - if (!await scope.HasAccess(product)) - return Json(new DataSourceResult { Data = Array.Empty(), Total = 0 }); - - var reservations = - await productReservationService.GetProductReservationsByProductId(productId, null, null, - command.Page - 1, command.PageSize); - var reservationModel = reservations - .Select(x => new ProductModel.ReservationModel { - ReservationId = x.Id, - Date = x.Date, - OrderId = x.OrderId, - ProductId = x.ProductId, - Parameter = x.Parameter, - Resource = x.Resource, - Duration = x.Duration - }).ToList(); - - var gridModel = new DataSourceResult { - Data = reservationModel, - Total = reservations.TotalCount - }; - - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task GenerateCalendar(string productId, ProductModel.GenerateCalendarModel model) - { - var product = await productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - // HasAccess (strict), returning the same {errors:...} JSON shape as Store's CanAccessProduct - // check (the client's ajax success handler reads data.errors on denial). Vendor's original threw - // ArgumentException for the combined null-or-access-denied case instead, which the client's - // Kendo/ajax error callback only surfaces as a generic "Error" alert - returning the JSON errors - // message here is strictly more informative and closes Admin's original gap (no check at all). - if (!await scope.HasAccess(product)) - return Json(new { errors = translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions") }); - - var reservations = await productReservationService.GetProductReservationsByProductId(productId, null, null); - if (reservations.Any()) - if (((product.IntervalUnitId == IntervalUnit.Minute || product.IntervalUnitId == IntervalUnit.Hour) && - (IntervalUnit)model.Interval == IntervalUnit.Day) || - (product.IntervalUnitId == IntervalUnit.Day && - ((IntervalUnit)model.IntervalUnit == IntervalUnit.Minute || - (IntervalUnit)model.IntervalUnit == IntervalUnit.Hour))) - return Json(new { - errors = translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Calendar.CannotChangeInterval") - }); - - if (!ModelState.IsValid) - { - var error = (Dictionary>)ModelState.SerializeErrors(); - var s = ""; - foreach (var error1 in error) - foreach (var error2 in error1.Value) - { - var v = (string[])error2.Value; - s += v[0] + "\n"; - } - - return Json(new { errors = s }); - } - - //update fields on product - await productService.UpdateProductField(product, x => x.Interval, model.Interval); - await productService.UpdateProductField(product, x => x.IntervalUnitId, (IntervalUnit)model.IntervalUnit); - await productService.UpdateProductField(product, x => x.IncBothDate, model.IncBothDate); - - var minutesToAdd = (IntervalUnit)model.IntervalUnit switch { - IntervalUnit.Minute => model.Interval, - IntervalUnit.Hour => model.Interval * 60, - IntervalUnit.Day => model.Interval * 60 * 24, - _ => 0 - }; - - var _hourFrom = model.StartTime.Hour; - var _minutesFrom = model.StartTime.Minute; - var _hourTo = model.EndTime.Hour; - var _minutesTo = model.EndTime.Minute; - var _dateFrom = new DateTime(model.StartDate.Value.Year, model.StartDate.Value.Month, model.StartDate.Value.Day, - 0, 0, 0, 0); - var _dateTo = new DateTime(model.EndDate.Value.Year, model.EndDate.Value.Month, model.EndDate.Value.Day, 23, 59, - 59, 999); - if ((IntervalUnit)model.IntervalUnit == IntervalUnit.Day) - { - model.Quantity = 1; - model.Parameter = ""; - } - else - { - model.Resource = ""; - } - - var dates = new List(); - var counter = 0; - for (var iterator = _dateFrom; iterator <= _dateTo; iterator += new TimeSpan(0, minutesToAdd, 0)) - { - if ((IntervalUnit)model.IntervalUnit != IntervalUnit.Day) - { - if (iterator.Hour >= _hourFrom && iterator.Hour <= _hourTo) - { - if (iterator.Hour == _hourTo) - if (iterator.Minute > _minutesTo) - continue; - if (iterator.Hour == _hourFrom) - if (iterator.Minute < _minutesFrom) - continue; - } - else - { - continue; - } - } - - if ((iterator.DayOfWeek == DayOfWeek.Monday && !model.Monday) || - (iterator.DayOfWeek == DayOfWeek.Tuesday && !model.Tuesday) || - (iterator.DayOfWeek == DayOfWeek.Wednesday && !model.Wednesday) || - (iterator.DayOfWeek == DayOfWeek.Thursday && !model.Thursday) || - (iterator.DayOfWeek == DayOfWeek.Friday && !model.Friday) || - (iterator.DayOfWeek == DayOfWeek.Saturday && !model.Saturday) || - (iterator.DayOfWeek == DayOfWeek.Sunday && !model.Sunday)) - continue; - - for (var i = 0; i < model.Quantity.MaxQuantity(); i++) - { - dates.Add(iterator); - try - { - var insert = true; - if ((IntervalUnit)model.IntervalUnit == IntervalUnit.Day) - if (reservations.Any(x => x.Resource == model.Resource && x.Date == iterator)) - insert = false; - if (insert) - { - if (counter++ > 1000) - break; - - await productReservationService.InsertProductReservation(new ProductReservation { - OrderId = "", - Date = iterator, - ProductId = productId, - Resource = model.Resource, - Parameter = model.Parameter, - Duration = model.Interval + " " + enumTranslationService.GetTranslationEnum((IntervalUnit)model.IntervalUnit) - }); - } - } - catch { } - } - } - - return Json(new { success = true }); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ClearCalendar(string productId) - { - var product = await productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - // Throw, not Json({errors}): unlike GenerateCalendar, this view's success callback never reads - // the response body at all (see CreateOrUpdate.Calendar.cshtml on all three hosts - identical, - // no else branch) - a 200 here would silently refresh the grid as if the clear had succeeded. A - // thrown exception at least surfaces a generic error via the ajax error callback. The view not - // reading a denial message is a pre-existing gap, out of scope for this migration. - if (!await scope.HasAccess(product)) - throw new ArgumentException(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var toDelete = await productReservationService.GetProductReservationsByProductId(productId, true, null); - foreach (var record in toDelete) await productReservationService.DeleteProductReservation(record); - - return Json(""); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - public async Task ClearOld(string productId) - { - var product = await productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - // Throw, not Json({errors}): see the comment on ClearCalendar above - this view's success - // callback doesn't read the response body either. - if (!await scope.HasAccess(product)) - throw new ArgumentException(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var toDelete = - (await productReservationService.GetProductReservationsByProductId(productId, true, null)).Where(x => - x.Date < DateTime.UtcNow); - foreach (var record in toDelete) await productReservationService.DeleteProductReservation(record); - - return Json(""); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task ProductReservationDelete(ProductModel.ReservationModel model) - { - var product = await productService.GetProductById(model.ProductId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - if (!await scope.HasAccess(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var toDelete = await productReservationService.GetProductReservation(model.ReservationId); - - // Cross-product IDOR closed here: none of the three original hosts verified that the reservation - // being deleted (looked up purely by model.ReservationId) actually belongs to the product just - // access-checked (model.ProductId). Both are independent, attacker-supplied POST fields - Store - // and Vendor's original code let a caller who owns/has-access-to *any* product pass the access - // check with that product's id while supplying a ReservationId belonging to a different, unowned - // product, deleting a reservation on a product they have no access to. - if (toDelete != null && toDelete.ProductId != product.Id) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - if (toDelete != null) - { - if (string.IsNullOrEmpty(toDelete.OrderId)) - await productReservationService.DeleteProductReservation(toDelete); - else - return Json(new DataSourceResult { - Errors = translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.ProductReservations.CantDeleteWithOrder") - }); - } - - return Json(""); - } - - #endregion - - #region Bids - - [PermissionAuthorizeAction(PermissionActionName.Preview)] - [HttpPost] - public async Task ListBids(DataSourceRequest command, string productId) - { - var product = await productService.GetProductById(productId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - // CanView on this read: a product shared with other stores opens read-only on Store, so its - // tabs must load; the mutating actions below keep the strict HasAccess. History: - // HasAccess: mirrors Store's CanAccessProduct and Vendor's HasAccessToProduct checks; Admin's - // original had no check at all - normalized to scope.HasAccess for all three hosts. - if (!await scope.CanView(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - //a product shared with other stores is shown read-only; its bids and reservations carry - //other stores' customers and orders, so they are listed only for a product this store owns - if (!await scope.HasAccess(product)) - return Json(new DataSourceResult { Data = Array.Empty(), Total = 0 }); - - var (bidModels, totalCount) = - await productViewModelService.PrepareBidMode(productId, command.Page, command.PageSize); - var gridModel = new DataSourceResult { - Data = bidModels.ToList(), - Total = totalCount - }; - return Json(gridModel); - } - - [PermissionAuthorizeAction(PermissionActionName.Edit)] - [HttpPost] - public async Task BidDelete(ProductModel.BidModel model) + /// + /// Filters a caller-supplied bulk-edit product list (BulkEditUpdate/BulkEditDelete) down to the ones + /// the current user may mutate. Same shape as the DeleteSelected gap above: this is a caller-supplied + /// list of ids being mutated in one request, and each id must be scope-checked individually before + /// mutation, not just implicitly trusted because it appeared in a grid the user was shown. + /// + /// Admin's original had NO check at all here - both BulkEditUpdate and BulkEditDelete accepted a + /// client-supplied list of ids and mutated/deleted every one of them unconditionally, a real unscoped + /// bulk-mutate/bulk-delete IDOR (any authenticated admin user hitting these actions directly - not + /// through the grid - could update or delete any product in the system by id). + /// + /// Store's original had this exact check (FilterValidProductsForStore, via CanAccessProduct / + /// AccessToEntityByStore) - HasAccess (strict), matching CanAccessProduct's strict rule, not CanView. + /// + /// Vendor's original enforced the equivalent strict check (HasAccessToProduct / VendorId equality) + /// inside its own separate service (Grand.Web.Vendor's ProductViewModelService.UpdateBulkEdit/ + /// DeleteBulkEdit), not in the controller - functionally the same per-item gate, just placed one layer + /// down. Routing it through scope.HasAccess here reproduces that gate at the controller layer, where + /// the shared IProductViewModelService.UpdateBulkEdit/DeleteBulkEdit used by this class does not + /// filter internally (verified: both loop and mutate/delete every product they're given, no ownership + /// check). + /// + /// No-op for Admin (GlobalAdminDataScope.HasAccess is always true, so validProducts == products). + /// Silently drops missing/inaccessible ids rather than throwing (matches Admin's/Vendor's + /// null-then-skip behavior, not Store's original throw-on-missing-id - the majority behavior, and a + /// single bad id in a bulk request shouldn't fail the whole batch). + /// + private async Task> FilterBulkEditProductsByAccess( + IEnumerable products) { - var product = await productService.GetProductById(model.ProductId); - if (product == null) - throw new ArgumentException("No product found with the specified id"); - - if (!await scope.HasAccess(product)) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); - - var toDelete = await auctionService.GetBid(model.BidId); - - // Cross-product IDOR closed here, identical shape to ProductReservationDelete above: none of the - // three original hosts verified that the bid being deleted (looked up purely by model.BidId) - // actually belongs to the product just access-checked (model.ProductId). Both are independent, - // attacker-supplied POST fields - Store and Vendor's original code let a caller who owns/has - // access to *any* product pass the access check using that product's id while supplying a BidId - // belonging to a different, unowned product, deleting a bid on a product they have no access to. - if (toDelete != null && toDelete.ProductId != product.Id) - return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + if (products == null) return []; - if (toDelete != null) - { - if (string.IsNullOrEmpty(toDelete.OrderId)) - { - //delete bid - await auctionService.DeleteBid(toDelete); - return Json(""); - } + var byId = products + .Where(x => !string.IsNullOrEmpty(x.Id)) + .GroupBy(x => x.Id) + .ToDictionary(g => g.Key, g => g.First()); + if (byId.Count == 0) return []; - return Json(new DataSourceResult { - Errors = translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Bids.CantDeleteWithOrder") - }); - } + var loadedProducts = await productService.GetProductsByIds(byId.Keys.ToArray(), + access: CatalogAccess.IncludingHidden()); + var validProducts = new List(); + foreach (var product in loadedProducts) + if (await scope.HasAccess(product)) + validProducts.Add(byId[product.Id]); - return Json(new DataSourceResult { Errors = "Bid not exists" }); + return validProducts; } #endregion diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductPictureController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductPictureController.cs new file mode 100644 index 000000000..6c7d9d936 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductPictureController.cs @@ -0,0 +1,201 @@ +using Grand.Business.Core.Extensions; +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Business.Core.Interfaces.Storage; +using Grand.Domain.Catalog; +using Grand.Domain.Common; +using Grand.Domain.Media; +using Grand.Domain.Permissions; +using Grand.SharedKernel.Extensions; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.AdminShared.Models.Orders; +using Grand.Web.Common.Controllers; +using Grand.Web.Common.DataSource; +using Grand.Web.Common.Extensions; +using Grand.Web.Common.Localization; +using Grand.Web.Common.Security.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.StaticFiles; + +namespace Grand.Web.AdminShared.Controllers; + +[PermissionAuthorize(PermissionSystemName.Products)] +[AutoValidateAntiforgeryToken] +public abstract class BaseProductPictureController( + IProductViewModelService productViewModelService, + IProductService productService, + ILanguageService languageService, + IRequestTranslationService translationService, + IRequestPermissionService permissionService, + IAdminDataScope scope) + : BaseController +{ + #region Product pictures + + [HttpPost] + public async Task ProductPictureAdd( + IFormFileCollection files, + Reference reference, string objectId, + [FromServices] IPictureService pictureService, + [FromServices] MediaSettings mediaSettings) + { + if (!await permissionService.Authorize(PermissionSystemName.Pictures)) + return Json(new { + success = false, + message = "Access denied - picture permissions" + }); + + if (reference != Reference.Product || string.IsNullOrEmpty(objectId)) + return Json(new { + success = false, + message = "Please save form before upload new pictures" + }); + + if (!files.Any()) + return Json(new { + success = false, + message = "No files uploaded" + }); + + var product = await productService.GetProductById(objectId); + + if (!await scope.HasAccess(product)) + return Json(new { + success = false, + message = translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions") + }); + + var values = new List<(string pictureUrl, string pictureId)>(); + foreach (var file in files) + { + var fileName = Path.GetFileName(file.FileName); + var contentType = file.ContentType; + var fileExtension = Path.GetExtension(fileName); + if (string.IsNullOrEmpty(contentType)) + _ = new FileExtensionContentTypeProvider().TryGetContentType(fileName, out contentType); + + if (FileExtensions.GetAllowedMediaFileTypes(mediaSettings.AllowedFileTypes).IsAllowedMediaFileType(fileExtension)) + { + var fileBinary = file.GetDownloadBits(); + //insert picture + var picture = await pictureService.InsertPicture(fileBinary, contentType, null, reference: reference, + objectId: objectId); + var pictureUrl = await pictureService.GetPictureUrl(picture); + + values.Add((pictureUrl, picture.Id)); + //assign picture to the product + await productViewModelService.InsertProductPicture(product, picture, 0); + } + } + + return Json(new { success = values.Any(), data = values }); + } + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task ProductPictureList(DataSourceRequest command, string productId) + { + var product = await productService.GetProductById(productId); + + // CanView on this read: a product shared with other stores opens read-only on Store, so its + // tabs must load; the mutating actions below keep the strict HasAccess. History: + // HasAccess (strict): mirrors Store's CanAccessProduct and Vendor's CheckAccessToProduct gating + // this action on both hosts. Admin's original had no check at all. Vendor's original signature + // also lacked the unused `DataSourceRequest command` parameter that Admin/Store both bind (Kendo + // posts it, but no host ever reads it) - kept here to match the two-of-three shape; harmless for + // Vendor since an unused extra bound parameter changes nothing about the response. + if (!await scope.CanView(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var productPicturesModel = await productViewModelService.PrepareProductPicturesModel(product); + var gridModel = new DataSourceResult { + Data = productPicturesModel, + Total = productPicturesModel.Count + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + public async Task ProductPicturePopup(string productId, string id) + { + var product = await productService.GetProductById(productId); + if (product == null) + return Content("Product not exist"); + + // HasAccess (strict): mirrors Store's CanAccessProduct and Vendor's CheckAccessToProduct gating + // this action on both hosts. Admin's original had no check at all. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var pp = product.ProductPictures.FirstOrDefault(x => x.Id == id); + if (pp == null) + return Content("Product picture not exist"); + + var (model, picture) = await productViewModelService.PrepareProductPictureModel(product, pp); + //locales + await AddLocales(languageService, model.Locales, (locale, languageId) => + { + locale.AltAttribute = picture?.GetTranslation(x => x.AltAttribute, languageId, false); + locale.TitleAttribute = picture?.GetTranslation(x => x.TitleAttribute, languageId, false); + }); + + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductPicturePopup(ProductModel.ProductPictureModel model) + { + if (ModelState.IsValid) + { + var product = await productService.GetProductById(model.ProductId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // HasAccess (strict): mirrors Store's CanAccessProduct check on this action. Vendor's original + // ProductPicturePopup(POST) had no check at all, letting any vendor rename/re-alt-text another + // vendor's product picture by posting its productId/model.Id - closed here the same way as the + // other rows in this task. + if (!await scope.HasAccess(product)) + throw new ArgumentException(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (product.ProductPictures.FirstOrDefault(x => x.Id == model.Id) == null) + throw new ArgumentException("No product picture found with the specified id"); + + await productViewModelService.UpdateProductPicture(model); + + return Content(""); + } + + Error(ModelState); + + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductPictureDelete(ProductModel.ProductPictureModel model) + { + var product = await productService.GetProductById(model.ProductId); + + // HasAccess (strict): mirrors Store's CanAccessProduct check on this action. Vendor's original + // ProductPictureDelete had no check at all, letting any vendor delete another vendor's product + // picture by posting its productId/model.Id - closed here the same way as ProductPicturePopup(POST) + // above. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + await productViewModelService.DeleteProductPicture(model); + return new JsonResult(""); + } + + return ErrorForKendoGridJson(ModelState); + } + + #endregion +} diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductPriceController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductPriceController.cs new file mode 100644 index 000000000..a0801ea91 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductPriceController.cs @@ -0,0 +1,312 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Domain.Catalog; +using Grand.Domain.Permissions; +using Grand.SharedKernel.Extensions; +using Grand.Web.AdminShared.Extensions.Mapping; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.AdminShared.Models.Orders; +using Grand.Web.Common.Controllers; +using Grand.Web.Common.DataSource; +using Grand.Web.Common.Extensions; +using Grand.Web.Common.Localization; +using Grand.Web.Common.Security.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.AdminShared.Controllers; + +[PermissionAuthorize(PermissionSystemName.Products)] +[AutoValidateAntiforgeryToken] +public abstract class BaseProductPriceController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IDateTimeService dateTimeService, + IAdminDataScope scope) + : BaseController +{ + #region Product currency price + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task ProductPriceList(DataSourceRequest command, string productId) + { + var product = await productService.GetProductById(productId); + + // CanView on this read: a product shared with other stores opens read-only on Store, so its + // tabs must load; the mutating actions below keep the strict HasAccess. History: + // HasAccess (strict) mirrors Store's CanAccessProduct (AccessToEntityByStore) check + // on this action. Applying it uniformly also closes real gaps on the mutate actions below: Store's + // original checked access only on List/Insert (ProductPriceUpdate/ProductPriceDelete had no check + // at all), and Vendor's original checked access only on List (ProductPriceInsert/Update/Delete had + // no check at all) - both let another party's product prices be updated/deleted by id. + if (!await scope.CanView(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var items = new List(); + foreach (var item in product.ProductPrices) + items.Add(new ProductModel.ProductPriceModel { + Id = item.Id, + CurrencyCode = item.CurrencyCode, + Price = item.Price, + ProductId = product.Id + }); + + var gridModel = new DataSourceResult { + Data = items, + Total = items.Count + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductPriceInsert(ProductModel.ProductPriceModel model) + { + var product = await productService.GetProductById(model.ProductId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (product.ProductPrices.Any(x => x.CurrencyCode == model.CurrencyCode)) + throw new ArgumentException("Currency code exists"); + + if (ModelState.IsValid) + try + { + await productService.InsertProductPrice(new ProductPrice { + ProductId = product.Id, + CurrencyCode = model.CurrencyCode, + Price = model.Price + }); + return new JsonResult(""); + } + catch (Exception ex) + { + return ErrorForKendoGridJson(ex.Message); + } + + return ErrorForKendoGridJson(ModelState); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductPriceUpdate(ProductModel.ProductPriceModel model) + { + var product = await productService.GetProductById(model.ProductId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var productPrice = product.ProductPrices.FirstOrDefault(x => x.Id == model.Id); + if (productPrice == null) + throw new ArgumentException("Product price model not exists"); + + if (product.ProductPrices.Any(x => x.Id != model.Id && x.CurrencyCode == model.CurrencyCode)) + throw new ArgumentException("You can't use this currency code"); + + if (ModelState.IsValid) + try + { + productPrice!.CurrencyCode = model.CurrencyCode; + productPrice.Price = model.Price; + productPrice.ProductId = model.ProductId; + + await productService.UpdateProductPrice(productPrice); + + return new JsonResult(""); + } + catch (Exception ex) + { + return ErrorForKendoGridJson(ex.Message); + } + + return ErrorForKendoGridJson(ModelState); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductPriceDelete(ProductModel.ProductPriceModel model) + { + var product = await productService.GetProductById(model.ProductId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var productPrice = product.ProductPrices.FirstOrDefault(x => x.Id == model.Id); + if (productPrice == null) + throw new ArgumentException("Product price model not exists"); + + if (ModelState.IsValid) + { + productPrice!.ProductId = model.ProductId; + await productService.DeleteProductPrice(productPrice); + + return new JsonResult(""); + } + + // ErrorForKendoGridJson(ModelState), not Content(ModelState.GetErrors()): matches Admin/Store. + // Vendor's original used the Vendor-only GetErrors() extension here (and inconsistently, since + // several of Vendor's *other* grid actions in this same file already use ErrorForKendoGridJson) - + // not a deliberate host-specific contract, just Vendor's own inconsistency. AdminShared cannot + // reference Grand.Web.Vendor.Extensions.ModelStateExtensions.GetErrors() from this project anyway. + return ErrorForKendoGridJson(ModelState); + } + + #endregion + + #region Tier prices + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task TierPriceList(DataSourceRequest command, string productId) + { + var product = await productService.GetProductById(productId); + + // CanView on this read: a product shared with other stores opens read-only on Store, so its + // tabs must load; the mutating actions below keep the strict HasAccess. History: + // HasAccess on List (Store/Vendor both checked here; Admin's Global scope is a no-op). Closes the + // same class of gap as "Product currency price": Vendor's original checked ownership only on List + // and TierPriceEditPopup(GET) - TierPriceCreatePopup(POST), TierPriceEditPopup(POST) and + // TierPriceDelete had NO ownership check at all, so a vendor could create/update/delete a tier + // price on any product (not just their own) by posting a known productId. Applying scope.HasAccess + // uniformly on every mutating action below closes that. + if (!await scope.CanView(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + // PrepareTierPriceModel(product) now reads scope internally (Task 9); scope.DefaultStoreId + // is null for Admin/Vendor (Global/VendorProduct scopes) and the staff store for Store. + var tierPricesModel = await productViewModelService.PrepareTierPriceModel(product); + var gridModel = new DataSourceResult { + Data = tierPricesModel, + Total = tierPricesModel.Count + }; + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task TierPriceCreatePopup(string productId) + { + var model = new ProductModel.TierPriceModel { + ProductId = productId + }; + await productViewModelService.PrepareTierPriceModel(model); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task TierPriceCreatePopup(ProductModel.TierPriceModel model) + { + if (ModelState.IsValid) + { + var product = await productService.GetProductById(model.ProductId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // Vendor's original never even loaded the product here - it inserted straight off + // model.ProductId with no ownership check at all. See the List-action comment above. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var tierPrice = model.ToEntity(dateTimeService); + await productService.InsertTierPrice(tierPrice, product.Id); + + return Content(""); + } + + Error(ModelState); + //If we got this far, something failed, redisplay form + await productViewModelService.PrepareTierPriceModel(model); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task TierPriceEditPopup(string id, string productId) + { + var product = await productService.GetProductById(productId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // HasAccess here too: Store's original never checked ownership on this GET action (only on the + // List/Create-POST/Edit-POST/Delete siblings), which would let store staff open (read-only, + // via this popup) the tier-price edit view for a product outside their store. Vendor's original did + // check (HasAccessToProduct), so this closes Store's gap while keeping Vendor's existing behavior. + if (!await scope.HasAccess(product)) + return Content("This is not your product"); + + var tierPrice = product.TierPrices.FirstOrDefault(x => x.Id == id); + if (tierPrice == null) + return Content("Empty tier price"); + + var model = tierPrice.ToModel(dateTimeService); + model.ProductId = productId; + await productViewModelService.PrepareTierPriceModel(model); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task TierPriceEditPopup(string productId, ProductModel.TierPriceModel model) + { + if (ModelState.IsValid) + { + var product = await productService.GetProductById(productId, true); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // See the List-action comment: Vendor's original had no ownership check on this POST at all. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var tierPrice = product.TierPrices.FirstOrDefault(x => x.Id == model.Id); + if (tierPrice == null) + return Content("Empty tier price"); + + tierPrice = model.ToEntity(tierPrice, dateTimeService); + await productService.UpdateTierPrice(tierPrice, product.Id); + + return Content(""); + } + + Error(ModelState); + //stores + await productViewModelService.PrepareTierPriceModel(model); + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task TierPriceDelete(ProductModel.TierPriceDeleteModel model) + { + if (ModelState.IsValid) + { + var product = await productService.GetProductById(model.ProductId, true); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // See the List-action comment: Vendor's original had no ownership check on Delete at all. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var tierPrice = product.TierPrices.FirstOrDefault(x => x.Id == model.Id); + if (tierPrice == null) + throw new ArgumentException("No tier price found with the specified id"); + + await productService.DeleteTierPrice(tierPrice, product.Id); + return new JsonResult(""); + } + + return ErrorForKendoGridJson(ModelState); + } + + #endregion +} diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductRelatedController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductRelatedController.cs new file mode 100644 index 000000000..f50e7024b --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductRelatedController.cs @@ -0,0 +1,543 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Domain.Permissions; +using Grand.SharedKernel.Extensions; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.AdminShared.Models.Orders; +using Grand.Web.Common.Controllers; +using Grand.Web.Common.DataSource; +using Grand.Web.Common.Localization; +using Grand.Web.Common.Security.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.AdminShared.Controllers; + +[PermissionAuthorize(PermissionSystemName.Products)] +[AutoValidateAntiforgeryToken] +public abstract class BaseProductRelatedController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseController +{ + #region Related products + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task RelatedProductList(DataSourceRequest command, string productId) + { + var product = await productService.GetProductById(productId); + + // CanView on this read: a product shared with other stores opens read-only on Store, so its + // tabs must load; the mutating actions below keep the strict HasAccess. History: + // HasAccess (strict) has the same shape as "Product categories"/"Product collections" - + // mirrors Store's CanAccessProduct and Vendor's CheckAccessToProduct gating this action on both + // hosts. Applying it uniformly also closes the same kind of gap found in those two regions: + // Vendor's original RelatedProductUpdate/Delete/AddPopup(POST) (below) had no ownership check at + // all - only List checked - letting any vendor mutate another vendor's related-product mappings + // by id. + if (!await scope.CanView(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var relatedProducts = product.RelatedProducts.OrderBy(x => x.DisplayOrder); + var relatedProductsModel = new List(); + foreach (var x in relatedProducts) + relatedProductsModel.Add(new ProductModel.RelatedProductModel { + Id = x.Id, + ProductId1 = productId, + ProductId2 = x.ProductId2, + Product2Name = (await productService.GetProductById(x.ProductId2))?.Name, + DisplayOrder = x.DisplayOrder + }); + + var gridModel = new DataSourceResult { + Data = relatedProductsModel, + Total = relatedProductsModel.Count + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task RelatedProductUpdate(ProductModel.RelatedProductModel model) + { + var product = await productService.GetProductById(model.ProductId1); + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + await productViewModelService.UpdateRelatedProductModel(model); + return new JsonResult(""); + } + + return ErrorForKendoGridJson(ModelState); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task RelatedProductDelete(ProductModel.RelatedProductModel model) + { + var product = await productService.GetProductById(model.ProductId1); + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + await productViewModelService.DeleteRelatedProductModel(model); + return new JsonResult(""); + } + + return ErrorForKendoGridJson(ModelState); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task RelatedProductAddPopup(string productId) + { + // No access check here in any of the three original hosts (Admin/Store/Vendor all open this + // popup unconditionally once the Edit permission is satisfied) - only the mutating POST below + // ties access to a specific product. PrepareRelatedProductModel() now reads scope + // internally (Task 9). + var model = await productViewModelService.PrepareRelatedProductModel(); + model.ProductId = productId; + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task RelatedProductAddPopupList(DataSourceRequest command, + ProductModel.AddRelatedProductModel model) + { + if (scope.DefaultStoreId is not null) model.SearchStoreId = scope.DefaultStoreId; + + var (products, totalCount) = + await productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); + var gridModel = new DataSourceResult { + Data = products.ToList(), + Total = totalCount + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task RelatedProductAddPopup(ProductModel.AddRelatedProductModel model) + { + var product = await productService.GetProductById(model.ProductId); + // HasAccess (strict): mirrors Store's CanAccessProduct check on this action. Vendor's original + // RelatedProductAddPopup(POST) had no check at all, letting any vendor add related-product + // mappings onto another vendor's product by posting its id - closed here the same way as the + // List/Update/Delete gap above. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + if (model.SelectedProductIds != null) await productViewModelService.InsertRelatedProductModel(model); + return Content(""); + } + + return await InvalidRelatedProductAddPopupResult(model); + } + + /// Hook for the host-specific invalid-model-state response of the AddPopup(POST) action + /// above. Admin and Store both re-prepare the popup model and return the View; Vendor instead + /// returns Content(ModelState.GetErrors()) - a Vendor-only extension method that AdminShared cannot + /// reference. Default here matches Admin/Store; a future Vendor subclass overrides it once hosts are + /// subclassed onto BaseProductController (Task 11). + protected virtual async Task InvalidRelatedProductAddPopupResult(ProductModel.AddRelatedProductModel model) + { + Error(ModelState); + model = await productViewModelService.PrepareRelatedProductModel(); + return View(model); + } + + #endregion + + #region Similar products + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task SimilarProductList(DataSourceRequest command, string productId) + { + var product = await productService.GetProductById(productId); + + // CanView on this read: a product shared with other stores opens read-only on Store, so its + // tabs must load; the mutating actions below keep the strict HasAccess. History: + // HasAccess (strict) has the same shape as "Related products" above - mirrors Store's + // CanAccessProduct check on this action. Applying it uniformly also closes a real gap: Vendor's + // original SimilarProductUpdate/Delete/AddPopup(GET/POST) (below) had no ownership check at all - + // only List checked (via CheckAccessToProduct) - letting any vendor mutate another vendor's + // similar-product mappings by id. + if (!await scope.CanView(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var similarProducts = product.SimilarProducts.OrderBy(x => x.DisplayOrder); + var similarProductsModel = new List(); + foreach (var x in similarProducts) + similarProductsModel.Add(new ProductModel.SimilarProductModel { + Id = x.Id, + ProductId1 = productId, + ProductId2 = x.ProductId2, + Product2Name = (await productService.GetProductById(x.ProductId2))?.Name, + DisplayOrder = x.DisplayOrder + }); + + var gridModel = new DataSourceResult { + Data = similarProductsModel, + Total = similarProductsModel.Count + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task SimilarProductUpdate(ProductModel.SimilarProductModel model) + { + var product = await productService.GetProductById(model.ProductId1); + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + await productViewModelService.UpdateSimilarProductModel(model); + return new JsonResult(""); + } + + return ErrorForKendoGridJson(ModelState); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task SimilarProductDelete(ProductModel.SimilarProductModel model) + { + var product = await productService.GetProductById(model.ProductId1); + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + await productViewModelService.DeleteSimilarProductModel(model); + return new JsonResult(""); + } + + return ErrorForKendoGridJson(ModelState); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task SimilarProductAddPopup(string productId) + { + // No access check here in any of the three original hosts (Admin/Store/Vendor all open this + // popup unconditionally once the Edit permission is satisfied) - only the mutating POST below + // ties access to a specific product. PrepareSimilarProductModel() now reads scope + // internally (Task 9). + var model = await productViewModelService.PrepareSimilarProductModel(); + model.ProductId = productId; + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task SimilarProductAddPopupList(DataSourceRequest command, + ProductModel.AddSimilarProductModel model) + { + if (scope.DefaultStoreId is not null) model.SearchStoreId = scope.DefaultStoreId; + + var (products, totalCount) = + await productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); + var gridModel = new DataSourceResult { + Data = products.ToList(), + Total = totalCount + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task SimilarProductAddPopup(ProductModel.AddSimilarProductModel model) + { + var product = await productService.GetProductById(model.ProductId); + // HasAccess (strict): mirrors Store's CanAccessProduct check on this action. Vendor's original + // SimilarProductAddPopup(POST) had no check at all, letting any vendor add similar-product + // mappings onto another vendor's product by posting its id - closed here the same way as the + // List/Update/Delete gap above. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + if (model.SelectedProductIds != null) await productViewModelService.InsertSimilarProductModel(model); + return Content(""); + } + + return await InvalidSimilarProductAddPopupResult(model); + } + + /// Hook for the host-specific invalid-model-state response of the AddPopup(POST) action + /// above. Admin and Store both re-prepare the popup model and return the View; Vendor instead + /// returns Content(ModelState.GetErrors()) - a Vendor-only extension method that AdminShared cannot + /// reference. Default here matches Admin/Store; a future Vendor subclass overrides it once hosts are + /// subclassed onto BaseProductController (Task 11). + protected virtual async Task InvalidSimilarProductAddPopupResult(ProductModel.AddSimilarProductModel model) + { + Error(ModelState); + model = await productViewModelService.PrepareSimilarProductModel(); + return View(model); + } + + #endregion + + #region Cross-sell products + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task CrossSellProductList(DataSourceRequest command, string productId) + { + var product = await productService.GetProductById(productId); + + // CanView on this read: a product shared with other stores opens read-only on Store, so its + // tabs must load; the mutating actions below keep the strict HasAccess. History: + // HasAccess (strict) has the same shape as "Related products"/"Bundle products" above - + // mirrors Store's CanAccessProduct and Vendor's CheckAccessToProduct gating this action on both + // hosts. Admin's original CrossSellProductList had no check at all - applying HasAccess uniformly + // also closes that gap without changing Admin's superuser behaviour (HasAccess is a no-op for + // Admin's scope). + if (!await scope.CanView(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var crossSellProducts = product.CrossSellProduct; + var crossSellProductsModel = new List(); + foreach (var x in crossSellProducts) + crossSellProductsModel.Add(new ProductModel.CrossSellProductModel { + Id = x, + ProductId = product.Id, + Product2Name = (await productService.GetProductById(x))?.Name + }); + var gridModel = new DataSourceResult { + Data = crossSellProductsModel, + Total = crossSellProductsModel.Count + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task CrossSellProductDelete(ProductModel.CrossSellProductModel model) + { + var product = await productService.GetProductById(model.ProductId); + if (product == null) throw new ArgumentException("Product not exists"); + + // HasAccess (strict): mirrors Store's CanAccessProduct check on this action. Vendor's original + // CrossSellProductDelete had no check at all, letting any vendor delete another vendor's + // cross-sell-product mappings by id - closed here the same way as the List gap above. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var crossSellProduct = product.CrossSellProduct.FirstOrDefault(x => x == model.Id); + if (string.IsNullOrEmpty(crossSellProduct)) + throw new ArgumentException("No cross-sell product found with the specified id"); + + if (ModelState.IsValid) + { + await productViewModelService.DeleteCrossSellProduct(product.Id, crossSellProduct); + return new JsonResult(""); + } + + return ErrorForKendoGridJson(ModelState); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task CrossSellProductAddPopup(string productId) + { + // No access check here in any of the three original hosts (Admin/Store/Vendor all open this + // popup unconditionally once the Edit permission is satisfied) - only the mutating POST below + // ties access to a specific product. PrepareCrossSellProductModel() now reads scope + // internally (Task 9). + var model = await productViewModelService.PrepareCrossSellProductModel(); + model.ProductId = productId; + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task CrossSellProductAddPopupList(DataSourceRequest command, + ProductModel.AddCrossSellProductModel model) + { + if (scope.DefaultStoreId is not null) model.SearchStoreId = scope.DefaultStoreId; + + var (products, totalCount) = + await productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); + var gridModel = new DataSourceResult { + Data = products.ToList(), + Total = totalCount + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task CrossSellProductAddPopup(ProductModel.AddCrossSellProductModel model) + { + var product = await productService.GetProductById(model.ProductId); + // HasAccess (strict): mirrors Store's CanAccessProduct check on this action. Vendor's original + // CrossSellProductAddPopup(POST) had no check at all, letting any vendor add cross-sell-product + // mappings onto another vendor's product by posting its id - closed here the same way as the + // List/Delete gap above. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + if (model.SelectedProductIds != null) await productViewModelService.InsertCrossSellProductModel(model); + return Content(""); + } + + return await InvalidCrossSellProductAddPopupResult(model); + } + + /// Hook for the host-specific invalid-model-state response of the AddPopup(POST) action + /// above. Admin and Store both re-prepare the popup model and return the View; Vendor instead + /// returns Content(ModelState.GetErrors()) - a Vendor-only extension method that AdminShared cannot + /// reference. Default here matches Admin/Store; a future Vendor subclass overrides it once hosts are + /// subclassed onto BaseProductController (Task 11). + protected virtual async Task InvalidCrossSellProductAddPopupResult(ProductModel.AddCrossSellProductModel model) + { + Error(ModelState); + model = await productViewModelService.PrepareCrossSellProductModel(); + return View(model); + } + + #endregion + + #region Recommended products + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task RecommendedProductList(DataSourceRequest command, string productId) + { + var product = await productService.GetProductById(productId); + + // CanView on this read: a product shared with other stores opens read-only on Store, so its + // tabs must load; the mutating actions below keep the strict HasAccess. History: + // HasAccess (strict) has the same shape as "Cross-sell products" above - mirrors Store's + // CanAccessProduct and Vendor's CheckAccessToProduct gating this action on both hosts. Admin's + // original RecommendedProductList had no check at all - applying HasAccess uniformly also closes + // that gap without changing Admin's superuser behaviour (HasAccess is a no-op for Admin's scope). + // Vendor's original signature also dropped the DataSourceRequest command parameter entirely + // (unused by the body on any host either way) - kept here for parity with Admin/Store. + if (!await scope.CanView(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var recommendedProductsModel = new List(); + foreach (var x in product.RecommendedProduct) + recommendedProductsModel.Add(new ProductModel.RecommendedProductModel { + Id = x, + ProductId = product.Id, + Product2Name = (await productService.GetProductById(x))?.Name + }); + var gridModel = new DataSourceResult { + Data = recommendedProductsModel, + Total = recommendedProductsModel.Count + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task RecommendedProductDelete(ProductModel.RecommendedProductModel model) + { + var product = await productService.GetProductById(model.ProductId); + if (product == null) throw new ArgumentException("Product not exists"); + + // HasAccess (strict): mirrors Store's CanAccessProduct check on this action. Vendor's original + // RecommendedProductDelete had no check at all, letting any vendor delete another vendor's + // recommended-product mappings by id - closed here the same way as the List gap above. + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var recommendedProduct = product.RecommendedProduct.FirstOrDefault(x => x == model.Id); + if (string.IsNullOrEmpty(recommendedProduct)) + throw new ArgumentException("No recommended product found with the specified id"); + + if (ModelState.IsValid) + { + await productViewModelService.DeleteRecommendedProduct(product.Id, recommendedProduct); + return new JsonResult(""); + } + + return ErrorForKendoGridJson(ModelState); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task RecommendedProductAddPopup(string productId) + { + // No access check here in any of the three original hosts (Admin/Store/Vendor all open this + // popup unconditionally once the Edit permission is satisfied) - only the mutating POST below + // ties access to a specific product. PrepareRecommendedProductModel() now reads scope + // internally (Task 9). + var model = await productViewModelService.PrepareRecommendedProductModel(); + model.ProductId = productId; + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task RecommendedProductAddPopupList(DataSourceRequest command, + ProductModel.AddRecommendedProductModel model) + { + if (scope.DefaultStoreId is not null) model.SearchStoreId = scope.DefaultStoreId; + + var (products, totalCount) = + await productViewModelService.PrepareProductModel(model, command.Page, command.PageSize); + var gridModel = new DataSourceResult { + Data = products.ToList(), + Total = totalCount + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task RecommendedProductAddPopup(ProductModel.AddRecommendedProductModel model) + { + var product = await productService.GetProductById(model.ProductId); + // HasAccess (strict): mirrors Store's CanAccessProduct check on this action. Vendor's original + // RecommendedProductAddPopup(POST) had no check at all, letting any vendor add recommended-product + // mappings onto another vendor's product by posting its id - closed here the same way as the + // List/Delete gap above. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (ModelState.IsValid) + { + if (model.SelectedProductIds != null) await productViewModelService.InsertRecommendedProductModel(model); + return Content(""); + } + + return await InvalidRecommendedProductAddPopupResult(model); + } + + /// Hook for the host-specific invalid-model-state response of the AddPopup(POST) action + /// above. Admin and Store both re-prepare the popup model and return the View; Vendor instead + /// returns Content(ModelState.GetErrors()) - a Vendor-only extension method that AdminShared cannot + /// reference. Default here matches Admin/Store; a future Vendor subclass overrides it once hosts are + /// subclassed onto BaseProductController (Task 11). + protected virtual async Task InvalidRecommendedProductAddPopupResult(ProductModel.AddRecommendedProductModel model) + { + Error(ModelState); + model = await productViewModelService.PrepareRecommendedProductModel(); + return View(model); + } + + #endregion +} diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductReservationController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductReservationController.cs new file mode 100644 index 000000000..28b36b856 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductReservationController.cs @@ -0,0 +1,280 @@ +using Grand.Business.Core.Extensions; +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Domain.Permissions; +using Grand.SharedKernel.Extensions; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.AdminShared.Models.Orders; +using Grand.Web.Common.Controllers; +using Grand.Web.Common.DataSource; +using Grand.Web.Common.Extensions; +using Grand.Web.Common.Helpers; +using Grand.Web.Common.Localization; +using Grand.Web.Common.Security.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.AdminShared.Controllers; + +[PermissionAuthorize(PermissionSystemName.Products)] +[AutoValidateAntiforgeryToken] +public abstract class BaseProductReservationController( + IProductService productService, + IRequestTranslationService translationService, + IProductReservationService productReservationService, + IEnumTranslationService enumTranslationService, + IAdminDataScope scope) + : BaseController +{ + #region Reservation + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task ListReservations(DataSourceRequest command, string productId) + { + var product = await productService.GetProductById(productId); + + // CanView on this read: a product shared with other stores opens read-only on Store, so its + // tabs must load; the mutating actions below keep the strict HasAccess. History: + // HasAccess (strict): mirrors Store's CanAccessProduct and Vendor's CheckAccessToProduct checks; + // Admin's original had no check at all - normalized to scope.HasAccess for all three hosts. + if (!await scope.CanView(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + //a product shared with other stores is shown read-only; its bids and reservations carry + //other stores' customers and orders, so they are listed only for a product this store owns + if (!await scope.HasAccess(product)) + return Json(new DataSourceResult { Data = Array.Empty(), Total = 0 }); + + var reservations = + await productReservationService.GetProductReservationsByProductId(productId, null, null, + command.Page - 1, command.PageSize); + var reservationModel = reservations + .Select(x => new ProductModel.ReservationModel { + ReservationId = x.Id, + Date = x.Date, + OrderId = x.OrderId, + ProductId = x.ProductId, + Parameter = x.Parameter, + Resource = x.Resource, + Duration = x.Duration + }).ToList(); + + var gridModel = new DataSourceResult { + Data = reservationModel, + Total = reservations.TotalCount + }; + + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task GenerateCalendar(string productId, ProductModel.GenerateCalendarModel model) + { + var product = await productService.GetProductById(productId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // HasAccess (strict), returning the same {errors:...} JSON shape as Store's CanAccessProduct + // check (the client's ajax success handler reads data.errors on denial). Vendor's original threw + // ArgumentException for the combined null-or-access-denied case instead, which the client's + // Kendo/ajax error callback only surfaces as a generic "Error" alert - returning the JSON errors + // message here is strictly more informative and closes Admin's original gap (no check at all). + if (!await scope.HasAccess(product)) + return Json(new { errors = translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions") }); + + var reservations = await productReservationService.GetProductReservationsByProductId(productId, null, null); + if (reservations.Any()) + if (((product.IntervalUnitId == IntervalUnit.Minute || product.IntervalUnitId == IntervalUnit.Hour) && + (IntervalUnit)model.Interval == IntervalUnit.Day) || + (product.IntervalUnitId == IntervalUnit.Day && + ((IntervalUnit)model.IntervalUnit == IntervalUnit.Minute || + (IntervalUnit)model.IntervalUnit == IntervalUnit.Hour))) + return Json(new { + errors = translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Calendar.CannotChangeInterval") + }); + + if (!ModelState.IsValid) + { + var error = (Dictionary>)ModelState.SerializeErrors(); + var s = ""; + foreach (var error1 in error) + foreach (var error2 in error1.Value) + { + var v = (string[])error2.Value; + s += v[0] + "\n"; + } + + return Json(new { errors = s }); + } + + //update fields on product + await productService.UpdateProductField(product, x => x.Interval, model.Interval); + await productService.UpdateProductField(product, x => x.IntervalUnitId, (IntervalUnit)model.IntervalUnit); + await productService.UpdateProductField(product, x => x.IncBothDate, model.IncBothDate); + + var minutesToAdd = (IntervalUnit)model.IntervalUnit switch { + IntervalUnit.Minute => model.Interval, + IntervalUnit.Hour => model.Interval * 60, + IntervalUnit.Day => model.Interval * 60 * 24, + _ => 0 + }; + + var _hourFrom = model.StartTime.Hour; + var _minutesFrom = model.StartTime.Minute; + var _hourTo = model.EndTime.Hour; + var _minutesTo = model.EndTime.Minute; + var _dateFrom = new DateTime(model.StartDate.Value.Year, model.StartDate.Value.Month, model.StartDate.Value.Day, + 0, 0, 0, 0); + var _dateTo = new DateTime(model.EndDate.Value.Year, model.EndDate.Value.Month, model.EndDate.Value.Day, 23, 59, + 59, 999); + if ((IntervalUnit)model.IntervalUnit == IntervalUnit.Day) + { + model.Quantity = 1; + model.Parameter = ""; + } + else + { + model.Resource = ""; + } + + var dates = new List(); + var counter = 0; + for (var iterator = _dateFrom; iterator <= _dateTo; iterator += new TimeSpan(0, minutesToAdd, 0)) + { + if ((IntervalUnit)model.IntervalUnit != IntervalUnit.Day) + { + if (iterator.Hour >= _hourFrom && iterator.Hour <= _hourTo) + { + if (iterator.Hour == _hourTo) + if (iterator.Minute > _minutesTo) + continue; + if (iterator.Hour == _hourFrom) + if (iterator.Minute < _minutesFrom) + continue; + } + else + { + continue; + } + } + + if ((iterator.DayOfWeek == DayOfWeek.Monday && !model.Monday) || + (iterator.DayOfWeek == DayOfWeek.Tuesday && !model.Tuesday) || + (iterator.DayOfWeek == DayOfWeek.Wednesday && !model.Wednesday) || + (iterator.DayOfWeek == DayOfWeek.Thursday && !model.Thursday) || + (iterator.DayOfWeek == DayOfWeek.Friday && !model.Friday) || + (iterator.DayOfWeek == DayOfWeek.Saturday && !model.Saturday) || + (iterator.DayOfWeek == DayOfWeek.Sunday && !model.Sunday)) + continue; + + for (var i = 0; i < model.Quantity.MaxQuantity(); i++) + { + dates.Add(iterator); + try + { + var insert = true; + if ((IntervalUnit)model.IntervalUnit == IntervalUnit.Day) + if (reservations.Any(x => x.Resource == model.Resource && x.Date == iterator)) + insert = false; + if (insert) + { + if (counter++ > 1000) + break; + + await productReservationService.InsertProductReservation(new ProductReservation { + OrderId = "", + Date = iterator, + ProductId = productId, + Resource = model.Resource, + Parameter = model.Parameter, + Duration = model.Interval + " " + enumTranslationService.GetTranslationEnum((IntervalUnit)model.IntervalUnit) + }); + } + } + catch { } + } + } + + return Json(new { success = true }); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task ClearCalendar(string productId) + { + var product = await productService.GetProductById(productId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // Throw, not Json({errors}): unlike GenerateCalendar, this view's success callback never reads + // the response body at all (see CreateOrUpdate.Calendar.cshtml on all three hosts - identical, + // no else branch) - a 200 here would silently refresh the grid as if the clear had succeeded. A + // thrown exception at least surfaces a generic error via the ajax error callback. The view not + // reading a denial message is a pre-existing gap, out of scope for this migration. + if (!await scope.HasAccess(product)) + throw new ArgumentException(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var toDelete = await productReservationService.GetProductReservationsByProductId(productId, true, null); + foreach (var record in toDelete) await productReservationService.DeleteProductReservation(record); + + return Json(""); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task ClearOld(string productId) + { + var product = await productService.GetProductById(productId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + // Throw, not Json({errors}): see the comment on ClearCalendar above - this view's success + // callback doesn't read the response body either. + if (!await scope.HasAccess(product)) + throw new ArgumentException(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var toDelete = + (await productReservationService.GetProductReservationsByProductId(productId, true, null)).Where(x => + x.Date < DateTime.UtcNow); + foreach (var record in toDelete) await productReservationService.DeleteProductReservation(record); + + return Json(""); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductReservationDelete(ProductModel.ReservationModel model) + { + var product = await productService.GetProductById(model.ProductId); + if (product == null) + throw new ArgumentException("No product found with the specified id"); + + if (!await scope.HasAccess(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var toDelete = await productReservationService.GetProductReservation(model.ReservationId); + + // Cross-product IDOR closed here: none of the three original hosts verified that the reservation + // being deleted (looked up purely by model.ReservationId) actually belongs to the product just + // access-checked (model.ProductId). Both are independent, attacker-supplied POST fields - Store + // and Vendor's original code let a caller who owns/has-access-to *any* product pass the access + // check with that product's id while supplying a ReservationId belonging to a different, unowned + // product, deleting a reservation on a product they have no access to. + if (toDelete != null && toDelete.ProductId != product.Id) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + if (toDelete != null) + { + if (string.IsNullOrEmpty(toDelete.OrderId)) + await productReservationService.DeleteProductReservation(toDelete); + else + return Json(new DataSourceResult { + Errors = translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.ProductReservations.CantDeleteWithOrder") + }); + } + + return Json(""); + } + + #endregion +} diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseProductSpecificationController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductSpecificationController.cs new file mode 100644 index 000000000..fb2dc160e --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseProductSpecificationController.cs @@ -0,0 +1,173 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Domain.Permissions; +using Grand.SharedKernel.Extensions; +using Grand.Web.AdminShared.Extensions.Mapping; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.AdminShared.Models.Orders; +using Grand.Web.Common.Controllers; +using Grand.Web.Common.DataSource; +using Grand.Web.Common.Localization; +using Grand.Web.Common.Security.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Rendering; + +namespace Grand.Web.AdminShared.Controllers; + +[PermissionAuthorize(PermissionSystemName.Products)] +[AutoValidateAntiforgeryToken] +public abstract class BaseProductSpecificationController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseController +{ + #region Product specification attributes + + //ajax + [AcceptVerbs("GET")] + public async Task GetOptionsByAttributeId(string attributeId, + [FromServices] ISpecificationAttributeService specificationAttributeService) + { + if (string.IsNullOrEmpty(attributeId)) + return Json(""); + + var options = + (await specificationAttributeService.GetSpecificationAttributeById(attributeId)) + .SpecificationAttributeOptions.OrderBy(x => x.DisplayOrder); + var result = (from o in options + select new { id = o.Id, name = o.Name }).ToList(); + return Json(result); + } + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + [HttpPost] + public async Task ProductSpecAttrList(DataSourceRequest command, string productId) + { + var product = await productService.GetProductById(productId); + + // CanView on this read: a product shared with other stores opens read-only on Store, so its + // tabs must load; the mutating actions below keep the strict HasAccess. History: + // HasAccess (strict): mirrors Store's CanAccessProduct and Vendor's CheckAccessToProduct gating + // this action on both hosts. Admin's original ProductSpecAttrList had no check at all - applying + // HasAccess uniformly also closes that gap without changing Admin's superuser behaviour (HasAccess + // is a no-op for Admin's scope). Vendor's original signature also dropped the DataSourceRequest + // command parameter entirely (unused by the body on any host either way) - kept here for parity + // with Admin/Store. + if (!await scope.CanView(product)) + return ErrorForKendoGridJson(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var productrSpecsModel = await productViewModelService.PrepareProductSpecificationAttributeModel(product); + var gridModel = new DataSourceResult { + Data = productrSpecsModel, + Total = productrSpecsModel.Count + }; + return Json(gridModel); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task ProductSpecAttrPopup( + [FromServices] ISpecificationAttributeService specificationAttributeService, + string productId, string id) + { + var product = await productService.GetProductById(productId); + + // HasAccess (strict): mirrors Store's CanAccessProduct and Vendor's CheckAccessToProduct check on + // this action. Admin's original ProductSpecAttrPopup(GET) had no check at all - closed the same + // way as ProductSpecAttrList above. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var model = new ProductModel.AddProductSpecificationAttributeModel { + //default specs values + ShowOnProductPage = true + }; + + if (!string.IsNullOrEmpty(id)) + { + var specification = product.ProductSpecificationAttributes.FirstOrDefault(x => x.Id == id); + if (specification != null) model = specification.ToModel(); + } + + model.AvailableAttributes = await PrepareAvailableAttributes(specificationAttributeService); + + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductSpecAttrPopup( + [FromServices] ISpecificationAttributeService specificationAttributeService, + ProductModel.AddProductSpecificationAttributeModel model) + { + if (ModelState.IsValid) + { + var product = await productService.GetProductById(model.ProductId); + if (product == null) + return Content("Product not exists"); + + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var psa = product.ProductSpecificationAttributes.FirstOrDefault(x => x.Id == model.Id); + if (psa == null) + await productViewModelService.InsertProductSpecificationAttributeModel(model, product); + else + await productViewModelService.UpdateProductSpecificationAttributeModel(psa, model); + + return new JsonResult(""); + } + + Error(ModelState); + model.AvailableAttributes = await PrepareAvailableAttributes(specificationAttributeService); + + return View(model); + } + + /// scope.DefaultStoreId ?? "" matches Store's PrepareAvailableAttributes(StaffStoreId) call + /// and Admin/Vendor's parameterless call (both pass an empty storeId, seeing every specification + /// attribute regardless of store). + private async Task> PrepareAvailableAttributes( + ISpecificationAttributeService specificationAttributeService) + { + var availableSpecificationAttributes = new List(); + foreach (var sa in await specificationAttributeService.GetSpecificationAttributes(scope.DefaultStoreId ?? "")) + availableSpecificationAttributes.Add(new SelectListItem { + Text = sa.Name, + Value = sa.Id + }); + return availableSpecificationAttributes; + } + + [PermissionAuthorizeAction(PermissionActionName.Edit)] + [HttpPost] + public async Task ProductSpecAttrDelete(ProductSpecificationAttributeModel model) + { + if (ModelState.IsValid) + { + var product = await productService.GetProductById(model.ProductId); + if (product == null) + return Content("Product not exists"); + + // HasAccess (strict): mirrors Store's CanAccessProduct check on this action. Vendor's original + // ProductSpecAttrDelete had no check at all, letting any vendor delete another vendor's + // specification attribute mapping by posting its productId/model.Id - closed here the same + // way as ProductSpecAttrPopup(POST) above. + if (!await scope.HasAccess(product)) + return Content(translationService.GetResource($"{scope.ResourceKeyPrefix}.Catalog.Products.Permissions")); + + var psa = product.ProductSpecificationAttributes.FirstOrDefault(x => x.Id == model.Id); + if (psa == null) + throw new ArgumentException("No specification attribute found with the specified id"); + + await productViewModelService.DeleteProductSpecificationAttribute(product, psa); + return new JsonResult(""); + } + + return ErrorForKendoGridJson(ModelState); + } + + #endregion +} diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.AssociatedProducts.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.AssociatedProducts.cshtml index f4a4c8341..ba711d92d 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.AssociatedProducts.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.AssociatedProducts.cshtml @@ -14,7 +14,7 @@

@if (!readOnly) { - @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.AssociatedProducts.AddNew"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.AssociatedProducts.AddNew"] } @@ -22,9 +22,9 @@
diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Bids.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Bids.cshtml index 4af366861..04687ae07 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Bids.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Bids.cshtml @@ -13,8 +13,8 @@
diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.BundleProducts.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.BundleProducts.cshtml index d05155747..79b12fa5e 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.BundleProducts.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.BundleProducts.cshtml @@ -12,7 +12,7 @@

@if (!readOnly) { - @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.BundleProducts.AddNew"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.BundleProducts.AddNew"] }
@@ -20,9 +20,9 @@
diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Calendar.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Calendar.cshtml index 2666b46b2..b2cbccbc4 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Calendar.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Calendar.cshtml @@ -72,8 +72,8 @@ } @@ -174,7 +174,7 @@ $.ajax({ cache: false, type: "POST", - url: "@(Url.Action("GenerateCalendar", "Product", new { area = area }))", + url: "@(Url.Action("GenerateCalendar", "ProductReservation", new { area = area }))", dataType: 'json', data: postData, success: function (data) { @@ -207,7 +207,7 @@ $.ajax({ cache: false, type: "POST", - url: "@(Url.Action("ClearCalendar", "Product", new { area = area }))", + url: "@(Url.Action("ClearCalendar", "ProductReservation", new { area = area }))", dataType: 'json', data: postData, success: function (data) { @@ -231,7 +231,7 @@ $.ajax({ cache: false, type: "POST", - url: "@(Url.Action("ClearOld", "Product", new { area = area }))", + url: "@(Url.Action("ClearOld", "ProductReservation", new { area = area }))", dataType: 'json', data: postData, success: function (data) { diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Categories.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Categories.cshtml index c6fab4274..f20e16941 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Categories.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Categories.cshtml @@ -13,10 +13,10 @@
diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Pictures.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Pictures.cshtml index d6845da12..3e5271e55 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Pictures.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.Pictures.cshtml @@ -11,8 +11,8 @@
{{ PictureId }} + {{ PictureId }} } @@ -69,7 +69,7 @@ @{ ViewData["Reference"] = "Product"; ViewData["ObjectId"] = Model.Id; - ViewData["Endpoint"] = Url.Action("ProductPictureAdd", "Product", new { area = area }); + ViewData["Endpoint"] = Url.Action("ProductPictureAdd", "ProductPicture", new { area = area }); ViewData["Click"] = "btnRefreshProductPictures"; } diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.ProductAttributes.TabAttributeCombinations.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.ProductAttributes.TabAttributeCombinations.cshtml index 3fc030042..13b0745fe 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.ProductAttributes.TabAttributeCombinations.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.ProductAttributes.TabAttributeCombinations.cshtml @@ -17,7 +17,7 @@

@if (!readOnly) { - @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.ProductAttributes.AttributeCombinations.AddNew"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.ProductAttributes.AttributeCombinations.AddNew"] @@ -27,8 +27,8 @@
{{{ Attributes }}}
+ {{{ Attributes }}}
{{ Warnings }}
} @@ -123,7 +123,7 @@ $.ajax({ cache: false, type: 'POST', - url: '@Url.Action("GenerateAllAttributeCombinations", "Product", new { productId = Model.Id, area = area })', + url: '@Url.Action("GenerateAllAttributeCombinations", "ProductAttributeCombination", new { productId = Model.Id, area = area })', data: postData, dataType: 'json', success: function(data) { @@ -151,7 +151,7 @@ $.ajax({ cache: false, type: 'POST', - url: '@Url.Action("ClearAllAttributeCombinations", "Product", new { productId = Model.Id, area = area })', + url: '@Url.Action("ClearAllAttributeCombinations", "ProductAttributeCombination", new { productId = Model.Id, area = area })', data: postData, dataType: 'json', success: function(data) { diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.ProductAttributes.TabAttributes.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.ProductAttributes.TabAttributes.cshtml index 87bc52694..e733eee22 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.ProductAttributes.TabAttributes.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.ProductAttributes.TabAttributes.cshtml @@ -9,15 +9,15 @@

@if (!readOnly) { - @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.ProductAttributes.AddNew"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.ProductAttributes.AddNew"] }
{{ ProductAttribute }}
+ {{ ProductAttribute }}
} @@ -54,7 +54,7 @@ } else { - {{ $texts.validationRulesLink }}
{{{ ValidationRulesString }}}
+ {{ $texts.validationRulesLink }}
{{{ ValidationRulesString }}}
} @@ -62,13 +62,13 @@ { - {{ $texts.edit }}
+ {{ $texts.edit }}
} @*attribute values: drop-down list, radio list, checkboxes, color and image squares; text controls have none*@ - {{ Name }} + {{ Name }} } @@ -133,7 +133,7 @@ $(e.detailElement).attr('data-id', mappingId); var button = $('') .attr('data-id', mappingId) - .attr('href', '@Url.Action("ProductAttributeValueCreatePopup", "Product", new { area = area })/?productAttributeMappingId=' + encodeURIComponent(mappingId) + '&productId=@Model.Id&formId=product-form&btnId=btnRefreshProductAttributesValues') + .attr('href', '@Url.Action("ProductAttributeValueCreatePopup", "ProductAttributeMapping", new { area = area })/?productAttributeMappingId=' + encodeURIComponent(mappingId) + '&productId=@Model.Id&formId=product-form&btnId=btnRefreshProductAttributesValues') .html('@Loc[$"{Scope.ResourceKeyPrefix}.Common.AddNew"]'); $('
').append(button).prependTo(e.detailElement); } diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.ProductPrices.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.ProductPrices.cshtml index e1aea93c8..331ec9a4f 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.ProductPrices.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.ProductPrices.cshtml @@ -17,10 +17,10 @@ @*loaded by tabstrip_on_tab_show when the Prices tab opens*@ @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Recommended"] @if (!readOnly) { - @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Recommended.AddNew"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Recommended.AddNew"] }
diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.RelatedProducts.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.RelatedProducts.cshtml index c8c1cea30..a5bdfc9d3 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.RelatedProducts.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.RelatedProducts.cshtml @@ -12,16 +12,16 @@

@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.RelatedProducts"]

@if (!readOnly) { - @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.RelatedProducts.AddNew"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.RelatedProducts.AddNew"] }
diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.SimilarProducts.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.SimilarProducts.cshtml index 815c6081f..1dc3be56b 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.SimilarProducts.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.SimilarProducts.cshtml @@ -13,16 +13,16 @@

@Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.SimilarProducts"]

@if (!readOnly) { - @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.SimilarProducts.AddNew"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.SimilarProducts.AddNew"] }
diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.SpecificationAttributes.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.SpecificationAttributes.cshtml index 31f64fbd4..9d59ec78d 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.SpecificationAttributes.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdate.SpecificationAttributes.cshtml @@ -12,15 +12,15 @@

@if (!readOnly) { - @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.SpecificationAttributes.AddButton"] + @Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.SpecificationAttributes.AddButton"] }
@@ -32,7 +32,7 @@ } else { - {{ AttributeTypeName }} + {{ AttributeTypeName }} } @@ -45,7 +45,7 @@ } else { - {{ AttributeName }} : {{{ ValueRaw }}} + {{ AttributeName }} : {{{ ValueRaw }}} } diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/AssociatedProductAddPopup.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductAssociated/AssociatedProductAddPopup.cshtml similarity index 97% rename from src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/AssociatedProductAddPopup.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductAssociated/AssociatedProductAddPopup.cshtml index 7bcf81ee8..936331b65 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/AssociatedProductAddPopup.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductAssociated/AssociatedProductAddPopup.cshtml @@ -6,7 +6,7 @@ ViewBag.Title = Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.AssociatedProducts.AddNew"]; } - @@ -29,7 +29,7 @@ diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/ProductAttributes.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductAttributeCombination/Partials/ProductAttributes.cshtml similarity index 100% rename from src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/ProductAttributes.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductAttributeCombination/Partials/ProductAttributes.cshtml diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/AssociateProductToAttributeValuePopup.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductAttributeMapping/AssociateProductToAttributeValuePopup.cshtml similarity index 96% rename from src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/AssociateProductToAttributeValuePopup.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductAttributeMapping/AssociateProductToAttributeValuePopup.cshtml index 66264dbc1..e58b0b5cb 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/AssociateProductToAttributeValuePopup.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductAttributeMapping/AssociateProductToAttributeValuePopup.cshtml @@ -6,7 +6,7 @@ ViewBag.Title = Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.ProductAttributes.Attributes.Values.Fields.AssociatedProduct.AddNew"]; } - @@ -26,7 +26,7 @@ diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdateProductAttributeValue.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductAttributeMapping/Partials/CreateOrUpdateProductAttributeValue.cshtml similarity index 96% rename from src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdateProductAttributeValue.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductAttributeMapping/Partials/CreateOrUpdateProductAttributeValue.cshtml index 1f7d95753..ffb2e7ed8 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/CreateOrUpdateProductAttributeValue.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductAttributeMapping/Partials/CreateOrUpdateProductAttributeValue.cshtml @@ -67,7 +67,7 @@ diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.AttributeValueButtons.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductAttributeMapping/Partials/WidgetZone.AttributeValueButtons.cshtml similarity index 100% rename from src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/Partials/WidgetZone.AttributeValueButtons.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductAttributeMapping/Partials/WidgetZone.AttributeValueButtons.cshtml diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/ProductAttributeConditionPopup.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductAttributeMapping/ProductAttributeConditionPopup.cshtml similarity index 98% rename from src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/ProductAttributeConditionPopup.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductAttributeMapping/ProductAttributeConditionPopup.cshtml index 34575eab0..0409adcc7 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/ProductAttributeConditionPopup.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductAttributeMapping/ProductAttributeConditionPopup.cshtml @@ -6,7 +6,7 @@ Layout = ""; ViewBag.Title = Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.ProductAttributes.Attributes.Condition"]; } - diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/ProductAttributeValueCreatePopup.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductAttributeMapping/ProductAttributeValueCreatePopup.cshtml similarity index 95% rename from src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/ProductAttributeValueCreatePopup.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductAttributeMapping/ProductAttributeValueCreatePopup.cshtml index e0fc35253..1edf40e83 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/ProductAttributeValueCreatePopup.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductAttributeMapping/ProductAttributeValueCreatePopup.cshtml @@ -7,7 +7,7 @@ //page title ViewBag.Title = Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.ProductAttributes.Attributes.Values.AddNew"]; } - @@ -27,7 +27,7 @@ diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/ProductPicturePopup.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductPicture/ProductPicturePopup.cshtml similarity index 97% rename from src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/ProductPicturePopup.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductPicture/ProductPicturePopup.cshtml index ea7bbdac2..a5ae6d80e 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/ProductPicturePopup.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductPicture/ProductPicturePopup.cshtml @@ -4,7 +4,7 @@ Layout = ""; var area = ViewContext.RouteData.Values["area"]?.ToString(); } - diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/TierPriceEditPopup.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductPrice/TierPriceEditPopup.cshtml similarity index 97% rename from src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/TierPriceEditPopup.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductPrice/TierPriceEditPopup.cshtml index 1384b09f3..10c113839 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/TierPriceEditPopup.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductPrice/TierPriceEditPopup.cshtml @@ -8,7 +8,7 @@ ViewBag.Title = Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.TierPrices.Edit"]; } - diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/CrossSellProductAddPopup.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductRelated/CrossSellProductAddPopup.cshtml similarity index 97% rename from src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/CrossSellProductAddPopup.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductRelated/CrossSellProductAddPopup.cshtml index b4aa4c275..ae1b8096d 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/CrossSellProductAddPopup.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductRelated/CrossSellProductAddPopup.cshtml @@ -5,7 +5,7 @@ //page title ViewBag.Title = Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.CrossSells.AddNew"]; } - @@ -28,7 +28,7 @@ diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/RecommendedProductAddPopup.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductRelated/RecommendedProductAddPopup.cshtml similarity index 97% rename from src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/RecommendedProductAddPopup.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductRelated/RecommendedProductAddPopup.cshtml index 42e13b008..018b9b8e9 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/RecommendedProductAddPopup.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductRelated/RecommendedProductAddPopup.cshtml @@ -5,7 +5,7 @@ //page title ViewBag.Title = Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.Recommended.AddNew"]; } - @@ -26,7 +26,7 @@ diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/RelatedProductAddPopup.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductRelated/RelatedProductAddPopup.cshtml similarity index 97% rename from src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/RelatedProductAddPopup.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductRelated/RelatedProductAddPopup.cshtml index ad032dbce..a02ddb70b 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/RelatedProductAddPopup.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductRelated/RelatedProductAddPopup.cshtml @@ -5,7 +5,7 @@ //page title ViewBag.Title = Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.RelatedProducts.AddNew"]; } - @@ -28,7 +28,7 @@ diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/SimilarProductAddPopup.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductRelated/SimilarProductAddPopup.cshtml similarity index 97% rename from src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/SimilarProductAddPopup.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductRelated/SimilarProductAddPopup.cshtml index cf64762e3..e31f0d029 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/SimilarProductAddPopup.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductRelated/SimilarProductAddPopup.cshtml @@ -5,7 +5,7 @@ //page title ViewBag.Title = Loc[$"{Scope.ResourceKeyPrefix}.Catalog.Products.SimilarProducts.AddNew"]; } - @@ -26,7 +26,7 @@ diff --git a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/ProductSpecAttrPopup.cshtml b/src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductSpecification/ProductSpecAttrPopup.cshtml similarity index 97% rename from src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/ProductSpecAttrPopup.cshtml rename to src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductSpecification/ProductSpecAttrPopup.cshtml index cf417ce9d..b8194cc31 100644 --- a/src/Web/Grand.Web.AdminShared/Views/AdminShared/Product/ProductSpecAttrPopup.cshtml +++ b/src/Web/Grand.Web.AdminShared/Views/AdminShared/ProductSpecification/ProductSpecAttrPopup.cshtml @@ -4,7 +4,7 @@ var area = ViewContext.RouteData.Values["area"]?.ToString(); } @@ -68,7 +68,7 @@ $.ajax({ cache: false, type: "GET", - url: "@(Url.Action("GetOptionsByAttributeId", "Product", new { area = area }))", + url: "@(Url.Action("GetOptionsByAttributeId", "ProductSpecification", new { area = area }))", data: { "attributeId": selectedAttributeId }, success: function (data) { var ddlSpecOptions = $("#@Html.IdFor(model => model.SpecificationAttributeOptionId)"); diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Prices.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Prices.cshtml index 8695488a4..3f0f532a8 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Prices.cshtml +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/CreateOrUpdate.Prices.cshtml @@ -71,7 +71,7 @@ @if (!readOnly) { - @Loc["Admin.Catalog.Products.TierPrices.AddNew"] + @Loc["Admin.Catalog.Products.TierPrices.AddNew"] } @@ -79,8 +79,8 @@ fields TierPriceDeleteModel binds *@ @@ -104,7 +104,7 @@ { -  {{ $texts.edit }} +  {{ $texts.edit }} } diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/AttributeCombinationPopup.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/ProductAttributeCombination/AttributeCombinationPopup.cshtml similarity index 90% rename from src/Web/Grand.Web.Store/Areas/Store/Views/Product/AttributeCombinationPopup.cshtml rename to src/Web/Grand.Web.Store/Areas/Store/Views/ProductAttributeCombination/AttributeCombinationPopup.cshtml index 541f5d8c7..1b454ab35 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/AttributeCombinationPopup.cshtml +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/ProductAttributeCombination/AttributeCombinationPopup.cshtml @@ -7,7 +7,7 @@ Layout = ""; ViewBag.Title = string.IsNullOrEmpty(Model.Id) ? Loc["Admin.Catalog.Products.ProductAttributes.AttributeCombinations.AddTitle"] : Loc["Admin.Catalog.Products.ProductAttributes.AttributeCombinations.Update"]; } - new SelectListItem(group.Name ?? "", group.Id))); } diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.AttributeValueButtons.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/ProductAttributeMapping/Partials/WidgetZone.AttributeValueButtons.cshtml similarity index 100% rename from src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.AttributeValueButtons.cshtml rename to src/Web/Grand.Web.Store/Areas/Store/Views/ProductAttributeMapping/Partials/WidgetZone.AttributeValueButtons.cshtml diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.TierPrice.Bottom.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/ProductPrice/Partials/WidgetZone.TierPrice.Bottom.cshtml similarity index 100% rename from src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.TierPrice.Bottom.cshtml rename to src/Web/Grand.Web.Store/Areas/Store/Views/ProductPrice/Partials/WidgetZone.TierPrice.Bottom.cshtml diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.TierPrice.Buttons.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/ProductPrice/Partials/WidgetZone.TierPrice.Buttons.cshtml similarity index 100% rename from src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.TierPrice.Buttons.cshtml rename to src/Web/Grand.Web.Store/Areas/Store/Views/ProductPrice/Partials/WidgetZone.TierPrice.Buttons.cshtml diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.TierPrice.Top.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/ProductPrice/Partials/WidgetZone.TierPrice.Top.cshtml similarity index 100% rename from src/Web/Grand.Web.Store/Areas/Store/Views/Product/Partials/WidgetZone.TierPrice.Top.cshtml rename to src/Web/Grand.Web.Store/Areas/Store/Views/ProductPrice/Partials/WidgetZone.TierPrice.Top.cshtml diff --git a/src/Web/Grand.Web.Store/Controllers/ProductAssociatedController.cs b/src/Web/Grand.Web.Store/Controllers/ProductAssociatedController.cs new file mode 100644 index 000000000..94d04e926 --- /dev/null +++ b/src/Web/Grand.Web.Store/Controllers/ProductAssociatedController.cs @@ -0,0 +1,21 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Grand.Web.Store.Extensions; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Store.Controllers; + +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaStore)] +[AuthorizeStore] +[AuthorizeMenu] +public class ProductAssociatedController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseProductAssociatedController(productViewModelService, productService, translationService, scope); diff --git a/src/Web/Grand.Web.Store/Controllers/ProductAttributeCombinationController.cs b/src/Web/Grand.Web.Store/Controllers/ProductAttributeCombinationController.cs new file mode 100644 index 000000000..a2d89bd69 --- /dev/null +++ b/src/Web/Grand.Web.Store/Controllers/ProductAttributeCombinationController.cs @@ -0,0 +1,22 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Grand.Web.Store.Extensions; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Store.Controllers; + +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaStore)] +[AuthorizeStore] +[AuthorizeMenu] +public class ProductAttributeCombinationController( + IProductViewModelService productViewModelService, + IProductService productService, + IInventoryManageService inventoryManageService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseProductAttributeCombinationController(productViewModelService, productService, inventoryManageService, translationService, scope); diff --git a/src/Web/Grand.Web.Store/Controllers/ProductAttributeMappingController.cs b/src/Web/Grand.Web.Store/Controllers/ProductAttributeMappingController.cs new file mode 100644 index 000000000..7d0ff3863 --- /dev/null +++ b/src/Web/Grand.Web.Store/Controllers/ProductAttributeMappingController.cs @@ -0,0 +1,23 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Grand.Web.Store.Extensions; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Store.Controllers; + +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaStore)] +[AuthorizeStore] +[AuthorizeMenu] +public class ProductAttributeMappingController( + IProductViewModelService productViewModelService, + IProductService productService, + ILanguageService languageService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseProductAttributeMappingController(productViewModelService, productService, languageService, translationService, scope); diff --git a/src/Web/Grand.Web.Store/Controllers/ProductBidController.cs b/src/Web/Grand.Web.Store/Controllers/ProductBidController.cs new file mode 100644 index 000000000..24d92f9bd --- /dev/null +++ b/src/Web/Grand.Web.Store/Controllers/ProductBidController.cs @@ -0,0 +1,22 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Grand.Web.Store.Extensions; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Store.Controllers; + +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaStore)] +[AuthorizeStore] +[AuthorizeMenu] +public class ProductBidController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IAuctionService auctionService, + IAdminDataScope scope) + : BaseProductBidController(productViewModelService, productService, translationService, auctionService, scope); diff --git a/src/Web/Grand.Web.Store/Controllers/ProductBundleController.cs b/src/Web/Grand.Web.Store/Controllers/ProductBundleController.cs new file mode 100644 index 000000000..a2b37a6cb --- /dev/null +++ b/src/Web/Grand.Web.Store/Controllers/ProductBundleController.cs @@ -0,0 +1,21 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Grand.Web.Store.Extensions; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Store.Controllers; + +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaStore)] +[AuthorizeStore] +[AuthorizeMenu] +public class ProductBundleController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseProductBundleController(productViewModelService, productService, translationService, scope); diff --git a/src/Web/Grand.Web.Store/Controllers/ProductCategoryController.cs b/src/Web/Grand.Web.Store/Controllers/ProductCategoryController.cs new file mode 100644 index 000000000..61b1b41f2 --- /dev/null +++ b/src/Web/Grand.Web.Store/Controllers/ProductCategoryController.cs @@ -0,0 +1,21 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Grand.Web.Store.Extensions; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Store.Controllers; + +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaStore)] +[AuthorizeStore] +[AuthorizeMenu] +public class ProductCategoryController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseProductCategoryController(productViewModelService, productService, translationService, scope); diff --git a/src/Web/Grand.Web.Store/Controllers/ProductCollectionController.cs b/src/Web/Grand.Web.Store/Controllers/ProductCollectionController.cs new file mode 100644 index 000000000..b2bdbfb34 --- /dev/null +++ b/src/Web/Grand.Web.Store/Controllers/ProductCollectionController.cs @@ -0,0 +1,21 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Grand.Web.Store.Extensions; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Store.Controllers; + +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaStore)] +[AuthorizeStore] +[AuthorizeMenu] +public class ProductCollectionController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseProductCollectionController(productViewModelService, productService, translationService, scope); diff --git a/src/Web/Grand.Web.Store/Controllers/ProductController.cs b/src/Web/Grand.Web.Store/Controllers/ProductController.cs index 7edf79bab..4ff451b3c 100644 --- a/src/Web/Grand.Web.Store/Controllers/ProductController.cs +++ b/src/Web/Grand.Web.Store/Controllers/ProductController.cs @@ -1,8 +1,6 @@ using Grand.Business.Core.Interfaces.Catalog.Products; using Grand.Business.Core.Interfaces.Common.Directory; using Grand.Business.Core.Interfaces.Common.Localization; -using Grand.Business.Core.Interfaces.ExportImport; -using Grand.Business.Core.Interfaces.Storage; using Grand.Domain.Catalog; using Grand.Web.AdminShared.Controllers; using Grand.Web.AdminShared.Interfaces; @@ -21,18 +19,13 @@ namespace Grand.Web.Store.Controllers; public class ProductController( IProductViewModelService productViewModelService, IProductService productService, - IInventoryManageService inventoryManageService, ILanguageService languageService, IRequestTranslationService translationService, - IProductReservationService productReservationService, - IAuctionService auctionService, IDateTimeService dateTimeService, IRequestPermissionService permissionService, - IEnumTranslationService enumTranslationService, IAdminDataScope scope) - : BaseProductController(productViewModelService, productService, inventoryManageService, languageService, - translationService, productReservationService, auctionService, dateTimeService, permissionService, - enumTranslationService, scope) + : BaseProductController(productViewModelService, productService, languageService, translationService, + dateTimeService, permissionService, scope) { // Re-derived from the original Store ProductController.Edit(GET) (pre-Task-11: // src/Web/Grand.Web.Store/Controllers/ProductController.cs:184-189), not retyped from memory - the diff --git a/src/Web/Grand.Web.Store/Controllers/ProductPictureController.cs b/src/Web/Grand.Web.Store/Controllers/ProductPictureController.cs new file mode 100644 index 000000000..883eef413 --- /dev/null +++ b/src/Web/Grand.Web.Store/Controllers/ProductPictureController.cs @@ -0,0 +1,25 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Grand.Web.Common.Security.Authorization; +using Grand.Web.Store.Extensions; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Store.Controllers; + +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaStore)] +[AuthorizeStore] +[AuthorizeMenu] +public class ProductPictureController( + IProductViewModelService productViewModelService, + IProductService productService, + ILanguageService languageService, + IRequestTranslationService translationService, + IRequestPermissionService permissionService, + IAdminDataScope scope) + : BaseProductPictureController(productViewModelService, productService, languageService, translationService, permissionService, scope); diff --git a/src/Web/Grand.Web.Store/Controllers/ProductPriceController.cs b/src/Web/Grand.Web.Store/Controllers/ProductPriceController.cs new file mode 100644 index 000000000..175241a06 --- /dev/null +++ b/src/Web/Grand.Web.Store/Controllers/ProductPriceController.cs @@ -0,0 +1,23 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Grand.Web.Store.Extensions; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Store.Controllers; + +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaStore)] +[AuthorizeStore] +[AuthorizeMenu] +public class ProductPriceController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IDateTimeService dateTimeService, + IAdminDataScope scope) + : BaseProductPriceController(productViewModelService, productService, translationService, dateTimeService, scope); diff --git a/src/Web/Grand.Web.Store/Controllers/ProductRelatedController.cs b/src/Web/Grand.Web.Store/Controllers/ProductRelatedController.cs new file mode 100644 index 000000000..08f465227 --- /dev/null +++ b/src/Web/Grand.Web.Store/Controllers/ProductRelatedController.cs @@ -0,0 +1,21 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Grand.Web.Store.Extensions; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Store.Controllers; + +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaStore)] +[AuthorizeStore] +[AuthorizeMenu] +public class ProductRelatedController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseProductRelatedController(productViewModelService, productService, translationService, scope); diff --git a/src/Web/Grand.Web.Store/Controllers/ProductReservationController.cs b/src/Web/Grand.Web.Store/Controllers/ProductReservationController.cs new file mode 100644 index 000000000..ffa41f747 --- /dev/null +++ b/src/Web/Grand.Web.Store/Controllers/ProductReservationController.cs @@ -0,0 +1,22 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Grand.Web.Store.Extensions; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Store.Controllers; + +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaStore)] +[AuthorizeStore] +[AuthorizeMenu] +public class ProductReservationController( + IProductService productService, + IRequestTranslationService translationService, + IProductReservationService productReservationService, + IEnumTranslationService enumTranslationService, + IAdminDataScope scope) + : BaseProductReservationController(productService, translationService, productReservationService, enumTranslationService, scope); diff --git a/src/Web/Grand.Web.Store/Controllers/ProductSpecificationController.cs b/src/Web/Grand.Web.Store/Controllers/ProductSpecificationController.cs new file mode 100644 index 000000000..37b1f1f72 --- /dev/null +++ b/src/Web/Grand.Web.Store/Controllers/ProductSpecificationController.cs @@ -0,0 +1,21 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Grand.Web.Store.Extensions; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Store.Controllers; + +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaStore)] +[AuthorizeStore] +[AuthorizeMenu] +public class ProductSpecificationController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseProductSpecificationController(productViewModelService, productService, translationService, scope); diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.Prices.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.Prices.cshtml index 08d34d19b..4f8ef07d9 100644 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.Prices.cshtml +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/CreateOrUpdate.Prices.cshtml @@ -64,12 +64,12 @@ { - @Loc["Vendor.Catalog.Products.TierPrices.AddNew"] + @Loc["Vendor.Catalog.Products.TierPrices.AddNew"] @@ -82,7 +82,7 @@ -  {{ $texts.edit }} +  {{ $texts.edit }} diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/AttributeCombinationPopup.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/ProductAttributeCombination/AttributeCombinationPopup.cshtml similarity index 89% rename from src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/AttributeCombinationPopup.cshtml rename to src/Web/Grand.Web.Vendor/Areas/Vendor/Views/ProductAttributeCombination/AttributeCombinationPopup.cshtml index 814f949ed..58fb67dd1 100644 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/AttributeCombinationPopup.cshtml +++ b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/ProductAttributeCombination/AttributeCombinationPopup.cshtml @@ -3,7 +3,7 @@ Layout = ""; ViewBag.Title = string.IsNullOrEmpty(Model.Id) ? Loc["Vendor.Catalog.Products.ProductAttributes.AttributeCombinations.AddTitle"] : Loc["Vendor.Catalog.Products.ProductAttributes.AttributeCombinations.Update"]; } - diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.AttributeValueButtons.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/ProductAttributeMapping/Partials/WidgetZone.AttributeValueButtons.cshtml similarity index 100% rename from src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.AttributeValueButtons.cshtml rename to src/Web/Grand.Web.Vendor/Areas/Vendor/Views/ProductAttributeMapping/Partials/WidgetZone.AttributeValueButtons.cshtml diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/TierPrice.StoreScope.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/ProductPrice/Partials/TierPrice.StoreScope.cshtml similarity index 100% rename from src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/TierPrice.StoreScope.cshtml rename to src/Web/Grand.Web.Vendor/Areas/Vendor/Views/ProductPrice/Partials/TierPrice.StoreScope.cshtml diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.TierPrice.Bottom.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/ProductPrice/Partials/WidgetZone.TierPrice.Bottom.cshtml similarity index 100% rename from src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.TierPrice.Bottom.cshtml rename to src/Web/Grand.Web.Vendor/Areas/Vendor/Views/ProductPrice/Partials/WidgetZone.TierPrice.Bottom.cshtml diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.TierPrice.Buttons.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/ProductPrice/Partials/WidgetZone.TierPrice.Buttons.cshtml similarity index 100% rename from src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.TierPrice.Buttons.cshtml rename to src/Web/Grand.Web.Vendor/Areas/Vendor/Views/ProductPrice/Partials/WidgetZone.TierPrice.Buttons.cshtml diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.TierPrice.Top.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/ProductPrice/Partials/WidgetZone.TierPrice.Top.cshtml similarity index 100% rename from src/Web/Grand.Web.Vendor/Areas/Vendor/Views/Product/Partials/WidgetZone.TierPrice.Top.cshtml rename to src/Web/Grand.Web.Vendor/Areas/Vendor/Views/ProductPrice/Partials/WidgetZone.TierPrice.Top.cshtml diff --git a/src/Web/Grand.Web.Vendor/Controllers/ProductAssociatedController.cs b/src/Web/Grand.Web.Vendor/Controllers/ProductAssociatedController.cs new file mode 100644 index 000000000..fae06caa5 --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Controllers/ProductAssociatedController.cs @@ -0,0 +1,29 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Infrastructure; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Grand.Web.Vendor.Extensions; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Vendor.Controllers; + +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaVendor)] +[AuthorizeVendor] +[AuthorizeMenu] +public class ProductAssociatedController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IAdminDataScope scope, + IContextAccessor contextAccessor) + : BaseProductAssociatedController(productViewModelService, productService, translationService, scope) +{ + // Vendor's original passed CurrentVendor.Id into GetAssociatedProducts(vendorId:) so a vendor only + // sees the subset of a grouped product's associated products that they themselves own. Overriding + // the base's empty default, per BaseProductAssociatedController.AssociatedProductVendorId's own doc comment. + protected override string AssociatedProductVendorId => contextAccessor.WorkContext.CurrentVendor.Id; +} diff --git a/src/Web/Grand.Web.Vendor/Controllers/ProductAttributeCombinationController.cs b/src/Web/Grand.Web.Vendor/Controllers/ProductAttributeCombinationController.cs new file mode 100644 index 000000000..4c2d5aaba --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Controllers/ProductAttributeCombinationController.cs @@ -0,0 +1,22 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Grand.Web.Vendor.Extensions; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Vendor.Controllers; + +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaVendor)] +[AuthorizeVendor] +[AuthorizeMenu] +public class ProductAttributeCombinationController( + IProductViewModelService productViewModelService, + IProductService productService, + IInventoryManageService inventoryManageService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseProductAttributeCombinationController(productViewModelService, productService, inventoryManageService, translationService, scope); diff --git a/src/Web/Grand.Web.Vendor/Controllers/ProductAttributeMappingController.cs b/src/Web/Grand.Web.Vendor/Controllers/ProductAttributeMappingController.cs new file mode 100644 index 000000000..65ee93019 --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Controllers/ProductAttributeMappingController.cs @@ -0,0 +1,23 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Grand.Web.Vendor.Extensions; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Vendor.Controllers; + +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaVendor)] +[AuthorizeVendor] +[AuthorizeMenu] +public class ProductAttributeMappingController( + IProductViewModelService productViewModelService, + IProductService productService, + ILanguageService languageService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseProductAttributeMappingController(productViewModelService, productService, languageService, translationService, scope); diff --git a/src/Web/Grand.Web.Vendor/Controllers/ProductBidController.cs b/src/Web/Grand.Web.Vendor/Controllers/ProductBidController.cs new file mode 100644 index 000000000..4af7ea3fa --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Controllers/ProductBidController.cs @@ -0,0 +1,22 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Grand.Web.Vendor.Extensions; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Vendor.Controllers; + +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaVendor)] +[AuthorizeVendor] +[AuthorizeMenu] +public class ProductBidController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IAuctionService auctionService, + IAdminDataScope scope) + : BaseProductBidController(productViewModelService, productService, translationService, auctionService, scope); diff --git a/src/Web/Grand.Web.Vendor/Controllers/ProductBundleController.cs b/src/Web/Grand.Web.Vendor/Controllers/ProductBundleController.cs new file mode 100644 index 000000000..7930fa945 --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Controllers/ProductBundleController.cs @@ -0,0 +1,29 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Grand.Web.Vendor.Extensions; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Vendor.Controllers; + +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaVendor)] +[AuthorizeVendor] +[AuthorizeMenu] +public class ProductBundleController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseProductBundleController(productViewModelService, productService, translationService, scope) +{ + // Vendor's original AddPopup(POST) actions returned Content(ModelState.GetErrors()) on an invalid + // model, instead of Admin/Store's re-prepare-and-View. Overriding the base's Admin/Store default, + // per each hook's own doc comment. + protected override Task InvalidBundleProductAddPopupResult(ProductModel.AddBundleProductModel model) + => Task.FromResult(Content(ModelState.GetErrors())); +} diff --git a/src/Web/Grand.Web.Vendor/Controllers/ProductCategoryController.cs b/src/Web/Grand.Web.Vendor/Controllers/ProductCategoryController.cs new file mode 100644 index 000000000..9e3c44605 --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Controllers/ProductCategoryController.cs @@ -0,0 +1,21 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Grand.Web.Vendor.Extensions; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Vendor.Controllers; + +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaVendor)] +[AuthorizeVendor] +[AuthorizeMenu] +public class ProductCategoryController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseProductCategoryController(productViewModelService, productService, translationService, scope); diff --git a/src/Web/Grand.Web.Vendor/Controllers/ProductCollectionController.cs b/src/Web/Grand.Web.Vendor/Controllers/ProductCollectionController.cs new file mode 100644 index 000000000..be6571f24 --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Controllers/ProductCollectionController.cs @@ -0,0 +1,21 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Grand.Web.Vendor.Extensions; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Vendor.Controllers; + +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaVendor)] +[AuthorizeVendor] +[AuthorizeMenu] +public class ProductCollectionController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseProductCollectionController(productViewModelService, productService, translationService, scope); diff --git a/src/Web/Grand.Web.Vendor/Controllers/ProductController.cs b/src/Web/Grand.Web.Vendor/Controllers/ProductController.cs index 62dc8ae26..b790514ca 100644 --- a/src/Web/Grand.Web.Vendor/Controllers/ProductController.cs +++ b/src/Web/Grand.Web.Vendor/Controllers/ProductController.cs @@ -2,10 +2,8 @@ using Grand.Business.Core.Interfaces.Common.Directory; using Grand.Business.Core.Interfaces.Common.Localization; using Grand.Domain.Catalog; -using Grand.Infrastructure; using Grand.Web.AdminShared.Controllers; using Grand.Web.AdminShared.Interfaces; -using Grand.Web.AdminShared.Models.Catalog; using Grand.Web.Common.Filters; using Grand.Web.Common.Localization; using Grand.Web.Common.Security.Authorization; @@ -21,40 +19,10 @@ namespace Grand.Web.Vendor.Controllers; public class ProductController( IProductViewModelService productViewModelService, IProductService productService, - IInventoryManageService inventoryManageService, ILanguageService languageService, IRequestTranslationService translationService, - IProductReservationService productReservationService, - IAuctionService auctionService, IDateTimeService dateTimeService, IRequestPermissionService permissionService, - IEnumTranslationService enumTranslationService, - IAdminDataScope scope, - IContextAccessor contextAccessor) - : BaseProductController(productViewModelService, productService, inventoryManageService, languageService, - translationService, productReservationService, auctionService, dateTimeService, permissionService, - enumTranslationService, scope) -{ - // Vendor's original passed CurrentVendor.Id into GetAssociatedProducts(vendorId:) so a vendor only - // sees the subset of a grouped product's associated products that they themselves own. Overriding - // the base's empty default, per BaseProductController.AssociatedProductVendorId's own doc comment. - protected override string AssociatedProductVendorId => contextAccessor.WorkContext.CurrentVendor.Id; - - // Vendor's original AddPopup(POST) actions returned Content(ModelState.GetErrors()) on an invalid - // model, instead of Admin/Store's re-prepare-and-View. Overriding the base's Admin/Store default, - // per each hook's own doc comment. - protected override Task InvalidRelatedProductAddPopupResult(ProductModel.AddRelatedProductModel model) - => Task.FromResult(Content(ModelState.GetErrors())); - - protected override Task InvalidSimilarProductAddPopupResult(ProductModel.AddSimilarProductModel model) - => Task.FromResult(Content(ModelState.GetErrors())); - - protected override Task InvalidBundleProductAddPopupResult(ProductModel.AddBundleProductModel model) - => Task.FromResult(Content(ModelState.GetErrors())); - - protected override Task InvalidCrossSellProductAddPopupResult(ProductModel.AddCrossSellProductModel model) - => Task.FromResult(Content(ModelState.GetErrors())); - - protected override Task InvalidRecommendedProductAddPopupResult(ProductModel.AddRecommendedProductModel model) - => Task.FromResult(Content(ModelState.GetErrors())); -} + IAdminDataScope scope) + : BaseProductController(productViewModelService, productService, languageService, translationService, + dateTimeService, permissionService, scope); diff --git a/src/Web/Grand.Web.Vendor/Controllers/ProductPictureController.cs b/src/Web/Grand.Web.Vendor/Controllers/ProductPictureController.cs new file mode 100644 index 000000000..dde7eeca7 --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Controllers/ProductPictureController.cs @@ -0,0 +1,25 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Grand.Web.Common.Security.Authorization; +using Grand.Web.Vendor.Extensions; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Vendor.Controllers; + +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaVendor)] +[AuthorizeVendor] +[AuthorizeMenu] +public class ProductPictureController( + IProductViewModelService productViewModelService, + IProductService productService, + ILanguageService languageService, + IRequestTranslationService translationService, + IRequestPermissionService permissionService, + IAdminDataScope scope) + : BaseProductPictureController(productViewModelService, productService, languageService, translationService, permissionService, scope); diff --git a/src/Web/Grand.Web.Vendor/Controllers/ProductPriceController.cs b/src/Web/Grand.Web.Vendor/Controllers/ProductPriceController.cs new file mode 100644 index 000000000..226c25ba1 --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Controllers/ProductPriceController.cs @@ -0,0 +1,23 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Grand.Web.Vendor.Extensions; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Vendor.Controllers; + +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaVendor)] +[AuthorizeVendor] +[AuthorizeMenu] +public class ProductPriceController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IDateTimeService dateTimeService, + IAdminDataScope scope) + : BaseProductPriceController(productViewModelService, productService, translationService, dateTimeService, scope); diff --git a/src/Web/Grand.Web.Vendor/Controllers/ProductRelatedController.cs b/src/Web/Grand.Web.Vendor/Controllers/ProductRelatedController.cs new file mode 100644 index 000000000..a5e8cd422 --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Controllers/ProductRelatedController.cs @@ -0,0 +1,38 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.AdminShared.Models.Catalog; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Grand.Web.Vendor.Extensions; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Vendor.Controllers; + +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaVendor)] +[AuthorizeVendor] +[AuthorizeMenu] +public class ProductRelatedController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseProductRelatedController(productViewModelService, productService, translationService, scope) +{ + // Vendor's original AddPopup(POST) actions returned Content(ModelState.GetErrors()) on an invalid + // model, instead of Admin/Store's re-prepare-and-View. Overriding the base's Admin/Store default, + // per each hook's own doc comment. + protected override Task InvalidRelatedProductAddPopupResult(ProductModel.AddRelatedProductModel model) + => Task.FromResult(Content(ModelState.GetErrors())); + + protected override Task InvalidSimilarProductAddPopupResult(ProductModel.AddSimilarProductModel model) + => Task.FromResult(Content(ModelState.GetErrors())); + + protected override Task InvalidCrossSellProductAddPopupResult(ProductModel.AddCrossSellProductModel model) + => Task.FromResult(Content(ModelState.GetErrors())); + + protected override Task InvalidRecommendedProductAddPopupResult(ProductModel.AddRecommendedProductModel model) + => Task.FromResult(Content(ModelState.GetErrors())); +} diff --git a/src/Web/Grand.Web.Vendor/Controllers/ProductReservationController.cs b/src/Web/Grand.Web.Vendor/Controllers/ProductReservationController.cs new file mode 100644 index 000000000..509ed4d1f --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Controllers/ProductReservationController.cs @@ -0,0 +1,22 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Grand.Web.Vendor.Extensions; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Vendor.Controllers; + +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaVendor)] +[AuthorizeVendor] +[AuthorizeMenu] +public class ProductReservationController( + IProductService productService, + IRequestTranslationService translationService, + IProductReservationService productReservationService, + IEnumTranslationService enumTranslationService, + IAdminDataScope scope) + : BaseProductReservationController(productService, translationService, productReservationService, enumTranslationService, scope); diff --git a/src/Web/Grand.Web.Vendor/Controllers/ProductSpecificationController.cs b/src/Web/Grand.Web.Vendor/Controllers/ProductSpecificationController.cs new file mode 100644 index 000000000..58aad6a4e --- /dev/null +++ b/src/Web/Grand.Web.Vendor/Controllers/ProductSpecificationController.cs @@ -0,0 +1,21 @@ +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Domain.Catalog; +using Grand.Web.AdminShared.Controllers; +using Grand.Web.AdminShared.Interfaces; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Localization; +using Grand.Web.Vendor.Extensions; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Vendor.Controllers; + +[AutoValidateAntiforgeryToken] +[Area(Constants.AreaVendor)] +[AuthorizeVendor] +[AuthorizeMenu] +public class ProductSpecificationController( + IProductViewModelService productViewModelService, + IProductService productService, + IRequestTranslationService translationService, + IAdminDataScope scope) + : BaseProductSpecificationController(productViewModelService, productService, translationService, scope);