From cf527ed9cc39a4388b70ae1da2bfa9acf7501730 Mon Sep 17 00:00:00 2001 From: support Date: Fri, 16 Oct 2026 23:12:05 +0200 Subject: [PATCH 1/8] Pass the customer and store to the external authentication service ExternalAuthenticationService read the request customer and store from IContextAccessor in Authenticate, in the registration and login paths (CustomerLoggedInEvent, RegistrationRequest) and in the provider availability checks. LoadActiveAuthenticationProviders took a customer and a store but ignored both. The callers (Facebook, Google and OpenID Connect controllers, the sign-out provider, the panel login page and the customer info handler) now pass them, and the service no longer depends on the ambient context. --- .../Services/ExternalAuthenticationService.cs | 76 +++++++++++-------- .../IExternalAuthenticationService.cs | 16 ++-- .../FacebookAuthenticationController.cs | 14 +++- .../GoogleAuthenticationController.cs | 14 +++- .../Controllers/OidcLoginController.cs | 3 +- .../Infrastructure/OidcSignOutProvider.cs | 6 +- .../ExternalAuthenticationServiceTests.cs | 52 ++++++++++--- .../OpenIdConnect/OidcLoginControllerTests.cs | 10 +-- .../OpenIdConnect/OidcSignOutProviderTests.cs | 7 +- .../LoginControllerExternalSignInTests.cs | 6 +- .../LoginControllerProviderOnlyTests.cs | 4 +- .../Handlers/Customers/GetInfoHandlerTests.cs | 2 +- .../Controllers/BaseLoginController.cs | 4 +- .../Handlers/Customers/GetInfoHandler.cs | 3 +- 14 files changed, 142 insertions(+), 75 deletions(-) diff --git a/src/Business/Grand.Business.Authentication/Services/ExternalAuthenticationService.cs b/src/Business/Grand.Business.Authentication/Services/ExternalAuthenticationService.cs index 9c06bc0d7e..80a0769cb1 100644 --- a/src/Business/Grand.Business.Authentication/Services/ExternalAuthenticationService.cs +++ b/src/Business/Grand.Business.Authentication/Services/ExternalAuthenticationService.cs @@ -9,7 +9,6 @@ using Grand.Data; using Grand.Domain.Customers; using Grand.Domain.Stores; -using Grand.Infrastructure; using Grand.Infrastructure.Extensions; using Grand.SharedKernel.Extensions; using Grand.Mediator; @@ -31,7 +30,6 @@ public ExternalAuthenticationService( IGroupService groupService, IMediator mediator, IRepository externalAuthenticationRecordRepository, - IContextAccessor contextAccessor, IEnumerable externalAuthenticationProviders, CustomerSettings customerSettings, ExternalAuthenticationSettings externalAuthenticationSettings) @@ -44,7 +42,6 @@ public ExternalAuthenticationService( _groupService = groupService; _mediator = mediator; _externalAuthenticationRecordRepository = externalAuthenticationRecordRepository; - _contextAccessor = contextAccessor; _externalAuthenticationProviders = externalAuthenticationProviders; } @@ -58,7 +55,6 @@ public ExternalAuthenticationService( private readonly IGroupService _groupService; private readonly IMediator _mediator; private readonly IRepository _externalAuthenticationRecordRepository; - private readonly IContextAccessor _contextAccessor; private readonly IEnumerable _externalAuthenticationProviders; private readonly CustomerSettings _customerSettings; private readonly ExternalAuthenticationSettings _externalAuthenticationSettings; @@ -72,14 +68,16 @@ public ExternalAuthenticationService( /// /// Associated with passed external authentication parameters user /// Current logged-in user + /// Customer of the request (a guest when nobody is logged in) + /// Store identifier /// URL to which the user will return after authentication /// Result of an authentication protected virtual async Task AuthenticateExistingUser(Customer associatedUser, - Customer currentLoggedInUser, string returnUrl) + Customer currentLoggedInUser, Customer customer, string storeId, string returnUrl) { //log in guest user if (currentLoggedInUser == null) - return await LoginUser(associatedUser, returnUrl); + return await LoginUser(associatedUser, customer, storeId, returnUrl); //account is already assigned to another user if (currentLoggedInUser.Id != associatedUser.Id) @@ -95,10 +93,12 @@ protected virtual async Task AuthenticateExistingUser(Customer as /// /// Current logged-in user /// Authentication parameters received from external authentication method + /// Customer of the request (a guest when nobody is logged in) + /// Store identifier /// URL to which the user will return after authentication /// Result of an authentication protected virtual async Task AuthenticateNewUser(Customer currentLoggedInUser, - ExternalAuthParam parameters, string returnUrl) + ExternalAuthParam parameters, Customer customer, string storeId, string returnUrl) { //associate external account with logged-in user if (currentLoggedInUser != null) @@ -111,7 +111,7 @@ protected virtual async Task AuthenticateNewUser(Customer current //or try to register new user if (_customerSettings.UserRegistrationType != UserRegistrationType.Disabled) - return await RegisterNewUser(parameters, returnUrl); + return await RegisterNewUser(parameters, customer, storeId, returnUrl); //registration is disabled return Error(["Registration is disabled"]); @@ -121,33 +121,36 @@ protected virtual async Task AuthenticateNewUser(Customer current /// Register new user /// /// Authentication parameters received from external authentication method + /// Guest customer of the request; registration turns it into the new user + /// Store identifier /// URL to which the user will return after authentication /// Result of an authentication - protected virtual async Task RegisterNewUser(ExternalAuthParam parameters, string returnUrl) + protected virtual async Task RegisterNewUser(ExternalAuthParam parameters, Customer customer, + string storeId, string returnUrl) { var approved = _customerSettings.UserRegistrationType is UserRegistrationType.Standard or UserRegistrationType.EmailValidation; //create registration request - var registrationRequest = new RegistrationRequest(_contextAccessor.WorkContext.CurrentCustomer, + var registrationRequest = new RegistrationRequest(customer, parameters.Email, parameters.Email, CommonHelper.GenerateRandomDigitCode(20), PasswordFormat.Hashed, - _contextAccessor.StoreContext.CurrentStore.Id, + storeId, approved); //whether registration request has been completed successfully await _customerManagerService.RegisterCustomer(registrationRequest); //allow to save other customer values by consuming this event - await _mediator.Publish(new RegisteredByExternalMethod(_contextAccessor.WorkContext.CurrentCustomer, parameters)); + await _mediator.Publish(new RegisteredByExternalMethod(customer, parameters)); //raise customer registered event - await _mediator.Publish(new CustomerRegisteredEvent(_contextAccessor.WorkContext.CurrentCustomer)); + await _mediator.Publish(new CustomerRegisteredEvent(customer)); //associate external account with registered user - await AssociateCustomer(_contextAccessor.WorkContext.CurrentCustomer, parameters); + await AssociateCustomer(customer, parameters); //authenticate if (!approved) @@ -155,7 +158,7 @@ _customerSettings.UserRegistrationType is UserRegistrationType.Standard ? new RedirectToRouteResult("RegisterResult", new { resultId = (int)UserRegistrationType.AdminApproval }) : Error(["Error on registration"]); - await _authenticationService.SignIn(_contextAccessor.WorkContext.CurrentCustomer, false); + await _authenticationService.SignIn(customer, false); return new RedirectToRouteResult("RegisterResult", new { resultId = (int)UserRegistrationType.Standard }); } @@ -164,13 +167,15 @@ _customerSettings.UserRegistrationType is UserRegistrationType.Standard /// Login passed user /// /// User to login + /// Customer of the request before the login (the guest whose cart is merged) + /// Store identifier /// URL to which the user will return after authentication /// Result of an authentication - protected virtual async Task LoginUser(Customer user, string returnUrl) + protected virtual async Task LoginUser(Customer user, Customer previousCustomer, string storeId, + string returnUrl) { - //raise event - await _mediator.Publish(new CustomerLoggedInEvent(user, - _contextAccessor.WorkContext.CurrentCustomer, _contextAccessor.StoreContext.CurrentStore.Id)); + //raise event + await _mediator.Publish(new CustomerLoggedInEvent(user, previousCustomer, storeId)); //authenticate await _authenticationService.SignIn(user, false); @@ -201,14 +206,14 @@ protected virtual IActionResult Error(IEnumerable errors) /// Load active external authentication providers /// /// External Authentication Providers - public virtual IList LoadActiveAuthenticationProviders(Customer customer = null, - Store store = null) + public virtual IList LoadActiveAuthenticationProviders(Customer customer, + Store store) { return LoadAllAuthenticationProviders() .Where(provider => provider.IsMethodActive(_externalAuthenticationSettings) && - provider.IsAuthenticateGroup(_contextAccessor.WorkContext.CurrentCustomer) && - provider.IsAuthenticateStore(_contextAccessor.StoreContext.CurrentStore) + provider.IsAuthenticateGroup(customer) && + provider.IsAuthenticateStore(store) ).ToList(); } @@ -236,15 +241,17 @@ public virtual IList LoadAllAuthenticationProvi /// Check whether authentication by the passed external authentication provider is available /// /// System name of the external authentication provider + /// Customer whose groups the provider may be limited to + /// Store the provider may be limited to /// True if is available; otherwise false - public virtual bool AuthenticationProviderIsAvailable(string systemName) + public virtual bool AuthenticationProviderIsAvailable(string systemName, Customer customer, Store store) { var authenticationMethod = LoadAuthenticationProviderBySystemName(systemName); return authenticationMethod != null && authenticationMethod.IsMethodActive(_externalAuthenticationSettings) && - authenticationMethod.IsAuthenticateGroup(_contextAccessor.WorkContext.CurrentCustomer) && - authenticationMethod.IsAuthenticateStore(_contextAccessor.StoreContext.CurrentStore); + authenticationMethod.IsAuthenticateGroup(customer) && + authenticationMethod.IsAuthenticateStore(store); } #endregion @@ -255,27 +262,30 @@ public virtual bool AuthenticationProviderIsAvailable(string systemName) /// Authenticate user by passed parameters /// /// External authentication parameters + /// Customer of the request (a guest when nobody is logged in) + /// Store of the request /// URL to which the user will return after authentication /// Result of an authentication - public virtual async Task Authenticate(ExternalAuthParam parameters, string returnUrl = null) + public virtual async Task Authenticate(ExternalAuthParam parameters, Customer customer, Store store, + string returnUrl = null) { ArgumentNullException.ThrowIfNull(parameters); + ArgumentNullException.ThrowIfNull(customer); + ArgumentNullException.ThrowIfNull(store); - if (!AuthenticationProviderIsAvailable(parameters.ProviderSystemName)) + if (!AuthenticationProviderIsAvailable(parameters.ProviderSystemName, customer, store)) return Error(["External authentication method cannot be loaded"]); //get current logged-in user - var currentLoggedInUser = await _groupService.IsRegistered(_contextAccessor.WorkContext.CurrentCustomer) - ? _contextAccessor.WorkContext.CurrentCustomer - : null; + var currentLoggedInUser = await _groupService.IsRegistered(customer) ? customer : null; //authenticate associated user if already exists var associatedUser = await GetCustomer(parameters); if (associatedUser != null) - return await AuthenticateExistingUser(associatedUser, currentLoggedInUser, returnUrl); + return await AuthenticateExistingUser(associatedUser, currentLoggedInUser, customer, store.Id, returnUrl); //or associate and authenticate new user - return await AuthenticateNewUser(currentLoggedInUser, parameters, returnUrl); + return await AuthenticateNewUser(currentLoggedInUser, parameters, customer, store.Id, returnUrl); } #endregion diff --git a/src/Business/Grand.Business.Core/Interfaces/Authentication/IExternalAuthenticationService.cs b/src/Business/Grand.Business.Core/Interfaces/Authentication/IExternalAuthenticationService.cs index 9df583b322..d9037a6936 100644 --- a/src/Business/Grand.Business.Core/Interfaces/Authentication/IExternalAuthenticationService.cs +++ b/src/Business/Grand.Business.Core/Interfaces/Authentication/IExternalAuthenticationService.cs @@ -16,9 +16,12 @@ public interface IExternalAuthenticationService /// Use passed parameters to authenticate customer /// /// External authentication parameters + /// Customer of the request (a guest when nobody is logged in) + /// Store of the request /// Defines the URL of user return, when successfully authenticated /// Result of an authentication - Task Authenticate(ExternalAuthParam parameters, string returnUrl = null); + Task Authenticate(ExternalAuthParam parameters, Customer customer, Store store, + string returnUrl = null); #endregion @@ -50,11 +53,10 @@ public interface IExternalAuthenticationService /// /// Load all active authentication providers /// - /// Customer - /// Store + /// Customer whose groups the providers may be limited to + /// Store the providers may be limited to /// External authentication providers - IList LoadActiveAuthenticationProviders(Customer customer = null, - Store store = null); + IList LoadActiveAuthenticationProviders(Customer customer, Store store); /// /// Load specified external authentication provider by system name @@ -73,8 +75,10 @@ IList LoadActiveAuthenticationProviders(Custome /// Check whether authentication by the passed external authentication provider is available /// /// System name of the external authentication provider + /// Customer whose groups the provider may be limited to + /// Store the provider may be limited to /// True if is available; otherwise false - bool AuthenticationProviderIsAvailable(string systemName); + bool AuthenticationProviderIsAvailable(string systemName, Customer customer, Store store); #endregion } \ No newline at end of file diff --git a/src/Plugins/Authentication.Facebook/Controllers/FacebookAuthenticationController.cs b/src/Plugins/Authentication.Facebook/Controllers/FacebookAuthenticationController.cs index 872797afc6..0e72bc1adc 100644 --- a/src/Plugins/Authentication.Facebook/Controllers/FacebookAuthenticationController.cs +++ b/src/Plugins/Authentication.Facebook/Controllers/FacebookAuthenticationController.cs @@ -1,6 +1,7 @@ using Authentication.Facebook.Models; using Grand.Business.Core.Interfaces.Authentication; using Grand.Business.Core.Utilities.Authentication; +using Grand.Infrastructure; using Grand.SharedKernel; using Grand.Web.Common.Controllers; using Microsoft.AspNetCore.Authentication; @@ -17,10 +18,12 @@ public class FacebookAuthenticationController : BasePluginController public FacebookAuthenticationController( IExternalAuthenticationService externalAuthenticationService, - IConfiguration configuration) + IConfiguration configuration, + IContextAccessor contextAccessor) { _externalAuthenticationService = externalAuthenticationService; _configuration = configuration; + _contextAccessor = contextAccessor; } #endregion @@ -29,6 +32,7 @@ public FacebookAuthenticationController( private readonly IExternalAuthenticationService _externalAuthenticationService; private readonly IConfiguration _configuration; + private readonly IContextAccessor _contextAccessor; #endregion @@ -36,8 +40,9 @@ public FacebookAuthenticationController( public IActionResult FacebookLogin(string returnUrl) { - if (!_externalAuthenticationService.AuthenticationProviderIsAvailable(FacebookAuthenticationDefaults - .ProviderSystemName)) + if (!_externalAuthenticationService.AuthenticationProviderIsAvailable( + FacebookAuthenticationDefaults.ProviderSystemName, _contextAccessor.WorkContext.CurrentCustomer, + _contextAccessor.StoreContext.CurrentStore)) throw new GrandException("Facebook authentication module cannot be loaded"); if (string.IsNullOrEmpty(_configuration["FacebookSettings:AppId"]) || @@ -71,7 +76,8 @@ public async Task FacebookLoginCallback(string returnUrl) }; //authenticate Grand user - return await _externalAuthenticationService.Authenticate(authenticationParameters, returnUrl); + return await _externalAuthenticationService.Authenticate(authenticationParameters, + _contextAccessor.WorkContext.CurrentCustomer, _contextAccessor.StoreContext.CurrentStore, returnUrl); } public IActionResult FacebookSignInFailed(string error_code, string error_message, string state) diff --git a/src/Plugins/Authentication.Google/Controllers/GoogleAuthenticationController.cs b/src/Plugins/Authentication.Google/Controllers/GoogleAuthenticationController.cs index 9cc8fe1941..368c4acdff 100644 --- a/src/Plugins/Authentication.Google/Controllers/GoogleAuthenticationController.cs +++ b/src/Plugins/Authentication.Google/Controllers/GoogleAuthenticationController.cs @@ -1,6 +1,7 @@ using Authentication.Google.Models; using Grand.Business.Core.Interfaces.Authentication; using Grand.Business.Core.Utilities.Authentication; +using Grand.Infrastructure; using Grand.SharedKernel; using Grand.Web.Common.Controllers; using Microsoft.AspNetCore.Authentication; @@ -16,10 +17,12 @@ public class GoogleAuthenticationController : BasePluginController #region Ctor public GoogleAuthenticationController(IExternalAuthenticationService externalAuthenticationService, - IConfiguration configuration) + IConfiguration configuration, + IContextAccessor contextAccessor) { _externalAuthenticationService = externalAuthenticationService; _configuration = configuration; + _contextAccessor = contextAccessor; } #endregion @@ -28,6 +31,7 @@ public GoogleAuthenticationController(IExternalAuthenticationService externalAut private readonly IExternalAuthenticationService _externalAuthenticationService; private readonly IConfiguration _configuration; + private readonly IContextAccessor _contextAccessor; #endregion @@ -35,8 +39,9 @@ public GoogleAuthenticationController(IExternalAuthenticationService externalAut public IActionResult GoogleLogin(string returnUrl) { - if (!_externalAuthenticationService.AuthenticationProviderIsAvailable(GoogleAuthenticationDefaults - .ProviderSystemName)) + if (!_externalAuthenticationService.AuthenticationProviderIsAvailable( + GoogleAuthenticationDefaults.ProviderSystemName, _contextAccessor.WorkContext.CurrentCustomer, + _contextAccessor.StoreContext.CurrentStore)) throw new GrandException("Google authentication module cannot be loaded"); if (string.IsNullOrEmpty(_configuration["GoogleSettings:ClientId"]) || @@ -70,7 +75,8 @@ public async Task GoogleLoginCallback(string returnUrl) }; //authenticate grand user - return await _externalAuthenticationService.Authenticate(authenticationParameters, returnUrl); + return await _externalAuthenticationService.Authenticate(authenticationParameters, + _contextAccessor.WorkContext.CurrentCustomer, _contextAccessor.StoreContext.CurrentStore, returnUrl); } public IActionResult GoogleSignInFailed(string error_message) diff --git a/src/Plugins/Authentication.OpenIdConnect/Controllers/OidcLoginController.cs b/src/Plugins/Authentication.OpenIdConnect/Controllers/OidcLoginController.cs index 658376972a..216949c58d 100644 --- a/src/Plugins/Authentication.OpenIdConnect/Controllers/OidcLoginController.cs +++ b/src/Plugins/Authentication.OpenIdConnect/Controllers/OidcLoginController.cs @@ -259,7 +259,8 @@ private async Task RefuseLinkInProviderOnly() } private bool IsAvailable() => - _externalAuthenticationService.AuthenticationProviderIsAvailable(OidcLoginDefaults.ProviderSystemName); + _externalAuthenticationService.AuthenticationProviderIsAvailable(OidcLoginDefaults.ProviderSystemName, + _contextAccessor.WorkContext.CurrentCustomer, _contextAccessor.StoreContext.CurrentStore); private static bool TryReadMarker(string marker, out long shown, out string subject) { diff --git a/src/Plugins/Authentication.OpenIdConnect/Infrastructure/OidcSignOutProvider.cs b/src/Plugins/Authentication.OpenIdConnect/Infrastructure/OidcSignOutProvider.cs index c890d9abca..78281a8422 100644 --- a/src/Plugins/Authentication.OpenIdConnect/Infrastructure/OidcSignOutProvider.cs +++ b/src/Plugins/Authentication.OpenIdConnect/Infrastructure/OidcSignOutProvider.cs @@ -1,4 +1,5 @@ using Grand.Business.Core.Interfaces.Authentication; +using Grand.Infrastructure; using Grand.Infrastructure.Configuration; using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.Extensions.Logging; @@ -14,11 +15,14 @@ public class OidcSignOutProvider( IOptionsMonitor optionsMonitor, OidcLoginConfig config, IExternalAuthenticationService externalAuthenticationService, + IContextAccessor contextAccessor, ILogger logger) : IExternalSignOutProvider { public async Task GetSignOutUrl(string postLogoutRedirectUri) { - if (!externalAuthenticationService.AuthenticationProviderIsAvailable(OidcLoginDefaults.ProviderSystemName)) + //called before the local sign-out: the customer is still the signed-in one + if (!externalAuthenticationService.AuthenticationProviderIsAvailable(OidcLoginDefaults.ProviderSystemName, + contextAccessor.WorkContext.CurrentCustomer, contextAccessor.StoreContext.CurrentStore)) return null; var options = optionsMonitor.Get(OidcLoginDefaults.AuthenticationScheme); diff --git a/src/Tests/Grand.Business.Authentication.Tests/Services/ExternalAuthenticationServiceTests.cs b/src/Tests/Grand.Business.Authentication.Tests/Services/ExternalAuthenticationServiceTests.cs index ec0ed70820..baf50fbd00 100644 --- a/src/Tests/Grand.Business.Authentication.Tests/Services/ExternalAuthenticationServiceTests.cs +++ b/src/Tests/Grand.Business.Authentication.Tests/Services/ExternalAuthenticationServiceTests.cs @@ -1,13 +1,14 @@ using Grand.Business.Authentication.Services; +using Grand.Business.Core.Events.Customers; using Grand.Business.Core.Interfaces.Authentication; using Grand.Business.Core.Interfaces.Common.Directory; using Grand.Business.Core.Interfaces.Customers; using Grand.Business.Core.Utilities.Authentication; +using Grand.Business.Core.Utilities.Customers; using Grand.Data; using Grand.Data.Tests.MongoDb; using Grand.Domain.Customers; using Grand.Domain.Stores; -using Grand.Infrastructure; using Grand.Mediator; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; @@ -26,7 +27,6 @@ public class ExternalAuthenticationServiceTests private ExternalAuthenticationService _externalAuthenticationService; private Mock _groupServiceMock; private Mock _mediatorMock; - private Mock _workContextMock; [TestInitialize] public void Init() @@ -37,9 +37,6 @@ public void Init() _customerManagerServiceMock = new Mock(); _mediatorMock = new Mock(); _externalAuthenticationRecordRepository = new MongoDBRepositoryTest(); - _workContextMock = new Mock(); - _workContextMock.Setup(s => s.WorkContext.CurrentCustomer).Returns(new Customer()); - _workContextMock.Setup(c => c.StoreContext.CurrentStore).Returns(() => new Store { Id = "" }); _externalAuthenticationProviders = new List { new ExternalAuthenticationProviderTest() }; @@ -52,8 +49,8 @@ public void Init() _externalAuthenticationService = new ExternalAuthenticationService(_authenticationServiceMock.Object, _customerManagerServiceMock.Object, _customerServiceMock.Object, _groupServiceMock.Object, _mediatorMock.Object, _externalAuthenticationRecordRepository, - _workContextMock.Object, _externalAuthenticationProviders, - new CustomerSettings(), externalAuthenticationSetting); + _externalAuthenticationProviders, + new CustomerSettings { UserRegistrationType = UserRegistrationType.Standard }, externalAuthenticationSetting); } @@ -61,7 +58,7 @@ public void Init() public void LoadActiveAuthenticationProvidersTest() { //Act - var result = _externalAuthenticationService.LoadActiveAuthenticationProviders(); + var result = _externalAuthenticationService.LoadActiveAuthenticationProviders(new Customer(), new Store()); //Assert Assert.IsTrue(result.Any()); } @@ -90,7 +87,8 @@ public void AuthenticationProviderIsAvailableTest_Available() { //Act var result = - _externalAuthenticationService.AuthenticationProviderIsAvailable("ExternalAuthenticationProviderTest"); + _externalAuthenticationService.AuthenticationProviderIsAvailable("ExternalAuthenticationProviderTest", + new Customer(), new Store()); //Assert Assert.IsTrue(result); } @@ -99,11 +97,45 @@ public void AuthenticationProviderIsAvailableTest_Available() public void AuthenticationProviderIsAvailableTest_NotAvailable() { //Act - var result = _externalAuthenticationService.AuthenticationProviderIsAvailable("123"); + var result = _externalAuthenticationService.AuthenticationProviderIsAvailable("123", new Customer(), new Store()); //Assert Assert.IsFalse(result); } + [TestMethod] + public async Task Authenticate_AssociatedUserFromGuest_PublishesLoggedInEventWithPassedCustomerAndStore() + { + var user = new Customer { Id = "user" }; + var guest = new Customer { Id = "guest" }; + _customerServiceMock.Setup(c => c.GetCustomerById("user")).ReturnsAsync(user); + var parameters = new ExternalAuthParam { ProviderSystemName = "ExternalAuthenticationProviderTest", Identifier = "1" }; + await _externalAuthenticationService.AssociateCustomer(user, parameters); + + await _externalAuthenticationService.Authenticate(parameters, guest, new Store { Id = "store1" }); + + _mediatorMock.Verify(m => m.Publish(It.Is(e => + e.Customer == user && e.PreviousCustomer == guest && e.StoreId == "store1"), default), Times.Once); + _authenticationServiceMock.Verify(a => a.SignIn(user, false), Times.Once); + } + + [TestMethod] + public async Task Authenticate_UnknownIdentity_RegistersPassedCustomerInPassedStore() + { + var guest = new Customer { Id = "guest" }; + var parameters = new ExternalAuthParam { + ProviderSystemName = "ExternalAuthenticationProviderTest", Identifier = "new", Email = "new@test.com" + }; + + await _externalAuthenticationService.Authenticate(parameters, guest, new Store { Id = "store1" }); + + _customerManagerServiceMock.Verify(m => m.RegisterCustomer(It.Is(r => + r.Customer == guest && r.StoreId == "store1" && r.Email == "new@test.com")), Times.Once); + _mediatorMock.Verify(m => m.Publish(It.Is(e => e.Customer == guest), default), + Times.Once); + _authenticationServiceMock.Verify(a => a.SignIn(guest, false), Times.Once); + Assert.AreEqual("guest", _externalAuthenticationRecordRepository.Table.Single().CustomerId); + } + [TestMethod] public async Task AssociateCustomerTest() { diff --git a/src/Tests/Grand.Modules.Tests/Plugins/OpenIdConnect/OidcLoginControllerTests.cs b/src/Tests/Grand.Modules.Tests/Plugins/OpenIdConnect/OidcLoginControllerTests.cs index 5620f56c5a..cdef82939b 100644 --- a/src/Tests/Grand.Modules.Tests/Plugins/OpenIdConnect/OidcLoginControllerTests.cs +++ b/src/Tests/Grand.Modules.Tests/Plugins/OpenIdConnect/OidcLoginControllerTests.cs @@ -77,7 +77,7 @@ public void Init() _sessions.Setup(x => x.Start(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync("session-1"); _mediator = new Mock(); _externalAuth = new Mock(); - _externalAuth.Setup(x => x.AuthenticationProviderIsAvailable(OidcLoginDefaults.ProviderSystemName)).Returns(true); + _externalAuth.Setup(x => x.AuthenticationProviderIsAvailable(OidcLoginDefaults.ProviderSystemName, It.IsAny(), It.IsAny())).Returns(true); _schemes = new Mock(); _schemes.Setup(x => x.GetSchemeAsync(OidcLoginDefaults.AuthenticationScheme)).ReturnsAsync( new AuthenticationScheme(OidcLoginDefaults.AuthenticationScheme, null, typeof(IAuthenticationHandler))); @@ -149,7 +149,7 @@ public async Task Login_NotConfigured_Failed() [TestMethod] public async Task Login_MethodInactive_Failed() { - _externalAuth.Setup(x => x.AuthenticationProviderIsAvailable(It.IsAny())).Returns(false); + _externalAuth.Setup(x => x.AuthenticationProviderIsAvailable(It.IsAny(), It.IsAny(), It.IsAny())).Returns(false); Assert.AreEqual("OidcLoginFailed", ((RedirectToRouteResult)await _controller.Login("/")).RouteName); } @@ -286,7 +286,7 @@ public void Failed_IdentityTaken_InModel() public async Task Callback_MethodInactive_ClearsAndFails_NothingResolved() { _external.Setup(x => x.GetPending()).ReturnsAsync(Pending); - _externalAuth.Setup(x => x.AuthenticationProviderIsAvailable(It.IsAny())).Returns(false); + _externalAuth.Setup(x => x.AuthenticationProviderIsAvailable(It.IsAny(), It.IsAny(), It.IsAny())).Returns(false); Assert.AreEqual("OidcLoginFailed", ((RedirectToRouteResult)await _controller.Callback("/")).RouteName); _external.Verify(x => x.Clear(), Times.Once); _resolver.Verify(x => x.ResolveForLogin(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), @@ -308,7 +308,7 @@ public async Task Link_MethodInactive_ClearsAndFails_StaysSignedIn() { _groups.Setup(x => x.IsRegistered(_current)).ReturnsAsync(true); _external.Setup(x => x.GetPending()).ReturnsAsync(Pending); - _externalAuth.Setup(x => x.AuthenticationProviderIsAvailable(It.IsAny())).Returns(false); + _externalAuth.Setup(x => x.AuthenticationProviderIsAvailable(It.IsAny(), It.IsAny(), It.IsAny())).Returns(false); Assert.AreEqual("OidcLoginFailed", ((RedirectToRouteResult)await _controller.Link("/")).RouteName); _external.Verify(x => x.Clear(), Times.Once); _httpAuth.Verify(x => x.SignOutAsync(It.IsAny(), GrandCookieAuthenticationDefaults.AuthenticationScheme, @@ -321,7 +321,7 @@ public async Task LinkComplete_MethodInactive_ClearsAndFails_NothingLinked() _groups.Setup(x => x.IsRegistered(_current)).ReturnsAsync(true); _external.Setup(x => x.GetPending()).ReturnsAsync(Pending); Marker("sub-1"); - _externalAuth.Setup(x => x.AuthenticationProviderIsAvailable(It.IsAny())).Returns(false); + _externalAuth.Setup(x => x.AuthenticationProviderIsAvailable(It.IsAny(), It.IsAny(), It.IsAny())).Returns(false); Assert.AreEqual("OidcLoginFailed", ((RedirectToRouteResult)await _controller.LinkComplete("/")).RouteName); _external.Verify(x => x.Complete(It.IsAny(), It.IsAny()), Times.Never); _external.Verify(x => x.Clear(), Times.Once); diff --git a/src/Tests/Grand.Modules.Tests/Plugins/OpenIdConnect/OidcSignOutProviderTests.cs b/src/Tests/Grand.Modules.Tests/Plugins/OpenIdConnect/OidcSignOutProviderTests.cs index 6bcafb76cb..17499096b3 100644 --- a/src/Tests/Grand.Modules.Tests/Plugins/OpenIdConnect/OidcSignOutProviderTests.cs +++ b/src/Tests/Grand.Modules.Tests/Plugins/OpenIdConnect/OidcSignOutProviderTests.cs @@ -1,6 +1,7 @@ using Authentication.OpenIdConnect; using Authentication.OpenIdConnect.Infrastructure; using Grand.Business.Core.Interfaces.Authentication; +using Grand.Infrastructure; using Grand.Infrastructure.Configuration; using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.Extensions.Logging; @@ -25,7 +26,7 @@ public class OidcSignOutProviderTests public void Init() { _authenticationService = new Mock(); - _authenticationService.Setup(x => x.AuthenticationProviderIsAvailable(OidcLoginDefaults.ProviderSystemName)) + _authenticationService.Setup(x => x.AuthenticationProviderIsAvailable(OidcLoginDefaults.ProviderSystemName, It.IsAny(), It.IsAny())) .Returns(true); _manager = new Mock>(); _logger = new Mock>(); @@ -43,7 +44,7 @@ private OidcSignOutProvider Provider(bool withManager = true) ConfigurationManager = withManager ? _manager.Object : null }); return new OidcSignOutProvider(monitor.Object, new OidcLoginConfig { ClientId = "shop app" }, - _authenticationService.Object, _logger.Object); + _authenticationService.Object, new Mock { DefaultValue = DefaultValue.Mock }.Object, _logger.Object); } [TestMethod] @@ -76,7 +77,7 @@ public async Task NoConfigurationManager_Null() => [TestMethod] public async Task NotAvailable_Null() { - _authenticationService.Setup(x => x.AuthenticationProviderIsAvailable(OidcLoginDefaults.ProviderSystemName)) + _authenticationService.Setup(x => x.AuthenticationProviderIsAvailable(OidcLoginDefaults.ProviderSystemName, It.IsAny(), It.IsAny())) .Returns(false); Assert.IsNull(await Provider().GetSignOutUrl(Back)); _manager.Verify(x => x.GetConfigurationAsync(It.IsAny()), Times.Never); diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/LoginControllerExternalSignInTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/LoginControllerExternalSignInTests.cs index 596c46c5c7..e16c8d1879 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/LoginControllerExternalSignInTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/LoginControllerExternalSignInTests.cs @@ -34,7 +34,7 @@ public void Setup() _controller = new LoginController(new CustomerSettings(), new CaptchaSettings(), new Mock().Object, new Mock().Object, new Mock().Object, new Mock().Object, - new Mock().Object, new Mock().Object, + new Mock().Object, new Mock { DefaultValue = DefaultValue.Mock }.Object, new Mock().Object); var httpContext = new DefaultHttpContext(); httpContext.Request.PathBase = "/shop"; @@ -54,7 +54,7 @@ public async Task Index_ListsPanelProvidersOnly() panel.Setup(x => x.FriendlyName).Returns("OpenID Connect"); panel.Setup(x => x.GetSignInPath("/shop/admin")).ReturnsAsync("/oidc/login?returnUrl=%2Fshop%2Fadmin"); var storefrontOnly = new Mock(); - _externalAuthenticationService.Setup(x => x.LoadActiveAuthenticationProviders(null, null)) + _externalAuthenticationService.Setup(x => x.LoadActiveAuthenticationProviders(It.IsAny(), It.IsAny())) .Returns([panel.Object, storefrontOnly.Object]); var model = (LoginModel)((ViewResult)await _controller.Index(_externalAuthenticationService.Object, new OidcLoginConfig(), _groupService.Object)).Model!; @@ -69,7 +69,7 @@ public async Task Index_ProviderWithoutPath_Hidden() { var panel = new Mock(); panel.Setup(x => x.GetSignInPath(It.IsAny())).ReturnsAsync((string)null!); - _externalAuthenticationService.Setup(x => x.LoadActiveAuthenticationProviders(null, null)).Returns([panel.Object]); + _externalAuthenticationService.Setup(x => x.LoadActiveAuthenticationProviders(It.IsAny(), It.IsAny())).Returns([panel.Object]); var model = (LoginModel)((ViewResult)await _controller.Index(_externalAuthenticationService.Object, new OidcLoginConfig(), _groupService.Object)).Model!; Assert.AreEqual(0, model.ExternalSignIn.Count); } diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/LoginControllerProviderOnlyTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/LoginControllerProviderOnlyTests.cs index 0316220bf6..ac8ce24d61 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/LoginControllerProviderOnlyTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/LoginControllerProviderOnlyTests.cs @@ -34,7 +34,7 @@ public void Setup() _groupService = new Mock(); var workContext = new Mock(); workContext.Setup(x => x.CurrentCustomer).Returns(new Customer()); - _contextAccessor = new Mock(); + _contextAccessor = new Mock { DefaultValue = DefaultValue.Mock }; _contextAccessor.Setup(x => x.WorkContext).Returns(workContext.Object); _customerManagerService = new Mock(); } @@ -63,7 +63,7 @@ private void WithSignInPath(string? path) var provider = new Mock(); provider.Setup(x => x.FriendlyName).Returns("OpenID Connect"); provider.Setup(x => x.GetSignInPath(It.IsAny())).ReturnsAsync(path); - _externalAuthenticationService.Setup(x => x.LoadActiveAuthenticationProviders(null, null)) + _externalAuthenticationService.Setup(x => x.LoadActiveAuthenticationProviders(It.IsAny(), It.IsAny())) .Returns([provider.Object]); } diff --git a/src/Tests/Grand.Web.Tests/Features/Handlers/Customers/GetInfoHandlerTests.cs b/src/Tests/Grand.Web.Tests/Features/Handlers/Customers/GetInfoHandlerTests.cs index 18910ba29b..45db0fbf5f 100644 --- a/src/Tests/Grand.Web.Tests/Features/Handlers/Customers/GetInfoHandlerTests.cs +++ b/src/Tests/Grand.Web.Tests/Features/Handlers/Customers/GetInfoHandlerTests.cs @@ -33,7 +33,7 @@ private static async Task Handle(OidcLoginConfig config) new() { Id = "1", ProviderSystemName = Oidc }, new() { Id = "2", ProviderSystemName = Google } }); - external.Setup(x => x.AuthenticationProviderIsAvailable(It.IsAny())).Returns(true); + external.Setup(x => x.AuthenticationProviderIsAvailable(It.IsAny(), It.IsAny(), It.IsAny())).Returns(true); var providerMock = new Mock(); providerMock.Setup(p => p.FriendlyName).Returns("Provider"); external.Setup(x => x.LoadAuthenticationProviderBySystemName(It.IsAny())).Returns(providerMock.Object); diff --git a/src/Web/Grand.Web.AdminShared/Controllers/BaseLoginController.cs b/src/Web/Grand.Web.AdminShared/Controllers/BaseLoginController.cs index 6dbde89931..0f25a00708 100644 --- a/src/Web/Grand.Web.AdminShared/Controllers/BaseLoginController.cs +++ b/src/Web/Grand.Web.AdminShared/Controllers/BaseLoginController.cs @@ -137,7 +137,9 @@ await _customerManagerService.LoginCustomer( private async Task PrepareExternalSignIn(LoginModel model, IExternalAuthenticationService externalAuthenticationService) { var returnUrl = Url.RouteUrl($"{GetCurrentArea()}Index"); - foreach (var provider in externalAuthenticationService.LoadActiveAuthenticationProviders() + foreach (var provider in externalAuthenticationService + .LoadActiveAuthenticationProviders(_contextAccessor.WorkContext.CurrentCustomer, + _contextAccessor.StoreContext.CurrentStore) .OfType()) { var path = await provider.GetSignInPath(returnUrl); diff --git a/src/Web/Grand.Web/Features/Handlers/Customers/GetInfoHandler.cs b/src/Web/Grand.Web/Features/Handlers/Customers/GetInfoHandler.cs index 10206f96e7..67ad687dae 100644 --- a/src/Web/Grand.Web/Features/Handlers/Customers/GetInfoHandler.cs +++ b/src/Web/Grand.Web/Features/Handlers/Customers/GetInfoHandler.cs @@ -209,7 +209,8 @@ private async Task PrepareExternalAuth(CustomerInfoModel model, GetInfo request) .LoadActiveAuthenticationProviders(request.Customer, request.Store).Count; foreach (var ear in await _externalAuthenticationService.GetExternalIdentifiers(request.Customer)) { - if (!_externalAuthenticationService.AuthenticationProviderIsAvailable(ear.ProviderSystemName)) + if (!_externalAuthenticationService.AuthenticationProviderIsAvailable(ear.ProviderSystemName, + request.Customer, request.Store)) continue; var authMethod = From ee45e1414506ffe77c5cc9a8323fbbbef10fc912 Mon Sep 17 00:00:00 2001 From: support Date: Fri, 16 Oct 2026 23:12:13 +0200 Subject: [PATCH 2/8] Sort customer group ids so cache keys do not depend on their order GetCustomerGroupIds returned the groups in the order they are stored, and about 30 cache keys (checkout attributes, categories, product mappings, menus, search, home page blocks) join that list. The same set of groups in another order therefore made a separate cache entry. Sorting once in GetCustomerGroupIds fixes every key; the ACL filters only test membership. --- .../Customers/CustomerExtensions.cs | 4 +-- .../CategoryServiceCacheKeyTests.cs | 14 ++++++++ .../CheckoutAttributeServiceTests.cs | 23 +++++++++++++ .../Customers/CustomerExtensionsTests.cs | 33 +++++++++++++++++++ 4 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 src/Tests/Grand.Domain.Tests/Customers/CustomerExtensionsTests.cs diff --git a/src/Core/Grand.Domain/Customers/CustomerExtensions.cs b/src/Core/Grand.Domain/Customers/CustomerExtensions.cs index b49a329b27..e101cd1f30 100644 --- a/src/Core/Grand.Domain/Customers/CustomerExtensions.cs +++ b/src/Core/Grand.Domain/Customers/CustomerExtensions.cs @@ -232,12 +232,12 @@ public static bool IsPasswordRecoveryLinkExpired(this Customer customer, Custome /// Get customer group identifiers /// /// Customer - /// Customer group identifiers + /// Customer group identifiers, sorted so that cache keys built from them do not depend on the stored order public static string[] GetCustomerGroupIds(this Customer customer) { ArgumentNullException.ThrowIfNull(customer); - return customer.Groups.ToArray(); + return customer.Groups.Order(StringComparer.Ordinal).ToArray(); } #endregion diff --git a/src/Tests/Grand.Business.Catalog.Tests/Services/Categories/CategoryServiceCacheKeyTests.cs b/src/Tests/Grand.Business.Catalog.Tests/Services/Categories/CategoryServiceCacheKeyTests.cs index 8760991d54..8154afe8b0 100644 --- a/src/Tests/Grand.Business.Catalog.Tests/Services/Categories/CategoryServiceCacheKeyTests.cs +++ b/src/Tests/Grand.Business.Catalog.Tests/Services/Categories/CategoryServiceCacheKeyTests.cs @@ -76,4 +76,18 @@ public async Task CustomersOfDifferentGroups_DoNotShareTheCachedTree() Assert.AreNotSame(first, second, "the ACL filter differs per group, so the entries must differ"); } + + [TestMethod] + public async Task SameGroupsInAnotherOrder_ShareTheCachedTree() + { + await _categoryService.InsertCategory(new Category { ParentCategoryId = "root", Published = true }); + + var first = await _categoryService.GetAllCategoriesByParentCategoryId( + CatalogAccess.For(WithGroups("customer-1", "group-a", "group-b"), "store"), "root"); + + var second = await _categoryService.GetAllCategoriesByParentCategoryId( + CatalogAccess.For(WithGroups("customer-2", "group-b", "group-a"), "store"), "root"); + + Assert.AreSame(first, second, "the order the groups are stored in must not split the cache"); + } } diff --git a/src/Tests/Grand.Business.Checkout.Tests/Services/CheckoutAttributes/CheckoutAttributeServiceTests.cs b/src/Tests/Grand.Business.Checkout.Tests/Services/CheckoutAttributes/CheckoutAttributeServiceTests.cs index 573c44188e..2842689096 100644 --- a/src/Tests/Grand.Business.Checkout.Tests/Services/CheckoutAttributes/CheckoutAttributeServiceTests.cs +++ b/src/Tests/Grand.Business.Checkout.Tests/Services/CheckoutAttributes/CheckoutAttributeServiceTests.cs @@ -145,4 +145,27 @@ public async Task GetAllCheckoutAttributes_CacheKeyDependsOnTheCustomerGroups() //Assert Assert.AreNotEqual(keys[0], keys[1]); } + + [TestMethod] + public async Task GetAllCheckoutAttributes_SameGroupsInAnotherOrder_UseTheSameCacheKey() + { + //Arrange + var keys = new List(); + _cacheMock.Setup(c => c.GetAsync(It.IsAny(), It.IsAny>>>())) + .Callback((string key, Func>> _) => keys.Add(key)) + .ReturnsAsync(new List()); + var first = new Customer(); + first.Groups.Add("registered"); + first.Groups.Add("members"); + var second = new Customer(); + second.Groups.Add("members"); + second.Groups.Add("registered"); + + //Act + await _service.GetAllCheckoutAttributes(CatalogAccess.For(first, "store")); + await _service.GetAllCheckoutAttributes(CatalogAccess.For(second, "store")); + + //Assert + Assert.AreEqual(keys[0], keys[1]); + } } \ No newline at end of file diff --git a/src/Tests/Grand.Domain.Tests/Customers/CustomerExtensionsTests.cs b/src/Tests/Grand.Domain.Tests/Customers/CustomerExtensionsTests.cs new file mode 100644 index 0000000000..f4d477a98f --- /dev/null +++ b/src/Tests/Grand.Domain.Tests/Customers/CustomerExtensionsTests.cs @@ -0,0 +1,33 @@ +using Grand.Domain.Customers; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Grand.Domain.Tests.Customers; + +[TestClass] +public class CustomerExtensionsTests +{ + [TestMethod] + public void GetCustomerGroupIds_ReturnsTheGroupsSorted() + { + var customer = new Customer(); + customer.Groups.Add("registered"); + customer.Groups.Add("Members"); + customer.Groups.Add("guests"); + + var ids = customer.GetCustomerGroupIds(); + + CollectionAssert.AreEqual(new[] { "Members", "guests", "registered" }, ids); + } + + [TestMethod] + public void GetCustomerGroupIds_DoesNotReorderTheCustomerGroups() + { + var customer = new Customer(); + customer.Groups.Add("registered"); + customer.Groups.Add("guests"); + + customer.GetCustomerGroupIds(); + + CollectionAssert.AreEqual(new[] { "registered", "guests" }, customer.Groups.ToArray()); + } +} From c82175757cbd4dd07f765c14221e3db3d5169887 Mon Sep 17 00:00:00 2001 From: support Date: Fri, 16 Oct 2026 23:12:52 +0200 Subject: [PATCH 3/8] Pass the request host to GetShoppingCart GetShoppingCartHandler took the customer, store, language and currency from the request, but read the host for checkout attribute download links from IContextAccessor. The host is now a property of GetShoppingCart, set by every sender, and the handler no longer depends on the ambient context. --- .../Grand.Web/Controllers/ShoppingCartController.cs | 11 ++++++++++- .../Handlers/ShoppingCart/GetShoppingCartHandler.cs | 8 ++------ .../Features/Models/ShoppingCart/GetShoppingCart.cs | 6 ++++++ 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/Web/Grand.Web/Controllers/ShoppingCartController.cs b/src/Web/Grand.Web/Controllers/ShoppingCartController.cs index c4d7369fbc..0c41bdf028 100644 --- a/src/Web/Grand.Web/Controllers/ShoppingCartController.cs +++ b/src/Web/Grand.Web/Controllers/ShoppingCartController.cs @@ -261,7 +261,8 @@ public virtual async Task> Cart(bool checkoutAtt Currency = _contextAccessor.WorkContext.WorkingCurrency, Language = _contextAccessor.WorkContext.WorkingLanguage, TaxDisplayType = _contextAccessor.WorkContext.TaxDisplayType, - Store = _contextAccessor.StoreContext.CurrentStore + Store = _contextAccessor.StoreContext.CurrentStore, + Host = _contextAccessor.StoreContext.CurrentHost }); return View(model); } @@ -280,6 +281,7 @@ public async Task> CartSummary() Currency = _contextAccessor.WorkContext.WorkingCurrency, Language = _contextAccessor.WorkContext.WorkingLanguage, Store = _contextAccessor.StoreContext.CurrentStore, + Host = _contextAccessor.StoreContext.CurrentHost, TaxDisplayType = _contextAccessor.WorkContext.TaxDisplayType }); @@ -334,6 +336,7 @@ public virtual async Task UpdateQuantity(UpdateQuantityModel mode Currency = _contextAccessor.WorkContext.WorkingCurrency, Language = _contextAccessor.WorkContext.WorkingLanguage, Store = _contextAccessor.StoreContext.CurrentStore, + Host = _contextAccessor.StoreContext.CurrentHost, TaxDisplayType = _contextAccessor.WorkContext.TaxDisplayType }); @@ -402,6 +405,7 @@ await _shoppingCartService.DeleteShoppingCartItem(_contextAccessor.WorkContext.C Currency = _contextAccessor.WorkContext.WorkingCurrency, Language = _contextAccessor.WorkContext.WorkingLanguage, Store = _contextAccessor.StoreContext.CurrentStore, + Host = _contextAccessor.StoreContext.CurrentHost, TaxDisplayType = _contextAccessor.WorkContext.TaxDisplayType }); @@ -452,6 +456,7 @@ public virtual async Task ChangeTypeCartItem(ChangeTypeCartItemMo Currency = _contextAccessor.WorkContext.WorkingCurrency, Language = _contextAccessor.WorkContext.WorkingLanguage, Store = _contextAccessor.StoreContext.CurrentStore, + Host = _contextAccessor.StoreContext.CurrentHost, TaxDisplayType = _contextAccessor.WorkContext.TaxDisplayType }); @@ -533,6 +538,7 @@ await _customerService.UpdateUserField(_contextAccessor.WorkContext.CurrentCusto Currency = _contextAccessor.WorkContext.WorkingCurrency, Language = _contextAccessor.WorkContext.WorkingLanguage, Store = _contextAccessor.StoreContext.CurrentStore, + Host = _contextAccessor.StoreContext.CurrentHost, TaxDisplayType = _contextAccessor.WorkContext.TaxDisplayType }); @@ -577,6 +583,7 @@ await _customerService.UpdateUserField(_contextAccessor.WorkContext.CurrentCusto Currency = _contextAccessor.WorkContext.WorkingCurrency, Language = _contextAccessor.WorkContext.WorkingLanguage, Store = _contextAccessor.StoreContext.CurrentStore, + Host = _contextAccessor.StoreContext.CurrentHost, TaxDisplayType = _contextAccessor.WorkContext.TaxDisplayType }); @@ -637,6 +644,7 @@ await _customerService.UpdateUserField(_contextAccessor.WorkContext.CurrentCusto Currency = _contextAccessor.WorkContext.WorkingCurrency, Language = _contextAccessor.WorkContext.WorkingLanguage, Store = _contextAccessor.StoreContext.CurrentStore, + Host = _contextAccessor.StoreContext.CurrentHost, TaxDisplayType = _contextAccessor.WorkContext.TaxDisplayType }); @@ -672,6 +680,7 @@ await _customerService.UpdateUserField(_contextAccessor.WorkContext.CurrentCusto Currency = _contextAccessor.WorkContext.WorkingCurrency, Language = _contextAccessor.WorkContext.WorkingLanguage, Store = _contextAccessor.StoreContext.CurrentStore, + Host = _contextAccessor.StoreContext.CurrentHost, TaxDisplayType = _contextAccessor.WorkContext.TaxDisplayType }); diff --git a/src/Web/Grand.Web/Features/Handlers/ShoppingCart/GetShoppingCartHandler.cs b/src/Web/Grand.Web/Features/Handlers/ShoppingCart/GetShoppingCartHandler.cs index 3d1799d183..8e97535a65 100644 --- a/src/Web/Grand.Web/Features/Handlers/ShoppingCart/GetShoppingCartHandler.cs +++ b/src/Web/Grand.Web/Features/Handlers/ShoppingCart/GetShoppingCartHandler.cs @@ -22,7 +22,6 @@ using Grand.Domain.Orders; using Grand.Domain.Tax; using Grand.Infrastructure.Configuration; -using Grand.Infrastructure; using Grand.Web.Common.Extensions; using Grand.Web.Common.Localization; using Grand.Web.Extensions; @@ -36,7 +35,6 @@ namespace Grand.Web.Features.Handlers.ShoppingCart; public class GetShoppingCartHandler : IRequestHandler { - private readonly IContextAccessor _contextAccessor; private readonly CatalogSettings _catalogSettings; private readonly ICheckoutAttributeFormatter _checkoutAttributeFormatter; private readonly ICheckoutAttributeParser _checkoutAttributeParser; @@ -95,10 +93,8 @@ public GetShoppingCartHandler( ShoppingCartSettings shoppingCartSettings, CatalogSettings catalogSettings, CommonSettings commonSettings, - IEnumTranslationService enumTranslationService, - IContextAccessor contextAccessor) + IEnumTranslationService enumTranslationService) { - _contextAccessor = contextAccessor; _productService = productService; _pictureService = pictureService; _translationService = translationService; @@ -165,7 +161,7 @@ private async Task PrepareSimpleProperties(ShoppingCartModel model, GetShoppingC request.Store.Id); model.CheckoutAttributeInfo = await _checkoutAttributeFormatter.FormatAttributes(checkoutAttributes, CalculationContextOf(request), - request.Language, _contextAccessor.StoreContext.CurrentHost); + request.Language, request.Host); if (!request.Cart.Where(x => x.ShoppingCartTypeId is ShoppingCartType.ShoppingCart or ShoppingCartType.Auctions) .ToList().Any()) { diff --git a/src/Web/Grand.Web/Features/Models/ShoppingCart/GetShoppingCart.cs b/src/Web/Grand.Web/Features/Models/ShoppingCart/GetShoppingCart.cs index 6094ecc7e1..ce49bd7fce 100644 --- a/src/Web/Grand.Web/Features/Models/ShoppingCart/GetShoppingCart.cs +++ b/src/Web/Grand.Web/Features/Models/ShoppingCart/GetShoppingCart.cs @@ -15,6 +15,12 @@ public class GetShoppingCart : IRequest public Language Language { get; set; } public Currency Currency { get; set; } public Domain.Stores.Store Store { get; set; } + + /// + /// Store address of the request, used for the download links of checkout attributes + /// + public DomainHost Host { get; set; } + public TaxDisplayType TaxDisplayType { get; set; } public IList Cart { get; set; } public bool IsEditable { get; set; } = true; From 308d41d46ac4c9ba505094bda7cf0928c44ef07a Mon Sep 17 00:00:00 2001 From: support Date: Fri, 16 Oct 2026 23:12:58 +0200 Subject: [PATCH 4/8] Add GetCurrentCart for the cart of the current request About 40 call sites in controllers, view components, a validator, a Razor partial and the BrainTree plugin repeated GetShoppingCart(contextAccessor.WorkContext.CurrentCustomer, contextAccessor.StoreContext.CurrentStore.Id, ...). The Grand.Web.Common extension GetCurrentCart(contextAccessor, ...) reads both from the request, next to CalculationContext(). Behavior is unchanged. --- .../Controllers/PaymentBrainTreeController.cs | 3 +- .../ShoppingCartServiceExtensionsTests.cs | 35 +++++++++++++++++++ .../ShoppingCartServiceExtensions.cs | 24 +++++++++++++ src/Web/Grand.Web/Components/EmailWishlist.cs | 3 +- .../Grand.Web/Components/EstimateShipping.cs | 3 +- .../Controllers/ActionCartController.cs | 11 +++--- .../Controllers/CheckoutController.cs | 16 ++++----- .../Controllers/ProductController.cs | 2 +- .../Controllers/ShoppingCartController.cs | 34 +++++++++--------- .../Controllers/WishlistController.cs | 7 ++-- .../ShoppingCart/UpdateQuantityValidator.cs | 3 +- .../Shared/Partials/ShoppingCartLinks.cshtml | 4 +-- 12 files changed, 105 insertions(+), 40 deletions(-) create mode 100644 src/Tests/Grand.Web.Common.Tests/Extensions/ShoppingCartServiceExtensionsTests.cs create mode 100644 src/Web/Grand.Web.Common/Extensions/ShoppingCartServiceExtensions.cs diff --git a/src/Plugins/Payments.BrainTree/Controllers/PaymentBrainTreeController.cs b/src/Plugins/Payments.BrainTree/Controllers/PaymentBrainTreeController.cs index 368ad2e18b..d79263934b 100644 --- a/src/Plugins/Payments.BrainTree/Controllers/PaymentBrainTreeController.cs +++ b/src/Plugins/Payments.BrainTree/Controllers/PaymentBrainTreeController.cs @@ -11,6 +11,7 @@ using Payments.BrainTree.Validators; using System.Net; using Environment = Braintree.Environment; +using Grand.Web.Common.Extensions; namespace Payments.BrainTree.Controllers; @@ -41,7 +42,7 @@ public async Task PaymentInfo() if (_brainTreePaymentSettings.Use3DS) { - var cart = await _shoppingCartService.GetShoppingCart(_contextAccessor.WorkContext.CurrentCustomer, _contextAccessor.StoreContext.CurrentStore.Id, + var cart = await _shoppingCartService.GetCurrentCart(_contextAccessor, ShoppingCartType.ShoppingCart); if (!cart.Any()) diff --git a/src/Tests/Grand.Web.Common.Tests/Extensions/ShoppingCartServiceExtensionsTests.cs b/src/Tests/Grand.Web.Common.Tests/Extensions/ShoppingCartServiceExtensionsTests.cs new file mode 100644 index 0000000000..06a310b5a7 --- /dev/null +++ b/src/Tests/Grand.Web.Common.Tests/Extensions/ShoppingCartServiceExtensionsTests.cs @@ -0,0 +1,35 @@ +using Grand.Business.Core.Interfaces.Checkout.Orders; +using Grand.Domain.Customers; +using Grand.Domain.Orders; +using Grand.Domain.Stores; +using Grand.Infrastructure; +using Grand.Web.Common.Extensions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Common.Tests.Extensions; + +[TestClass] +public class ShoppingCartServiceExtensionsTests +{ + [TestMethod] + public async Task GetCurrentCart_ReadsTheCartOfTheRequestCustomerInTheRequestStore() + { + //Arrange + var customer = new Customer(); + var accessor = new Mock(); + accessor.Setup(a => a.WorkContext.CurrentCustomer).Returns(customer); + accessor.Setup(a => a.StoreContext.CurrentStore).Returns(new Store { Id = "s1" }); + IList items = [new ShoppingCartItem()]; + var service = new Mock(); + service.Setup(s => s.GetShoppingCart(customer, "s1", ShoppingCartType.ShoppingCart, ShoppingCartType.Auctions)) + .ReturnsAsync(items); + + //Act + var result = await service.Object.GetCurrentCart(accessor.Object, ShoppingCartType.ShoppingCart, + ShoppingCartType.Auctions); + + //Assert + Assert.AreSame(items, result); + } +} diff --git a/src/Web/Grand.Web.Common/Extensions/ShoppingCartServiceExtensions.cs b/src/Web/Grand.Web.Common/Extensions/ShoppingCartServiceExtensions.cs new file mode 100644 index 0000000000..386291710e --- /dev/null +++ b/src/Web/Grand.Web.Common/Extensions/ShoppingCartServiceExtensions.cs @@ -0,0 +1,24 @@ +using Grand.Business.Core.Interfaces.Checkout.Orders; +using Grand.Domain.Orders; +using Grand.Infrastructure; + +namespace Grand.Web.Common.Extensions; + +/// +/// Reads the shopping cart of the current request +/// +public static class ShoppingCartServiceExtensions +{ + /// + /// Gets the cart items of the current request: its customer in its store + /// + /// Shopping cart service + /// Context of the current request + /// Shopping cart types; pass none to load all records + public static Task> GetCurrentCart(this IShoppingCartService shoppingCartService, + IContextAccessor contextAccessor, params ShoppingCartType[] shoppingCartType) + { + return shoppingCartService.GetShoppingCart(contextAccessor.WorkContext.CurrentCustomer, + contextAccessor.StoreContext.CurrentStore.Id, shoppingCartType); + } +} diff --git a/src/Web/Grand.Web/Components/EmailWishlist.cs b/src/Web/Grand.Web/Components/EmailWishlist.cs index 51c2479324..774a069a7b 100644 --- a/src/Web/Grand.Web/Components/EmailWishlist.cs +++ b/src/Web/Grand.Web/Components/EmailWishlist.cs @@ -7,6 +7,7 @@ using Grand.Web.Common.Components; using Grand.Web.Models.ShoppingCart; using Microsoft.AspNetCore.Mvc; +using Grand.Web.Common.Extensions; namespace Grand.Web.Components; @@ -39,7 +40,7 @@ public async Task InvokeAsync() !_shoppingCartSettings.EmailWishlistEnabled) return Content(""); - var cart = await _shoppingCartService.GetShoppingCart(_contextAccessor.WorkContext.CurrentCustomer, _contextAccessor.StoreContext.CurrentStore.Id, ShoppingCartType.Wishlist); + var cart = await _shoppingCartService.GetCurrentCart(_contextAccessor, ShoppingCartType.Wishlist); if (!cart.Any()) return Content(""); diff --git a/src/Web/Grand.Web/Components/EstimateShipping.cs b/src/Web/Grand.Web/Components/EstimateShipping.cs index 83b59cf1fd..30424a5f66 100644 --- a/src/Web/Grand.Web/Components/EstimateShipping.cs +++ b/src/Web/Grand.Web/Components/EstimateShipping.cs @@ -5,6 +5,7 @@ using Grand.Web.Features.Models.ShoppingCart; using Grand.Mediator; using Microsoft.AspNetCore.Mvc; +using Grand.Web.Common.Extensions; namespace Grand.Web.Components; @@ -24,7 +25,7 @@ public EstimateShippingViewComponent(IMediator mediator, IShoppingCartService sh public async Task InvokeAsync() { - var cart = await _shoppingCartService.GetShoppingCart(_contextAccessor.WorkContext.CurrentCustomer, _contextAccessor.StoreContext.CurrentStore.Id, + var cart = await _shoppingCartService.GetCurrentCart(_contextAccessor, ShoppingCartType.ShoppingCart); var model = await _mediator.Send(new GetEstimateShipping { diff --git a/src/Web/Grand.Web/Controllers/ActionCartController.cs b/src/Web/Grand.Web/Controllers/ActionCartController.cs index f40980bd54..5eeac268e1 100644 --- a/src/Web/Grand.Web/Controllers/ActionCartController.cs +++ b/src/Web/Grand.Web/Controllers/ActionCartController.cs @@ -18,6 +18,7 @@ using Grand.Mediator; using Microsoft.AspNetCore.Mvc; using System.Globalization; +using Grand.Web.Common.Extensions; namespace Grand.Web.Controllers; @@ -182,7 +183,7 @@ public virtual async Task> AddProductCatalog(Pr var warehouseId = GetWarehouse(product); - var cart = await _shoppingCartService.GetShoppingCart(_contextAccessor.WorkContext.CurrentCustomer, _contextAccessor.StoreContext.CurrentStore.Id, model.ShoppingCartTypeId); + var cart = await _shoppingCartService.GetCurrentCart(_contextAccessor, model.ShoppingCartTypeId); if (model.ShoppingCartTypeId != ShoppingCartType.Wishlist) { @@ -255,7 +256,7 @@ await _shoppingCartService.FindShoppingCartItem(cart, model.ShoppingCartTypeId, }); //display notification message and update appropriate blocks - var qty = (await _shoppingCartService.GetShoppingCart(_contextAccessor.WorkContext.CurrentCustomer, _contextAccessor.StoreContext.CurrentStore.Id, + var qty = (await _shoppingCartService.GetCurrentCart(_contextAccessor, ShoppingCartType.Wishlist)).Sum(x => x.Quantity); var updatetopwishlistsectionhtml = string.Format(_translationService.GetResource("Wishlist.HeaderQuantity"), qty); @@ -289,7 +290,7 @@ await _shoppingCartService.FindShoppingCartItem(cart, model.ShoppingCartTypeId, var updatetopcartsectionhtml = string.Format( _translationService.GetResource("ShoppingCart.HeaderQuantity"), - (await _shoppingCartService.GetShoppingCart(_contextAccessor.WorkContext.CurrentCustomer, _contextAccessor.StoreContext.CurrentStore.Id, + (await _shoppingCartService.GetCurrentCart(_contextAccessor, shoppingCartTypes.ToArray())) .Sum(x => x.Quantity)); @@ -451,7 +452,7 @@ public virtual async Task> AddProductDetails(Pr }); //display notification message and update appropriate blocks - var qty = (await _shoppingCartService.GetShoppingCart(_contextAccessor.WorkContext.CurrentCustomer, _contextAccessor.StoreContext.CurrentStore.Id, + var qty = (await _shoppingCartService.GetCurrentCart(_contextAccessor, ShoppingCartType.Wishlist)).Sum(x => x.Quantity); var updatetopwishlistsectionhtml = string.Format(_translationService.GetResource("Wishlist.HeaderQuantity"), qty); @@ -485,7 +486,7 @@ public virtual async Task> AddProductDetails(Pr var updatetopcartsectionhtml = string.Format( _translationService.GetResource("ShoppingCart.HeaderQuantity"), - (await _shoppingCartService.GetShoppingCart(_contextAccessor.WorkContext.CurrentCustomer, _contextAccessor.StoreContext.CurrentStore.Id, + (await _shoppingCartService.GetCurrentCart(_contextAccessor, shoppingCartTypes.ToArray())) .Sum(x => x.Quantity)); diff --git a/src/Web/Grand.Web/Controllers/CheckoutController.cs b/src/Web/Grand.Web/Controllers/CheckoutController.cs index 8b62cb1bdb..72d3d3cca3 100644 --- a/src/Web/Grand.Web/Controllers/CheckoutController.cs +++ b/src/Web/Grand.Web/Controllers/CheckoutController.cs @@ -88,7 +88,7 @@ public virtual async Task Index() { var customer = _contextAccessor.WorkContext.CurrentCustomer; - var cart = await _shoppingCartService.GetShoppingCart(_contextAccessor.WorkContext.CurrentCustomer, _contextAccessor.StoreContext.CurrentStore.Id, + var cart = await _shoppingCartService.GetCurrentCart(_contextAccessor, ShoppingCartType.ShoppingCart, ShoppingCartType.Auctions); if (!cart.Any()) @@ -125,7 +125,7 @@ await _shoppingCartValidator.GetShoppingCartItemWarnings(customer, sci, product, public virtual async Task Start() { //validation - var cart = await _shoppingCartService.GetShoppingCart(_contextAccessor.WorkContext.CurrentCustomer, _contextAccessor.StoreContext.CurrentStore.Id, + var cart = await _shoppingCartService.GetCurrentCart(_contextAccessor, ShoppingCartType.ShoppingCart, ShoppingCartType.Auctions); if (!cart.Any()) @@ -191,7 +191,7 @@ public virtual async Task SaveBilling( try { //validation - var cart = await _shoppingCartService.GetShoppingCart(_contextAccessor.WorkContext.CurrentCustomer, _contextAccessor.StoreContext.CurrentStore.Id, + var cart = await _shoppingCartService.GetCurrentCart(_contextAccessor, ShoppingCartType.ShoppingCart, ShoppingCartType.Auctions); await CartValidate(cart); @@ -289,7 +289,7 @@ public virtual async Task SaveShipping( try { //validation - var cart = await _shoppingCartService.GetShoppingCart(_contextAccessor.WorkContext.CurrentCustomer, _contextAccessor.StoreContext.CurrentStore.Id, + var cart = await _shoppingCartService.GetCurrentCart(_contextAccessor, ShoppingCartType.ShoppingCart, ShoppingCartType.Auctions); await CartValidate(cart); @@ -465,7 +465,7 @@ public virtual async Task SaveShippingMethod(CheckoutShippingMeth var customer = _contextAccessor.WorkContext.CurrentCustomer; var store = _contextAccessor.StoreContext.CurrentStore; - var cart = await _shoppingCartService.GetShoppingCart(_contextAccessor.WorkContext.CurrentCustomer, _contextAccessor.StoreContext.CurrentStore.Id, + var cart = await _shoppingCartService.GetCurrentCart(_contextAccessor, ShoppingCartType.ShoppingCart, ShoppingCartType.Auctions); await CartValidate(cart); @@ -550,7 +550,7 @@ public virtual async Task SavePaymentMethod(CheckoutPaymentMethod try { //validation - var cart = await _shoppingCartService.GetShoppingCart(_contextAccessor.WorkContext.CurrentCustomer, _contextAccessor.StoreContext.CurrentStore.Id, + var cart = await _shoppingCartService.GetCurrentCart(_contextAccessor, ShoppingCartType.ShoppingCart, ShoppingCartType.Auctions); await CartValidate(cart); @@ -620,7 +620,7 @@ public virtual async Task SavePaymentInfo(IDictionary ConfirmOrder() try { //validation - var cart = await _shoppingCartService.GetShoppingCart(_contextAccessor.WorkContext.CurrentCustomer, _contextAccessor.StoreContext.CurrentStore.Id, + var cart = await _shoppingCartService.GetCurrentCart(_contextAccessor, ShoppingCartType.ShoppingCart, ShoppingCartType.Auctions); await CartValidate(cart); diff --git a/src/Web/Grand.Web/Controllers/ProductController.cs b/src/Web/Grand.Web/Controllers/ProductController.cs index e3a19dc491..85791be6e7 100644 --- a/src/Web/Grand.Web/Controllers/ProductController.cs +++ b/src/Web/Grand.Web/Controllers/ProductController.cs @@ -239,7 +239,7 @@ public virtual async Task GetDatesForMonth(string productId, int if (!string.IsNullOrEmpty(parameter)) query = query.Where(x => x.Parameter == parameter); var reservations = query.ToList(); - var inCart = (await _shoppingCartService.GetShoppingCart(_contextAccessor.WorkContext.CurrentCustomer, _contextAccessor.StoreContext.CurrentStore.Id)) + var inCart = (await _shoppingCartService.GetCurrentCart(_contextAccessor)) .Where(x => !string.IsNullOrEmpty(x.ReservationId)).ToList(); foreach (var cartItem in inCart) { diff --git a/src/Web/Grand.Web/Controllers/ShoppingCartController.cs b/src/Web/Grand.Web/Controllers/ShoppingCartController.cs index 0c41bdf028..af38005f2a 100644 --- a/src/Web/Grand.Web/Controllers/ShoppingCartController.cs +++ b/src/Web/Grand.Web/Controllers/ShoppingCartController.cs @@ -127,7 +127,7 @@ public virtual async Task CheckoutAttributeChange(CheckoutAttribu [FromServices] ICheckoutAttributeParser checkoutAttributeParser, [FromServices] ICheckoutAttributeFormatter checkoutAttributeFormatter) { - var cart = await _shoppingCartService.GetShoppingCart(_contextAccessor.WorkContext.CurrentCustomer, _contextAccessor.StoreContext.CurrentStore.Id, + var cart = await _shoppingCartService.GetCurrentCart(_contextAccessor, ShoppingCartType.ShoppingCart, ShoppingCartType.Auctions); var checkoutAttributes = await _mediator.Send(new SaveCheckoutAttributesCommand { @@ -253,7 +253,7 @@ public virtual async Task> Cart(bool checkoutAtt if (!await _permissionService.Authorize(StandardPermission.EnableShoppingCart)) return RedirectToRoute("HomePage"); - var cart = await _shoppingCartService.GetShoppingCart(_contextAccessor.WorkContext.CurrentCustomer, _contextAccessor.StoreContext.CurrentStore.Id, PrepareCartTypes()); + var cart = await _shoppingCartService.GetCurrentCart(_contextAccessor, PrepareCartTypes()); var model = await _mediator.Send(new GetShoppingCart { Cart = cart, ValidateCheckoutAttributes = checkoutAttributes, @@ -271,7 +271,7 @@ public virtual async Task> Cart(bool checkoutAtt [DenySystemAccount] public async Task> CartSummary() { - var cart = await _shoppingCartService.GetShoppingCart(_contextAccessor.WorkContext.CurrentCustomer, _contextAccessor.StoreContext.CurrentStore.Id, + var cart = await _shoppingCartService.GetCurrentCart(_contextAccessor, ShoppingCartType.ShoppingCart, ShoppingCartType.Auctions); var model = await _mediator.Send(new GetShoppingCart { @@ -292,7 +292,7 @@ public async Task> CartSummary() [DenySystemAccount] public async Task> CartTotal() { - var cart = await _shoppingCartService.GetShoppingCart(_contextAccessor.WorkContext.CurrentCustomer, _contextAccessor.StoreContext.CurrentStore.Id, + var cart = await _shoppingCartService.GetCurrentCart(_contextAccessor, ShoppingCartType.ShoppingCart, ShoppingCartType.Auctions); var model = await _mediator.Send(new GetOrderTotals { @@ -317,7 +317,7 @@ public virtual async Task UpdateQuantity(UpdateQuantityModel mode warnings = string.Join(',', ModelState.Values.SelectMany(x => x.Errors.Select(x => x.ErrorMessage))) }); - var cart = (await _shoppingCartService.GetShoppingCart(_contextAccessor.WorkContext.CurrentCustomer, _contextAccessor.StoreContext.CurrentStore.Id, PrepareCartTypes())) + var cart = (await _shoppingCartService.GetCurrentCart(_contextAccessor, PrepareCartTypes())) .FirstOrDefault(x => x.Id == model.ShoppingCartId); var warnings = new List(); @@ -331,7 +331,7 @@ public virtual async Task UpdateQuantity(UpdateQuantityModel mode } var cartModel = await _mediator.Send(new GetShoppingCart { - Cart = await _shoppingCartService.GetShoppingCart(_contextAccessor.WorkContext.CurrentCustomer, _contextAccessor.StoreContext.CurrentStore.Id, PrepareCartTypes()), + Cart = await _shoppingCartService.GetCurrentCart(_contextAccessor, PrepareCartTypes()), Customer = _contextAccessor.WorkContext.CurrentCustomer, Currency = _contextAccessor.WorkContext.WorkingCurrency, Language = _contextAccessor.WorkContext.WorkingLanguage, @@ -356,7 +356,7 @@ public virtual async Task ClearCart() if (!await _permissionService.Authorize(StandardPermission.EnableShoppingCart)) return RedirectToRoute("HomePage"); - var cart = await _shoppingCartService.GetShoppingCart(_contextAccessor.WorkContext.CurrentCustomer, _contextAccessor.StoreContext.CurrentStore.Id, PrepareCartTypes()); + var cart = await _shoppingCartService.GetCurrentCart(_contextAccessor, PrepareCartTypes()); foreach (var sci in cart) await _shoppingCartService.DeleteShoppingCartItem(_contextAccessor.WorkContext.CurrentCustomer, sci, @@ -376,7 +376,7 @@ public virtual async Task DeleteCartItem(DeleteCartItemModel mode if (_shoppingCartSettings.AllowOnHoldCart) shoppingCartTypes.Add(ShoppingCartType.OnHoldCart); - var item = (await _shoppingCartService.GetShoppingCart(_contextAccessor.WorkContext.CurrentCustomer, _contextAccessor.StoreContext.CurrentStore.Id, + var item = (await _shoppingCartService.GetCurrentCart(_contextAccessor, shoppingCartTypes.ToArray())) .FirstOrDefault(sci => sci.Id == model.Id); @@ -398,7 +398,7 @@ await _shoppingCartService.DeleteShoppingCartItem(_contextAccessor.WorkContext.C sidebarshoppingcartmodel = miniShoppingCartmodel }); - var cart = await _shoppingCartService.GetShoppingCart(_contextAccessor.WorkContext.CurrentCustomer, _contextAccessor.StoreContext.CurrentStore.Id, PrepareCartTypes()); + var cart = await _shoppingCartService.GetCurrentCart(_contextAccessor, PrepareCartTypes()); var shoppingcartmodel = await _mediator.Send(new GetShoppingCart { Cart = cart, Customer = _contextAccessor.WorkContext.CurrentCustomer, @@ -431,7 +431,7 @@ public virtual async Task ChangeTypeCartItem(ChangeTypeCartItemMo if (_shoppingCartSettings.AllowOnHoldCart) shoppingCartTypes.Add(ShoppingCartType.OnHoldCart); - var item = (await _shoppingCartService.GetShoppingCart(_contextAccessor.WorkContext.CurrentCustomer, _contextAccessor.StoreContext.CurrentStore.Id, + var item = (await _shoppingCartService.GetCurrentCart(_contextAccessor, shoppingCartTypes.ToArray())) .FirstOrDefault(sci => sci.Id == model.Id); @@ -449,7 +449,7 @@ public virtual async Task ChangeTypeCartItem(ChangeTypeCartItemMo Store = _contextAccessor.StoreContext.CurrentStore }); - var cart = await _shoppingCartService.GetShoppingCart(_contextAccessor.WorkContext.CurrentCustomer, _contextAccessor.StoreContext.CurrentStore.Id, PrepareCartTypes()); + var cart = await _shoppingCartService.GetCurrentCart(_contextAccessor, PrepareCartTypes()); var shoppingcartmodel = await _mediator.Send(new GetShoppingCart { Cart = cart, Customer = _contextAccessor.WorkContext.CurrentCustomer, @@ -477,7 +477,7 @@ public virtual IActionResult ContinueShopping() [DenySystemAccount] public virtual async Task StartCheckout(CheckoutAttributeSelectedModel model) { - var cart = await _shoppingCartService.GetShoppingCart(_contextAccessor.WorkContext.CurrentCustomer, _contextAccessor.StoreContext.CurrentStore.Id, + var cart = await _shoppingCartService.GetCurrentCart(_contextAccessor, ShoppingCartType.ShoppingCart, ShoppingCartType.Auctions); List checkoutAttributes; //parse and save checkout attributes @@ -510,7 +510,7 @@ await _shoppingCartValidator.GetShoppingCartWarnings(cart, checkoutAttributes, [HttpPost] public virtual async Task ApplyDiscountCoupon(DiscountCouponModel model) { - var cart = await _shoppingCartService.GetShoppingCart(_contextAccessor.WorkContext.CurrentCustomer, _contextAccessor.StoreContext.CurrentStore.Id, PrepareCartTypes()); + var cart = await _shoppingCartService.GetCurrentCart(_contextAccessor, PrepareCartTypes()); var message = string.Empty; var isApplied = false; @@ -554,7 +554,7 @@ await _customerService.UpdateUserField(_contextAccessor.WorkContext.CurrentCusto [HttpPost] public virtual async Task ApplyGiftVoucher(GiftVoucherCouponModel model) { - var cart = await _shoppingCartService.GetShoppingCart(_contextAccessor.WorkContext.CurrentCustomer, _contextAccessor.StoreContext.CurrentStore.Id, PrepareCartTypes()); + var cart = await _shoppingCartService.GetCurrentCart(_contextAccessor, PrepareCartTypes()); var message = string.Empty; var isApplied = false; @@ -598,7 +598,7 @@ await _customerService.UpdateUserField(_contextAccessor.WorkContext.CurrentCusto [HttpPost] public virtual async Task GetEstimateShipping(EstimateShippingModel model) { - var cart = await _shoppingCartService.GetShoppingCart(_contextAccessor.WorkContext.CurrentCustomer, _contextAccessor.StoreContext.CurrentStore.Id, + var cart = await _shoppingCartService.GetCurrentCart(_contextAccessor, ShoppingCartType.ShoppingCart, ShoppingCartType.Auctions); var result = await _mediator.Send(new GetEstimateShippingResult { @@ -636,7 +636,7 @@ await _customerService.UpdateUserField(_contextAccessor.WorkContext.CurrentCusto } } - var cart = await _shoppingCartService.GetShoppingCart(_contextAccessor.WorkContext.CurrentCustomer, _contextAccessor.StoreContext.CurrentStore.Id, PrepareCartTypes()); + var cart = await _shoppingCartService.GetCurrentCart(_contextAccessor, PrepareCartTypes()); var model = await _mediator.Send(new GetShoppingCart { Cart = cart, @@ -672,7 +672,7 @@ await _customerService.UpdateUserField(_contextAccessor.WorkContext.CurrentCusto } } - var cart = await _shoppingCartService.GetShoppingCart(_contextAccessor.WorkContext.CurrentCustomer, _contextAccessor.StoreContext.CurrentStore.Id, PrepareCartTypes()); + var cart = await _shoppingCartService.GetCurrentCart(_contextAccessor, PrepareCartTypes()); var model = await _mediator.Send(new GetShoppingCart { Cart = cart, diff --git a/src/Web/Grand.Web/Controllers/WishlistController.cs b/src/Web/Grand.Web/Controllers/WishlistController.cs index c6adf55a12..7f81eeb39f 100644 --- a/src/Web/Grand.Web/Controllers/WishlistController.cs +++ b/src/Web/Grand.Web/Controllers/WishlistController.cs @@ -16,6 +16,7 @@ using Grand.Web.Models.ShoppingCart; using Grand.Mediator; using Microsoft.AspNetCore.Mvc; +using Grand.Web.Common.Extensions; namespace Grand.Web.Controllers; @@ -119,7 +120,7 @@ public virtual async Task UpdateQuantity(UpdateQuantityModel mode if (ModelState.IsValid) { var cart = - (await _shoppingCartService.GetShoppingCart(_contextAccessor.WorkContext.CurrentCustomer, _contextAccessor.StoreContext.CurrentStore.Id, ShoppingCartType.Wishlist)) + (await _shoppingCartService.GetCurrentCart(_contextAccessor, ShoppingCartType.Wishlist)) .FirstOrDefault(x => x.Id == model.ShoppingCartId); if (cart != null) { @@ -141,7 +142,7 @@ public virtual async Task UpdateQuantity(UpdateQuantityModel mode success = !warnings.Any(), warnings = string.Join(", ", warnings), totalproducts = - (await _shoppingCartService.GetShoppingCart(_contextAccessor.WorkContext.CurrentCustomer, _contextAccessor.StoreContext.CurrentStore.Id, ShoppingCartType.Wishlist)) + (await _shoppingCartService.GetCurrentCart(_contextAccessor, ShoppingCartType.Wishlist)) .Sum(x => x.Quantity) }); } @@ -216,7 +217,7 @@ public virtual async Task EmailWishlist(WishlistEmailAFriendModel !_shoppingCartSettings.EmailWishlistEnabled) return Content(""); - var cart = await _shoppingCartService.GetShoppingCart(_contextAccessor.WorkContext.CurrentCustomer, _contextAccessor.StoreContext.CurrentStore.Id, ShoppingCartType.Wishlist); + var cart = await _shoppingCartService.GetCurrentCart(_contextAccessor, ShoppingCartType.Wishlist); if (!cart.Any()) return Content(""); diff --git a/src/Web/Grand.Web/Validators/ShoppingCart/UpdateQuantityValidator.cs b/src/Web/Grand.Web/Validators/ShoppingCart/UpdateQuantityValidator.cs index 8fc61cc9f3..ad136fc14e 100644 --- a/src/Web/Grand.Web/Validators/ShoppingCart/UpdateQuantityValidator.cs +++ b/src/Web/Grand.Web/Validators/ShoppingCart/UpdateQuantityValidator.cs @@ -6,6 +6,7 @@ using Grand.Infrastructure; using Grand.Infrastructure.Validators; using Grand.Web.Models.ShoppingCart; +using Grand.Web.Common.Extensions; namespace Grand.Web.Validators.ShoppingCart; @@ -36,7 +37,7 @@ public UpdateQuantityValidator( } } - var cart = (await shoppingCartService.GetShoppingCart(contextAccessor.WorkContext.CurrentCustomer, contextAccessor.StoreContext.CurrentStore.Id, PrepareCartTypes())) + var cart = (await shoppingCartService.GetCurrentCart(contextAccessor, PrepareCartTypes())) .FirstOrDefault(z => z.Id == x.ShoppingCartId); if (cart == null) context.AddFailure("Shopping cart item not found"); }); diff --git a/src/Web/Grand.Web/Views/Shared/Partials/ShoppingCartLinks.cshtml b/src/Web/Grand.Web/Views/Shared/Partials/ShoppingCartLinks.cshtml index 68e2824aba..1e4a885dce 100644 --- a/src/Web/Grand.Web/Views/Shared/Partials/ShoppingCartLinks.cshtml +++ b/src/Web/Grand.Web/Views/Shared/Partials/ShoppingCartLinks.cshtml @@ -21,13 +21,13 @@ var shoppingCartItems = contextAccessor.WorkContext.CurrentCustomer.ShoppingCartItems.Any() - ? (await shoppingCartService.GetShoppingCart(contextAccessor.WorkContext.CurrentCustomer, contextAccessor.StoreContext.CurrentStore.Id, shoppingCartTypes.ToArray())) + ? (await shoppingCartService.GetCurrentCart(contextAccessor, shoppingCartTypes.ToArray())) .Sum(x => x.Quantity) : 0; var wishlistItems = contextAccessor.WorkContext.CurrentCustomer.ShoppingCartItems.Any() - ? (await shoppingCartService.GetShoppingCart(contextAccessor.WorkContext.CurrentCustomer, contextAccessor.StoreContext.CurrentStore.Id, ShoppingCartType.Wishlist)) + ? (await shoppingCartService.GetCurrentCart(contextAccessor, ShoppingCartType.Wishlist)) .Sum(x => x.Quantity) : 0; } From 654bfedb435fa2f24d965f2648f34d055c879c97 Mon Sep 17 00:00:00 2001 From: support Date: Sat, 17 Oct 2026 06:30:58 +0200 Subject: [PATCH 5/8] Reject a missing customer, store or host instead of skipping its checks LoadActiveAuthenticationProviders and AuthenticationProviderIsAvailable treated a null customer or store as allowed by every group and store limit. GetShoppingCartHandler formatted checkout attribute download links with a host nothing required. All three now throw on null, as Authenticate already does. --- .../Services/ExternalAuthenticationService.cs | 6 ++++++ .../ExternalAuthenticationServiceTests.cs | 20 +++++++++++++++++++ .../ShoppingCart/GetShoppingCartHandler.cs | 4 ++++ 3 files changed, 30 insertions(+) diff --git a/src/Business/Grand.Business.Authentication/Services/ExternalAuthenticationService.cs b/src/Business/Grand.Business.Authentication/Services/ExternalAuthenticationService.cs index 80a0769cb1..e1d42be685 100644 --- a/src/Business/Grand.Business.Authentication/Services/ExternalAuthenticationService.cs +++ b/src/Business/Grand.Business.Authentication/Services/ExternalAuthenticationService.cs @@ -209,6 +209,9 @@ protected virtual IActionResult Error(IEnumerable errors) public virtual IList LoadActiveAuthenticationProviders(Customer customer, Store store) { + ArgumentNullException.ThrowIfNull(customer); + ArgumentNullException.ThrowIfNull(store); + return LoadAllAuthenticationProviders() .Where(provider => provider.IsMethodActive(_externalAuthenticationSettings) && @@ -246,6 +249,9 @@ public virtual IList LoadAllAuthenticationProvi /// True if is available; otherwise false public virtual bool AuthenticationProviderIsAvailable(string systemName, Customer customer, Store store) { + ArgumentNullException.ThrowIfNull(customer); + ArgumentNullException.ThrowIfNull(store); + var authenticationMethod = LoadAuthenticationProviderBySystemName(systemName); return authenticationMethod != null && diff --git a/src/Tests/Grand.Business.Authentication.Tests/Services/ExternalAuthenticationServiceTests.cs b/src/Tests/Grand.Business.Authentication.Tests/Services/ExternalAuthenticationServiceTests.cs index baf50fbd00..065e400a0f 100644 --- a/src/Tests/Grand.Business.Authentication.Tests/Services/ExternalAuthenticationServiceTests.cs +++ b/src/Tests/Grand.Business.Authentication.Tests/Services/ExternalAuthenticationServiceTests.cs @@ -102,6 +102,26 @@ public void AuthenticationProviderIsAvailableTest_NotAvailable() Assert.IsFalse(result); } + [TestMethod] + public void LoadActiveAuthenticationProviders_NullCustomerOrStore_Throws() + { + Assert.ThrowsExactly(() => + _externalAuthenticationService.LoadActiveAuthenticationProviders(null, new Store())); + Assert.ThrowsExactly(() => + _externalAuthenticationService.LoadActiveAuthenticationProviders(new Customer(), null)); + } + + [TestMethod] + public void AuthenticationProviderIsAvailable_NullCustomerOrStore_Throws() + { + Assert.ThrowsExactly(() => + _externalAuthenticationService.AuthenticationProviderIsAvailable("ExternalAuthenticationProviderTest", + null, new Store())); + Assert.ThrowsExactly(() => + _externalAuthenticationService.AuthenticationProviderIsAvailable("ExternalAuthenticationProviderTest", + new Customer(), null)); + } + [TestMethod] public async Task Authenticate_AssociatedUserFromGuest_PublishesLoggedInEventWithPassedCustomerAndStore() { diff --git a/src/Web/Grand.Web/Features/Handlers/ShoppingCart/GetShoppingCartHandler.cs b/src/Web/Grand.Web/Features/Handlers/ShoppingCart/GetShoppingCartHandler.cs index 8e97535a65..82712141ea 100644 --- a/src/Web/Grand.Web/Features/Handlers/ShoppingCart/GetShoppingCartHandler.cs +++ b/src/Web/Grand.Web/Features/Handlers/ShoppingCart/GetShoppingCartHandler.cs @@ -128,6 +128,10 @@ public GetShoppingCartHandler( public async Task Handle(GetShoppingCart request, CancellationToken cancellationToken) { + ArgumentNullException.ThrowIfNull(request.Customer); + ArgumentNullException.ThrowIfNull(request.Store); + ArgumentNullException.ThrowIfNull(request.Host); + var model = new ShoppingCartModel(); if (!request.Cart.Any()) From 512805dc82650ddb900c1979914dee2c4bb04516 Mon Sep 17 00:00:00 2001 From: support Date: Sat, 17 Oct 2026 06:30:59 +0200 Subject: [PATCH 6/8] Build the GetShoppingCart request in one place The nine senders in ShoppingCartController repeated the customer, currency, language, tax display, store and host lines; ShoppingCartRequest fills them once. --- .../Controllers/ShoppingCartController.cs | 109 +++++------------- 1 file changed, 26 insertions(+), 83 deletions(-) diff --git a/src/Web/Grand.Web/Controllers/ShoppingCartController.cs b/src/Web/Grand.Web/Controllers/ShoppingCartController.cs index af38005f2a..775b737b8a 100644 --- a/src/Web/Grand.Web/Controllers/ShoppingCartController.cs +++ b/src/Web/Grand.Web/Controllers/ShoppingCartController.cs @@ -68,6 +68,22 @@ public ShoppingCartController( #region Utilities + private GetShoppingCart ShoppingCartRequest(IList cart, bool isEditable = true, + bool validateCheckoutAttributes = false) + { + return new GetShoppingCart { + Cart = cart, + IsEditable = isEditable, + ValidateCheckoutAttributes = validateCheckoutAttributes, + Customer = _contextAccessor.WorkContext.CurrentCustomer, + Currency = _contextAccessor.WorkContext.WorkingCurrency, + Language = _contextAccessor.WorkContext.WorkingLanguage, + TaxDisplayType = _contextAccessor.WorkContext.TaxDisplayType, + Store = _contextAccessor.StoreContext.CurrentStore, + Host = _contextAccessor.StoreContext.CurrentHost + }; + } + private ShoppingCartType[] PrepareCartTypes() { var shoppingCartTypes = new List { @@ -254,16 +270,7 @@ public virtual async Task> Cart(bool checkoutAtt return RedirectToRoute("HomePage"); var cart = await _shoppingCartService.GetCurrentCart(_contextAccessor, PrepareCartTypes()); - var model = await _mediator.Send(new GetShoppingCart { - Cart = cart, - ValidateCheckoutAttributes = checkoutAttributes, - Customer = _contextAccessor.WorkContext.CurrentCustomer, - Currency = _contextAccessor.WorkContext.WorkingCurrency, - Language = _contextAccessor.WorkContext.WorkingLanguage, - TaxDisplayType = _contextAccessor.WorkContext.TaxDisplayType, - Store = _contextAccessor.StoreContext.CurrentStore, - Host = _contextAccessor.StoreContext.CurrentHost - }); + var model = await _mediator.Send(ShoppingCartRequest(cart, validateCheckoutAttributes: checkoutAttributes)); return View(model); } @@ -274,16 +281,7 @@ public async Task> CartSummary() var cart = await _shoppingCartService.GetCurrentCart(_contextAccessor, ShoppingCartType.ShoppingCart, ShoppingCartType.Auctions); - var model = await _mediator.Send(new GetShoppingCart { - Cart = cart, - IsEditable = false, - Customer = _contextAccessor.WorkContext.CurrentCustomer, - Currency = _contextAccessor.WorkContext.WorkingCurrency, - Language = _contextAccessor.WorkContext.WorkingLanguage, - Store = _contextAccessor.StoreContext.CurrentStore, - Host = _contextAccessor.StoreContext.CurrentHost, - TaxDisplayType = _contextAccessor.WorkContext.TaxDisplayType - }); + var model = await _mediator.Send(ShoppingCartRequest(cart, isEditable: false)); return Json(model); } @@ -330,15 +328,8 @@ public virtual async Task UpdateQuantity(UpdateQuantityModel mode warnings.AddRange(currSciWarnings); } - var cartModel = await _mediator.Send(new GetShoppingCart { - Cart = await _shoppingCartService.GetCurrentCart(_contextAccessor, PrepareCartTypes()), - Customer = _contextAccessor.WorkContext.CurrentCustomer, - Currency = _contextAccessor.WorkContext.WorkingCurrency, - Language = _contextAccessor.WorkContext.WorkingLanguage, - Store = _contextAccessor.StoreContext.CurrentStore, - Host = _contextAccessor.StoreContext.CurrentHost, - TaxDisplayType = _contextAccessor.WorkContext.TaxDisplayType - }); + var updatedCart = await _shoppingCartService.GetCurrentCart(_contextAccessor, PrepareCartTypes()); + var cartModel = await _mediator.Send(ShoppingCartRequest(updatedCart)); return Json(new { success = !warnings.Any(), @@ -399,15 +390,7 @@ await _shoppingCartService.DeleteShoppingCartItem(_contextAccessor.WorkContext.C }); var cart = await _shoppingCartService.GetCurrentCart(_contextAccessor, PrepareCartTypes()); - var shoppingcartmodel = await _mediator.Send(new GetShoppingCart { - Cart = cart, - Customer = _contextAccessor.WorkContext.CurrentCustomer, - Currency = _contextAccessor.WorkContext.WorkingCurrency, - Language = _contextAccessor.WorkContext.WorkingLanguage, - Store = _contextAccessor.StoreContext.CurrentStore, - Host = _contextAccessor.StoreContext.CurrentHost, - TaxDisplayType = _contextAccessor.WorkContext.TaxDisplayType - }); + var shoppingcartmodel = await _mediator.Send(ShoppingCartRequest(cart)); return Json(new { totalproducts = string.Format(_translationService.GetResource("ShoppingCart.HeaderQuantity"), @@ -450,15 +433,7 @@ public virtual async Task ChangeTypeCartItem(ChangeTypeCartItemMo }); var cart = await _shoppingCartService.GetCurrentCart(_contextAccessor, PrepareCartTypes()); - var shoppingcartmodel = await _mediator.Send(new GetShoppingCart { - Cart = cart, - Customer = _contextAccessor.WorkContext.CurrentCustomer, - Currency = _contextAccessor.WorkContext.WorkingCurrency, - Language = _contextAccessor.WorkContext.WorkingLanguage, - Store = _contextAccessor.StoreContext.CurrentStore, - Host = _contextAccessor.StoreContext.CurrentHost, - TaxDisplayType = _contextAccessor.WorkContext.TaxDisplayType - }); + var shoppingcartmodel = await _mediator.Send(ShoppingCartRequest(cart)); return Json(new { model = shoppingcartmodel, @@ -532,15 +507,7 @@ await _customerService.UpdateUserField(_contextAccessor.WorkContext.CurrentCusto message = string.Join(',', ModelState.Values.SelectMany(x => x.Errors.Select(x => x.ErrorMessage))); } - var cartModel = await _mediator.Send(new GetShoppingCart { - Cart = cart, - Customer = _contextAccessor.WorkContext.CurrentCustomer, - Currency = _contextAccessor.WorkContext.WorkingCurrency, - Language = _contextAccessor.WorkContext.WorkingLanguage, - Store = _contextAccessor.StoreContext.CurrentStore, - Host = _contextAccessor.StoreContext.CurrentHost, - TaxDisplayType = _contextAccessor.WorkContext.TaxDisplayType - }); + var cartModel = await _mediator.Send(ShoppingCartRequest(cart)); cartModel.DiscountBox.Message = message; cartModel.DiscountBox.IsApplied = isApplied; @@ -577,15 +544,7 @@ await _customerService.UpdateUserField(_contextAccessor.WorkContext.CurrentCusto message = string.Join(',', ModelState.Values.SelectMany(x => x.Errors.Select(x => x.ErrorMessage))); } - var cartModel = await _mediator.Send(new GetShoppingCart { - Cart = cart, - Customer = _contextAccessor.WorkContext.CurrentCustomer, - Currency = _contextAccessor.WorkContext.WorkingCurrency, - Language = _contextAccessor.WorkContext.WorkingLanguage, - Store = _contextAccessor.StoreContext.CurrentStore, - Host = _contextAccessor.StoreContext.CurrentHost, - TaxDisplayType = _contextAccessor.WorkContext.TaxDisplayType - }); + var cartModel = await _mediator.Send(ShoppingCartRequest(cart)); cartModel.GiftVoucherBox.Message = message; cartModel.GiftVoucherBox.IsApplied = isApplied; @@ -638,15 +597,7 @@ await _customerService.UpdateUserField(_contextAccessor.WorkContext.CurrentCusto var cart = await _shoppingCartService.GetCurrentCart(_contextAccessor, PrepareCartTypes()); - var model = await _mediator.Send(new GetShoppingCart { - Cart = cart, - Customer = _contextAccessor.WorkContext.CurrentCustomer, - Currency = _contextAccessor.WorkContext.WorkingCurrency, - Language = _contextAccessor.WorkContext.WorkingLanguage, - Store = _contextAccessor.StoreContext.CurrentStore, - Host = _contextAccessor.StoreContext.CurrentHost, - TaxDisplayType = _contextAccessor.WorkContext.TaxDisplayType - }); + var model = await _mediator.Send(ShoppingCartRequest(cart)); return Json(new { model @@ -674,15 +625,7 @@ await _customerService.UpdateUserField(_contextAccessor.WorkContext.CurrentCusto var cart = await _shoppingCartService.GetCurrentCart(_contextAccessor, PrepareCartTypes()); - var model = await _mediator.Send(new GetShoppingCart { - Cart = cart, - Customer = _contextAccessor.WorkContext.CurrentCustomer, - Currency = _contextAccessor.WorkContext.WorkingCurrency, - Language = _contextAccessor.WorkContext.WorkingLanguage, - Store = _contextAccessor.StoreContext.CurrentStore, - Host = _contextAccessor.StoreContext.CurrentHost, - TaxDisplayType = _contextAccessor.WorkContext.TaxDisplayType - }); + var model = await _mediator.Send(ShoppingCartRequest(cart)); return Json(new { model From 1f9bd2e71b3b58d268b9c228cafa71a3da38741e Mon Sep 17 00:00:00 2001 From: support Date: Sat, 17 Oct 2026 06:30:59 +0200 Subject: [PATCH 7/8] Load the wishlist and header counts through the shopping cart service SidebarWishlist and Index filtered the wishlist by hand, keeping items of deleted products that every other wishlist read drops. The header partial loaded the cart twice to count cart and wishlist items; it loads both types once and sums them in memory. --- .../Grand.Web/Controllers/WishlistController.cs | 16 +++++----------- .../Shared/Partials/ShoppingCartLinks.cshtml | 15 +++++---------- 2 files changed, 10 insertions(+), 21 deletions(-) diff --git a/src/Web/Grand.Web/Controllers/WishlistController.cs b/src/Web/Grand.Web/Controllers/WishlistController.cs index 7f81eeb39f..199bc649e7 100644 --- a/src/Web/Grand.Web/Controllers/WishlistController.cs +++ b/src/Web/Grand.Web/Controllers/WishlistController.cs @@ -65,14 +65,10 @@ public async Task> SidebarWishlist() if (!await _permissionService.Authorize(StandardPermission.EnableWishlist)) return Content(""); - var cart = _contextAccessor.WorkContext.CurrentCustomer.ShoppingCartItems.Where(sci => - sci.ShoppingCartTypeId == ShoppingCartType.Wishlist); - - if (!string.IsNullOrEmpty(_contextAccessor.StoreContext.CurrentStore.Id)) - cart = cart.LimitPerStore(_shoppingCartSettings.SharedCartBetweenStores, _contextAccessor.StoreContext.CurrentStore.Id); + var cart = await _shoppingCartService.GetCurrentCart(_contextAccessor, ShoppingCartType.Wishlist); var model = await _mediator.Send(new GetMiniWishlist { - Cart = cart.ToList(), + Cart = cart, Customer = _contextAccessor.WorkContext.CurrentCustomer, Language = _contextAccessor.WorkContext.WorkingLanguage, Currency = _contextAccessor.WorkContext.WorkingCurrency, @@ -94,13 +90,11 @@ public virtual async Task> Index(Guid? customerGuid) if (customer == null) return RedirectToRoute("HomePage"); - var cart = customer.ShoppingCartItems.Where(sci => sci.ShoppingCartTypeId == ShoppingCartType.Wishlist); - - if (!string.IsNullOrEmpty(_contextAccessor.StoreContext.CurrentStore.Id)) - cart = cart.LimitPerStore(_shoppingCartSettings.SharedCartBetweenStores, _contextAccessor.StoreContext.CurrentStore.Id); + var cart = await _shoppingCartService.GetShoppingCart(customer, _contextAccessor.StoreContext.CurrentStore.Id, + ShoppingCartType.Wishlist); var model = await _mediator.Send(new GetWishlist { - Cart = cart.ToList(), + Cart = cart, Customer = customer, Language = _contextAccessor.WorkContext.WorkingLanguage, Currency = _contextAccessor.WorkContext.WorkingCurrency, diff --git a/src/Web/Grand.Web/Views/Shared/Partials/ShoppingCartLinks.cshtml b/src/Web/Grand.Web/Views/Shared/Partials/ShoppingCartLinks.cshtml index 1e4a885dce..54b99572b6 100644 --- a/src/Web/Grand.Web/Views/Shared/Partials/ShoppingCartLinks.cshtml +++ b/src/Web/Grand.Web/Views/Shared/Partials/ShoppingCartLinks.cshtml @@ -19,17 +19,12 @@ var wishlistEnabled = await permissionService.Authorize(StandardPermission.EnableWishlist); var miniShoppingCartEnabled = shoppingCartSettings.MiniShoppingCartEnabled; - var shoppingCartItems = - contextAccessor.WorkContext.CurrentCustomer.ShoppingCartItems.Any() - ? (await shoppingCartService.GetCurrentCart(contextAccessor, shoppingCartTypes.ToArray())) - .Sum(x => x.Quantity) - : 0; + var cart = contextAccessor.WorkContext.CurrentCustomer.ShoppingCartItems.Any() + ? await shoppingCartService.GetCurrentCart(contextAccessor, [.. shoppingCartTypes, ShoppingCartType.Wishlist]) + : []; - - var wishlistItems = contextAccessor.WorkContext.CurrentCustomer.ShoppingCartItems.Any() - ? (await shoppingCartService.GetCurrentCart(contextAccessor, ShoppingCartType.Wishlist)) - .Sum(x => x.Quantity) - : 0; + var shoppingCartItems = cart.Where(x => shoppingCartTypes.Contains(x.ShoppingCartTypeId)).Sum(x => x.Quantity); + var wishlistItems = cart.Where(x => x.ShoppingCartTypeId == ShoppingCartType.Wishlist).Sum(x => x.Quantity); } @if (catalogSettings.CompareProductsEnabled) { From 80f73506761eb7167aa368baa40f3d7465b1afa1 Mon Sep 17 00:00:00 2001 From: support Date: Sat, 17 Oct 2026 06:31:00 +0200 Subject: [PATCH 8/8] Check the permission of the cart item being updated UpdateQuantityValidator picked the permission from the cart type in the request, not from the item it found. It now checks the wishlist or shopping cart permission of the item itself. --- .../UpdateQuantityValidatorTests.cs | 92 +++++++++++++++++++ .../ShoppingCart/UpdateQuantityValidator.cs | 27 +++--- 2 files changed, 103 insertions(+), 16 deletions(-) create mode 100644 src/Tests/Grand.Web.Tests/Validators/ShoppingCart/UpdateQuantityValidatorTests.cs diff --git a/src/Tests/Grand.Web.Tests/Validators/ShoppingCart/UpdateQuantityValidatorTests.cs b/src/Tests/Grand.Web.Tests/Validators/ShoppingCart/UpdateQuantityValidatorTests.cs new file mode 100644 index 0000000000..488c2a4b82 --- /dev/null +++ b/src/Tests/Grand.Web.Tests/Validators/ShoppingCart/UpdateQuantityValidatorTests.cs @@ -0,0 +1,92 @@ +using Grand.Business.Core.Interfaces.Checkout.Orders; +using Grand.Business.Core.Interfaces.Common.Security; +using Grand.Domain.Customers; +using Grand.Domain.Orders; +using Grand.Domain.Permissions; +using Grand.Infrastructure; +using Grand.Infrastructure.Validators; +using Grand.Web.Models.ShoppingCart; +using Grand.Web.Validators.ShoppingCart; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Tests.Validators.ShoppingCart; + +[TestClass] +public class UpdateQuantityValidatorTests +{ + private static UpdateQuantityValidator Validator(ShoppingCartItem item, bool cartEnabled, bool wishlistEnabled) + { + var permissionService = new Mock(); + permissionService.Setup(p => p.Authorize(It.Is(x => + x.SystemName == StandardPermission.EnableShoppingCart.SystemName))) + .ReturnsAsync(cartEnabled); + permissionService.Setup(p => p.Authorize(It.Is(x => + x.SystemName == StandardPermission.EnableWishlist.SystemName))) + .ReturnsAsync(wishlistEnabled); + + var shoppingCartService = new Mock(); + shoppingCartService.Setup(s => s.GetShoppingCart(It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync([item]); + + var work = new Mock(); + work.Setup(w => w.CurrentCustomer).Returns(new Customer()); + var store = new Mock(); + store.Setup(s => s.CurrentStore).Returns(new Domain.Stores.Store { Id = "s1" }); + var context = new Mock(); + context.Setup(c => c.WorkContext).Returns(work.Object); + context.Setup(c => c.StoreContext).Returns(store.Object); + + return new UpdateQuantityValidator(new List>(), + permissionService.Object, new ShoppingCartSettings(), shoppingCartService.Object, context.Object); + } + + [TestMethod] + public async Task CartItemDeclaredAsWishlist_WithoutCartPermission_IsRejected() + { + var item = new ShoppingCartItem { Id = "i1", ShoppingCartTypeId = ShoppingCartType.ShoppingCart }; + + var result = await Validator(item, false, true).ValidateAsync(new UpdateQuantityModel { + ShoppingCartId = "i1", Quantity = 5, ShoppingCartType = ShoppingCartType.Wishlist + }); + + Assert.IsFalse(result.IsValid); + } + + [TestMethod] + public async Task OnHoldItemDeclaredAsCart_WithCartPermission_IsValid() + { + var item = new ShoppingCartItem { Id = "i1", ShoppingCartTypeId = ShoppingCartType.OnHoldCart }; + + var result = await Validator(item, true, false).ValidateAsync(new UpdateQuantityModel { + ShoppingCartId = "i1", Quantity = 5, ShoppingCartType = ShoppingCartType.ShoppingCart + }); + + Assert.IsTrue(result.IsValid); + } + + [TestMethod] + public async Task WishlistItem_WithoutWishlistPermission_IsRejected() + { + var item = new ShoppingCartItem { Id = "i1", ShoppingCartTypeId = ShoppingCartType.Wishlist }; + + var result = await Validator(item, true, false).ValidateAsync(new UpdateQuantityModel { + ShoppingCartId = "i1", Quantity = 5, ShoppingCartType = ShoppingCartType.Wishlist + }); + + Assert.IsFalse(result.IsValid); + } + + [TestMethod] + public async Task UnknownItem_IsRejected() + { + var item = new ShoppingCartItem { Id = "i1", ShoppingCartTypeId = ShoppingCartType.ShoppingCart }; + + var result = await Validator(item, true, true).ValidateAsync(new UpdateQuantityModel { + ShoppingCartId = "other", Quantity = 5, ShoppingCartType = ShoppingCartType.ShoppingCart + }); + + Assert.IsFalse(result.IsValid); + } +} diff --git a/src/Web/Grand.Web/Validators/ShoppingCart/UpdateQuantityValidator.cs b/src/Web/Grand.Web/Validators/ShoppingCart/UpdateQuantityValidator.cs index ad136fc14e..1f62d25a34 100644 --- a/src/Web/Grand.Web/Validators/ShoppingCart/UpdateQuantityValidator.cs +++ b/src/Web/Grand.Web/Validators/ShoppingCart/UpdateQuantityValidator.cs @@ -21,25 +21,20 @@ public UpdateQuantityValidator( RuleFor(x => x.Quantity).GreaterThan(0).WithMessage("Wrong quantity"); RuleFor(x => x).CustomAsync(async (x, context, _) => { - switch (x.ShoppingCartType) + var cart = (await shoppingCartService.GetCurrentCart(contextAccessor, PrepareCartTypes())) + .FirstOrDefault(z => z.Id == x.ShoppingCartId); + if (cart == null) { - case ShoppingCartType.ShoppingCart: - { - if (!await permissionService.Authorize(StandardPermission.EnableShoppingCart)) - context.AddFailure("No permission"); - break; - } - case ShoppingCartType.Wishlist: - { - if (!await permissionService.Authorize(StandardPermission.EnableWishlist)) - context.AddFailure("No permission"); - break; - } + context.AddFailure("Shopping cart item not found"); + return; } - var cart = (await shoppingCartService.GetCurrentCart(contextAccessor, PrepareCartTypes())) - .FirstOrDefault(z => z.Id == x.ShoppingCartId); - if (cart == null) context.AddFailure("Shopping cart item not found"); + //check the permission of the item's own type; the type sent by the client is not trusted + var permission = cart.ShoppingCartTypeId == ShoppingCartType.Wishlist + ? StandardPermission.EnableWishlist + : StandardPermission.EnableShoppingCart; + if (!await permissionService.Authorize(permission)) + context.AddFailure("No permission"); }); ShoppingCartType[] PrepareCartTypes()