diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseCustomerControllerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseCustomerControllerTests.cs index 69de8356b..cda632b19 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseCustomerControllerTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/BaseCustomerControllerTests.cs @@ -14,6 +14,7 @@ using Grand.Web.Common.DataSource; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Mvc.Routing; using Microsoft.AspNetCore.Mvc.ViewFeatures; using Microsoft.Extensions.Logging; @@ -186,6 +187,81 @@ public async Task CreateGet_GlobalScope_DoesNotCallApplyPostConstraints_ModelUnt Assert.AreEqual("", model.StoreId ?? ""); // never forced for Admin (default no-op hook) } + [TestMethod] + public async Task CreateGet_StoreScope_ClearsAvailableStores() + { + // Regression test for the cross-store leak flagged in code review: PrepareCustomerModel + // always populates every store's id/name onto AvailableStores (used for the + // StaffStoreId/StoreId pickers), but Store's own view never renders either select and the + // model still reaches Store's store_customer_details_* widget zones via + // additional-data="Model" - other stores' names/ids must not leak there. + ScopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + CustomerViewModelServiceMock + .Setup(v => v.PrepareCustomerModel(It.IsAny(), null, false)) + .Callback((m, _, _) => + m.AvailableStores.Add(new SelectListItem { Value = "store-1", Text = "Store 1" })) + .Returns(Task.CompletedTask); + + var result = await Controller.Create(); + + var model = (CustomerModel)((ViewResult)result).Model; + Assert.AreEqual(0, model.AvailableStores.Count); + } + + [TestMethod] + public async Task CreateGet_GlobalScope_KeepsAvailableStores() + { + ScopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + CustomerViewModelServiceMock + .Setup(v => v.PrepareCustomerModel(It.IsAny(), null, false)) + .Callback((m, _, _) => + m.AvailableStores.Add(new SelectListItem { Value = "store-1", Text = "Store 1" })) + .Returns(Task.CompletedTask); + + var result = await Controller.Create(); + + var model = (CustomerModel)((ViewResult)result).Model; + Assert.AreEqual(1, model.AvailableStores.Count); + } + + [TestMethod] + public async Task EditGet_StoreScope_ClearsAvailableStores() + { + var customer = new Customer { Id = "c1" }; + CustomerServiceMock.Setup(s => s.GetCustomerById("c1")).ReturnsAsync(customer); + ScopeMock.Setup(s => s.HasAccess(customer)).ReturnsAsync(true); + ScopeMock.Setup(s => s.DefaultStoreId).Returns("store-1"); + CustomerViewModelServiceMock + .Setup(v => v.PrepareCustomerModel(It.IsAny(), customer, false)) + .Callback((m, _, _) => + m.AvailableStores.Add(new SelectListItem { Value = "store-1", Text = "Store 1" })) + .Returns(Task.CompletedTask); + + var result = await Controller.Edit("c1"); + + var model = (CustomerModel)((ViewResult)result).Model; + Assert.AreEqual(0, model.AvailableStores.Count); + } + + [TestMethod] + public async Task EditGet_GlobalScope_KeepsAvailableStores() + { + var customer = new Customer { Id = "c1" }; + CustomerServiceMock.Setup(s => s.GetCustomerById("c1")).ReturnsAsync(customer); + ScopeMock.Setup(s => s.HasAccess(customer)).ReturnsAsync(true); + ScopeMock.Setup(s => s.DefaultStoreId).Returns((string)null); + CustomerViewModelServiceMock + .Setup(v => v.PrepareCustomerModel(It.IsAny(), customer, false)) + .Callback((m, _, _) => + m.AvailableStores.Add(new SelectListItem { Value = "store-1", Text = "Store 1" })) + .Returns(Task.CompletedTask); + + var result = await Controller.Edit("c1"); + + var model = (CustomerModel)((ViewResult)result).Model; + Assert.AreEqual(1, model.AvailableStores.Count); + } + [TestMethod] public async Task CreatePost_Invalid_TwoFactorWarningHookNotCalledOnBase() { diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseCustomerController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseCustomerController.cs index e3d1b2106..d51774818 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseCustomerController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseCustomerController.cs @@ -74,6 +74,19 @@ public abstract class BaseCustomerController( /// (BaseCustomerManagementController never overrides this — Admin never had this method). protected virtual Task ApplyPostConstraints(CustomerModel model) => Task.CompletedTask; + /// PrepareCustomerModel always populates every store's id/name onto + /// model.AvailableStores (used for the StaffStoreId/StoreId pickers). Store's own + /// CreateOrUpdate.TabInfo view gates both selects on Scope.DefaultStoreId is null and never + /// renders them - but the model is still handed to Store's store_customer_details_* widget + /// zones via additional-data="Model", so a Store widget must not be able to read other + /// stores' names/ids off a store-scoped screen. Same fix and rationale as ARCH-001 + /// EmailAccount's BaseEmailAccountController (code review, 2026-09-09). + private void ClearAvailableStoresForStoreScope(CustomerModel model) + { + if (scope.DefaultStoreId is not null) + model.AvailableStores.Clear(); + } + /// Admin-only: warns when a submitted model newly enables two-factor auth. No-op here /// — Store's originals never referenced TwoFactorEnabled at all. existingCustomer is null on /// Create, which collapses to Admin's original simpler create-time condition automatically. @@ -184,6 +197,7 @@ public async Task Create() var model = new CustomerModel(); await customerViewModelService.PrepareCustomerModel(model, null, false); await ApplyPostConstraints(model); + ClearAvailableStoresForStoreScope(model); model.Active = true; return View(model); } @@ -218,6 +232,7 @@ public async Task Create(CustomerModel model, bool continueEditin await customerViewModelService.PrepareCustomerModel(model, null, true); await ApplyPostConstraints(model); + ClearAvailableStoresForStoreScope(model); return View(model); } @@ -244,6 +259,7 @@ public async Task Edit(string id) var model = new CustomerModel(); await customerViewModelService.PrepareCustomerModel(model, customer, false); + ClearAvailableStoresForStoreScope(model); return View(model); } @@ -287,6 +303,7 @@ public async Task Edit(CustomerModel model, bool continueEditing) await customerViewModelService.PrepareCustomerModel(model, customer, true); await ApplyPostConstraints(model); + ClearAvailableStoresForStoreScope(model); return View(model); }