diff --git a/src/Business/Grand.Business.Core/Interfaces/Customers/ICustomerService.cs b/src/Business/Grand.Business.Core/Interfaces/Customers/ICustomerService.cs
index 7f64d4c29..2106a6890 100644
--- a/src/Business/Grand.Business.Core/Interfaces/Customers/ICustomerService.cs
+++ b/src/Business/Grand.Business.Core/Interfaces/Customers/ICustomerService.cs
@@ -43,6 +43,8 @@ public interface ICustomerService
/// Page index
/// Page size
///
+ /// Customers in any of these groups are left out
+ /// Leaves out accounts tied to a vendor or a staff store
/// Customers
Task> GetAllCustomers(DateTime? createdFromUtc = null,
DateTime? createdToUtc = null, string affiliateId = "", string vendorId = "", string storeId = "",
@@ -52,7 +54,8 @@ Task> GetAllCustomers(DateTime? createdFromUtc = null,
string firstName = null, string lastName = null,
string company = null, string phone = null, string zipPostalCode = null,
bool loadOnlyWithShoppingCart = false, ShoppingCartType? sct = null,
- int pageIndex = 0, int pageSize = int.MaxValue, Expression> orderBySelector = null);
+ int pageIndex = 0, int pageSize = int.MaxValue, Expression> orderBySelector = null,
+ string[] excludedCustomerGroupIds = null, bool excludeVendorAndStaffAccounts = false);
///
diff --git a/src/Business/Grand.Business.Core/Queries/Customers/GetCustomerQuery.cs b/src/Business/Grand.Business.Core/Queries/Customers/GetCustomerQuery.cs
index 0d4cbd104..086ae33aa 100644
--- a/src/Business/Grand.Business.Core/Queries/Customers/GetCustomerQuery.cs
+++ b/src/Business/Grand.Business.Core/Queries/Customers/GetCustomerQuery.cs
@@ -18,6 +18,13 @@ public class GetCustomerQuery : IRequest>
public string SalesEmployeeId { get; set; } = "";
public string[] CustomerGroupIds { get; set; } = null;
public string[] CustomerTagIds { get; set; } = null;
+
+ /// Customers in any of these groups are left out
+ public string[] ExcludedCustomerGroupIds { get; set; } = null;
+
+ /// Leaves out accounts tied to a vendor or a staff store
+ public bool ExcludeVendorAndStaffAccounts { get; set; } = false;
+
public string Email { get; set; } = null;
public string Username { get; set; } = null;
public string FirstName { get; set; } = null;
diff --git a/src/Business/Grand.Business.Customers/Queries/Handlers/GetCustomerQueryHandler.cs b/src/Business/Grand.Business.Customers/Queries/Handlers/GetCustomerQueryHandler.cs
index c59cabdc8..6502b610c 100644
--- a/src/Business/Grand.Business.Customers/Queries/Handlers/GetCustomerQueryHandler.cs
+++ b/src/Business/Grand.Business.Customers/Queries/Handlers/GetCustomerQueryHandler.cs
@@ -39,6 +39,11 @@ public async Task> Handle(GetCustomerQuery request, Cancell
query = query.Where(c => !c.Deleted);
if (request.CustomerGroupIds is { Length: > 0 })
query = query.Where(c => c.Groups.Any(x => request.CustomerGroupIds.Contains(x)));
+ if (request.ExcludedCustomerGroupIds is { Length: > 0 })
+ query = query.Where(c => !c.Groups.Any(x => request.ExcludedCustomerGroupIds.Contains(x)));
+ if (request.ExcludeVendorAndStaffAccounts)
+ query = query.Where(c => (c.VendorId == null || c.VendorId == "") &&
+ (c.StaffStoreId == null || c.StaffStoreId == ""));
if (request.CustomerTagIds is { Length: > 0 })
foreach (var item in request.CustomerTagIds)
query = query.Where(c => c.CustomerTags.Contains(item));
diff --git a/src/Business/Grand.Business.Customers/Services/CustomerService.cs b/src/Business/Grand.Business.Customers/Services/CustomerService.cs
index da23c7bd0..6fe3747eb 100644
--- a/src/Business/Grand.Business.Customers/Services/CustomerService.cs
+++ b/src/Business/Grand.Business.Customers/Services/CustomerService.cs
@@ -79,6 +79,8 @@ public CustomerService(
/// Page index
/// Page size
/// order by selector
+ /// Customers in any of these groups are left out
+ /// Leaves out accounts tied to a vendor or a staff store
/// Customers
public virtual async Task> GetAllCustomers(DateTime? createdFromUtc = null,
DateTime? createdToUtc = null, string affiliateId = "", string vendorId = "", string storeId = "",
@@ -88,7 +90,8 @@ public virtual async Task> GetAllCustomers(DateTime? create
string firstName = null, string lastName = null,
string company = null, string phone = null, string zipPostalCode = null,
bool loadOnlyWithShoppingCart = false, ShoppingCartType? sct = null,
- int pageIndex = 0, int pageSize = 2147483647, Expression> orderBySelector = null)
+ int pageIndex = 0, int pageSize = 2147483647, Expression> orderBySelector = null,
+ string[] excludedCustomerGroupIds = null, bool excludeVendorAndStaffAccounts = false)
{
var queryModel = new GetCustomerQuery {
CreatedFromUtc = createdFromUtc,
@@ -111,7 +114,9 @@ public virtual async Task> GetAllCustomers(DateTime? create
Sct = sct,
PageIndex = pageIndex,
PageSize = pageSize,
- OrderBySelector = orderBySelector
+ OrderBySelector = orderBySelector,
+ ExcludedCustomerGroupIds = excludedCustomerGroupIds,
+ ExcludeVendorAndStaffAccounts = excludeVendorAndStaffAccounts
};
return await _mediator.Send(queryModel);
}
diff --git a/src/Modules/Grand.Module.Installer/Services/InstallationService.cs b/src/Modules/Grand.Module.Installer/Services/InstallationService.cs
index a39d505e4..3a45b8063 100644
--- a/src/Modules/Grand.Module.Installer/Services/InstallationService.cs
+++ b/src/Modules/Grand.Module.Installer/Services/InstallationService.cs
@@ -492,6 +492,9 @@ await dbContext.CreateIndex(_customerRepository,
OrderBuilder.Create().Ascending(x => x.Email).Ascending(x => x.StoreId), "Email_StoreId");
await dbContext.CreateIndex(_customerRepository,
OrderBuilder.Create().Ascending(x => x.Username).Ascending(x => x.StoreId), "Username_StoreId");
+ //an owner's sub-accounts
+ await dbContext.CreateIndex(_customerRepository,
+ OrderBuilder.Create().Ascending(x => x.OwnerId), "OwnerId_1");
await dbContext.CreateIndex(_customerRepository,
OrderBuilder.Create().Descending(x => x.CreatedOnUtc), "CreatedOnUtc");
diff --git a/src/Modules/Grand.Module.Migration/Migrations/2.5/MigrationCustomerOwnerIndex.cs b/src/Modules/Grand.Module.Migration/Migrations/2.5/MigrationCustomerOwnerIndex.cs
new file mode 100644
index 000000000..e78f7f75a
--- /dev/null
+++ b/src/Modules/Grand.Module.Migration/Migrations/2.5/MigrationCustomerOwnerIndex.cs
@@ -0,0 +1,39 @@
+using Grand.Data;
+using Grand.Domain.Customers;
+using Grand.Infrastructure.Migrations;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+namespace Grand.Module.Migration.Migrations._2._5;
+
+public class MigrationCustomerOwnerIndex : IMigration
+{
+ private const string IndexName = "OwnerId_1";
+
+ public int Priority => 1;
+ public DbVersion Version => new(2, 5);
+ public Guid Identity => new("6f0d4b9e-3c1a-4e27-9a85-2b7e4c1d9f63");
+ public string Name => "Index on Customer owner 2.5";
+
+ public async Task UpgradeProcess(IServiceProvider serviceProvider)
+ {
+ var repository = serviceProvider.GetRequiredService>();
+ var databaseContext = serviceProvider.GetRequiredService();
+ var logService = serviceProvider.GetRequiredService>();
+
+ try
+ {
+ //an owner's sub-accounts are listed on the storefront and in the admin customer form, and the admin
+ //checks for them before linking an owner - each a scan of the whole customer collection without it
+ await databaseContext.CreateIndex(repository,
+ OrderBuilder.Create().Ascending(x => x.OwnerId), IndexName);
+ }
+ catch (Exception ex)
+ {
+ logService.LogError(ex, "UpgradeProcess - CustomerOwnerIndex (2.5)");
+ return false;
+ }
+
+ return true;
+ }
+}
diff --git a/src/Tests/Grand.Business.Customers.Tests/Queries/Handlers/GetCustomerQueryHandlerTests.cs b/src/Tests/Grand.Business.Customers.Tests/Queries/Handlers/GetCustomerQueryHandlerTests.cs
index dd8b1af5b..cfdfd07cf 100644
--- a/src/Tests/Grand.Business.Customers.Tests/Queries/Handlers/GetCustomerQueryHandlerTests.cs
+++ b/src/Tests/Grand.Business.Customers.Tests/Queries/Handlers/GetCustomerQueryHandlerTests.cs
@@ -46,4 +46,41 @@ public async Task Handle_PageSizeTwo_ReturnsFirstPageAndTotal()
Assert.AreEqual(4, result.TotalCount);
Assert.AreEqual(2, result.TotalPages);
}
+
+ [TestMethod]
+ public async Task Handle_ExcludedGroupsAndStaffAccounts_LeftOutInTheQuery()
+ {
+ var repository = new MongoDBRepositoryTest();
+ var customer = new Customer { Email = "customer@x.com", Groups = { "registered" } };
+ var wholesale = new Customer { Email = "wholesale@x.com", Groups = { "registered", "wholesale" } };
+ repository.Insert(customer);
+ repository.Insert(wholesale);
+ repository.Insert(new Customer { Email = "vendor-group@x.com", Groups = { "registered", "vendors" } });
+ repository.Insert(new Customer { Email = "manager-group@x.com", Groups = { "registered", "storemanager" } });
+ repository.Insert(new Customer { Email = "vendor-link@x.com", Groups = { "registered" }, VendorId = "v1" });
+ repository.Insert(new Customer { Email = "staff-link@x.com", Groups = { "registered" }, StaffStoreId = "s1" });
+ var queryHandler = new GetCustomerQueryHandler(repository);
+
+ var result = await queryHandler.Handle(new GetCustomerQuery {
+ ExcludedCustomerGroupIds = ["vendors", "storemanager"],
+ ExcludeVendorAndStaffAccounts = true
+ }, CancellationToken.None);
+
+ CollectionAssert.AreEquivalent(new[] { "customer@x.com", "wholesale@x.com" },
+ result.Select(c => c.Email).ToArray());
+ Assert.AreEqual(2, result.TotalCount);
+ }
+
+ [TestMethod]
+ public async Task Handle_NoExclusions_KeepsEveryone()
+ {
+ var repository = new MongoDBRepositoryTest();
+ repository.Insert(new Customer { Groups = { "vendors" }, VendorId = "v1" });
+ repository.Insert(new Customer { StaffStoreId = "s1" });
+
+ var result = await new GetCustomerQueryHandler(repository).Handle(new GetCustomerQuery(),
+ CancellationToken.None);
+
+ Assert.AreEqual(2, result.TotalCount);
+ }
}
\ No newline at end of file
diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/StoreCustomerDataScopeTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/StoreCustomerDataScopeTests.cs
index 95a0335b2..0c4c60305 100644
--- a/src/Tests/Grand.Web.Admin.Tests/Controllers/StoreCustomerDataScopeTests.cs
+++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/StoreCustomerDataScopeTests.cs
@@ -10,7 +10,8 @@ namespace Grand.Web.Admin.Tests.Controllers;
[TestClass]
public class StoreCustomerDataScopeTests
{
- private static StoreCustomerDataScope Build(string staffStoreId, bool isRegistered = true)
+ private static StoreCustomerDataScope Build(string staffStoreId, bool isRegistered = true,
+ Action> groups = null)
{
var customer = new Customer { StaffStoreId = staffStoreId };
var workContextMock = new Mock();
@@ -20,10 +21,55 @@ private static StoreCustomerDataScope Build(string staffStoreId, bool isRegister
var groupServiceMock = new Mock();
groupServiceMock.Setup(g => g.IsRegistered(It.IsAny())).ReturnsAsync(isRegistered);
+ groups?.Invoke(groupServiceMock);
return new StoreCustomerDataScope(contextAccessorMock.Object, groupServiceMock.Object);
}
+ //a store manager manages registered customers only: a staff account in reach could be reset and taken over
+
+ [TestMethod]
+ [DataRow("admin")]
+ [DataRow("vendor")]
+ [DataRow("storemanager")]
+ [DataRow("salesmanager")]
+ public async Task HasAccess_StaffGroupMember_False(string role)
+ {
+ var scope = Build("store-1", groups: g => {
+ g.Setup(x => x.IsAdmin(It.IsAny())).ReturnsAsync(role == "admin");
+ g.Setup(x => x.IsVendor(It.IsAny())).ReturnsAsync(role == "vendor");
+ g.Setup(x => x.IsStoreManager(It.IsAny())).ReturnsAsync(role == "storemanager");
+ g.Setup(x => x.IsSalesManager(It.IsAny())).ReturnsAsync(role == "salesmanager");
+ });
+
+ Assert.IsFalse(await scope.HasAccess(new Customer { StoreId = "store-1" }));
+ }
+
+ [TestMethod]
+ public async Task HasAccess_TiedToAVendor_False()
+ {
+ var scope = Build("store-1");
+
+ Assert.IsFalse(await scope.HasAccess(new Customer { StoreId = "store-1", VendorId = "vendor-1" }));
+ }
+
+ [TestMethod]
+ public async Task HasAccess_TiedToAStaffStore_False()
+ {
+ var scope = Build("store-1");
+
+ Assert.IsFalse(await scope.HasAccess(new Customer { StoreId = "store-1", StaffStoreId = "store-1" }));
+ }
+
+ [TestMethod]
+ public void StaffGroupSystemNames_CoverEveryStaffRole()
+ {
+ CollectionAssert.AreEquivalent(new[] {
+ SystemCustomerGroupNames.Administrators, SystemCustomerGroupNames.Vendors,
+ SystemCustomerGroupNames.StoreManager, SystemCustomerGroupNames.SalesManager
+ }, StoreCustomerDataScope.StaffGroupSystemNames);
+ }
+
[TestMethod]
public async Task HasAccess_MatchingStoreId_Registered_NotDeleted_True()
{
diff --git a/src/Tests/Grand.Web.Admin.Tests/Services/CustomerViewModelServiceTests.cs b/src/Tests/Grand.Web.Admin.Tests/Services/CustomerViewModelServiceTests.cs
index 17ce621e1..9cf0c5282 100644
--- a/src/Tests/Grand.Web.Admin.Tests/Services/CustomerViewModelServiceTests.cs
+++ b/src/Tests/Grand.Web.Admin.Tests/Services/CustomerViewModelServiceTests.cs
@@ -19,6 +19,8 @@
using Grand.Domain.Vendors;
using Grand.Domain.Messages;
using Grand.Infrastructure;
+using Grand.Infrastructure.Configuration;
+using Grand.Web.AdminShared.Interfaces;
using Grand.Web.AdminShared.Models.Customers;
using Grand.Web.AdminShared.Services;
using Grand.Web.Common.Localization;
@@ -46,6 +48,8 @@ public class CustomerViewModelServiceTests
private Mock _newsLetterSubscriptionServiceMock;
private Mock _translationServiceMock;
private Customer _currentCustomer;
+ private CustomerConfig _customerConfig;
+ private Mock> _scopeMock;
private CustomerViewModelService _customerViewModelService;
[TestInitialize]
@@ -114,6 +118,13 @@ public void Setup()
_groupServiceMock.Setup(g => g.GetAllByIds(It.IsAny()))
.ReturnsAsync(new List());
+ SetupGetAllCustomers(new PagedList());
+ _customerConfig = new CustomerConfig();
+ //Admin panel: no default store, every customer in reach
+ _scopeMock = new Mock>();
+ _scopeMock.Setup(s => s.GetDefaultStoreIdAsync()).ReturnsAsync((string)null);
+ _scopeMock.Setup(s => s.HasAccess(It.IsAny())).ReturnsAsync(true);
+
_customerViewModelService = new CustomerViewModelService(
_customerServiceMock.Object,
_groupServiceMock.Object,
@@ -133,7 +144,9 @@ public void Setup()
new TaxSettings(),
new LoyaltyPointsSettings(),
new CommonSettings(),
- _enumTranslationServiceMock.Object);
+ _enumTranslationServiceMock.Object,
+ _customerConfig,
+ _scopeMock.Object);
}
[TestMethod]
@@ -432,6 +445,210 @@ public async Task PrepareCustomerList_NoCustomers_ReturnsEmpty()
Assert.AreEqual(0, list.Count());
}
+ #region Store panel list
+
+ private void VerifyListQuery(string[] excludedGroupIds, bool excludeVendorAndStaff) =>
+ _customerServiceMock.Verify(c => c.GetAllCustomers(It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny(), It.IsAny(),
+ It.IsAny>>(),
+ It.Is(ids => excludedGroupIds == null
+ ? ids == null
+ : ids != null && ids.OrderBy(x => x).SequenceEqual(excludedGroupIds.OrderBy(x => x))),
+ excludeVendorAndStaff), Times.Once);
+
+ [TestMethod]
+ public async Task PrepareCustomerList_StorePanel_LeavesTheStaffOutInTheQuery()
+ {
+ _scopeMock.Setup(s => s.GetDefaultStoreIdAsync()).ReturnsAsync("store-7");
+ foreach (var name in StoreCustomerDataScope.StaffGroupSystemNames)
+ _groupServiceMock.Setup(g => g.GetCustomerGroupBySystemName(name))
+ .ReturnsAsync(new CustomerGroup { Id = "id-" + name, SystemName = name });
+
+ await _customerViewModelService.PrepareCustomerList(new CustomerListModel(), null, null, 1, 10, "store-7");
+
+ VerifyListQuery(StoreCustomerDataScope.StaffGroupSystemNames.Select(n => "id-" + n).ToArray(), true);
+ }
+
+ [TestMethod]
+ public async Task PrepareCustomerList_AdminPanel_ExcludesNobody()
+ {
+ await _customerViewModelService.PrepareCustomerList(new CustomerListModel(), null, null, 1, 10);
+
+ VerifyListQuery(null, false);
+ }
+
+ #endregion
+
+ #region Owner and sub-accounts
+
+ [TestMethod]
+ public async Task UpdateCustomerModel_OwnerEmailUnchanged_KeepsTheLinkedOwner()
+ {
+ //posted back as shown, the owner is kept even where an email lookup would find someone else
+ var owner = new Customer { Id = "owner-1", Email = "boss@firm.com" };
+ _customerServiceMock.Setup(c => c.GetCustomerById("owner-1")).ReturnsAsync(owner);
+ _customerServiceMock.Setup(c => c.GetCustomerByEmail("boss@firm.com", It.IsAny()))
+ .ReturnsAsync(new Customer { Id = "same-email-other-store" });
+ var customer = new Customer { Id = "c1", OwnerId = "owner-1" };
+
+ await _customerViewModelService.UpdateCustomerModel(customer,
+ new CustomerModel { Email = "c1@firm.com", Owner = "Boss@Firm.com " });
+
+ Assert.AreEqual("owner-1", customer.OwnerId);
+ }
+
+ [TestMethod]
+ public async Task UpdateCustomerModel_NewOwner_PerStore_IsLookedUpInTheCustomersStore()
+ {
+ _customerConfig.RegisterCustomersPerStore = true;
+ _customerServiceMock.Setup(c => c.GetCustomerByEmail("boss@firm.com", "store-2"))
+ .ReturnsAsync(new Customer { Id = "owner-store-2" });
+ var customer = new Customer { Id = "c1" };
+
+ await _customerViewModelService.UpdateCustomerModel(customer,
+ new CustomerModel { Email = "c1@firm.com", StoreId = "store-2", Owner = "boss@firm.com" });
+
+ //the same scope the validator checks in - the global lookup used to pick any store's account
+ Assert.AreEqual("owner-store-2", customer.OwnerId);
+ _customerServiceMock.Verify(c => c.GetCustomerByEmail("boss@firm.com", ""), Times.Never);
+ }
+
+ [TestMethod]
+ public async Task UpdateCustomerModel_PerStore_CustomerMovedToAnotherStore_LooksTheOwnerUpAgain()
+ {
+ _customerConfig.RegisterCustomersPerStore = true;
+ _customerServiceMock.Setup(c => c.GetCustomerById("owner-a"))
+ .ReturnsAsync(new Customer { Id = "owner-a", Email = "boss@firm.com" });
+ _customerServiceMock.Setup(c => c.GetCustomerByEmail("boss@firm.com", "store-b"))
+ .ReturnsAsync(new Customer { Id = "owner-b" });
+ var customer = new Customer { Id = "c1", OwnerId = "owner-a", StoreId = "store-a" };
+
+ await _customerViewModelService.UpdateCustomerModel(customer,
+ new CustomerModel { Email = "c1@firm.com", StoreId = "store-b", Owner = "boss@firm.com" });
+
+ Assert.AreEqual("owner-b", customer.OwnerId);
+ }
+
+ [TestMethod]
+ public async Task UpdateCustomerModel_OwnerCleared_UnlinksTheOwner()
+ {
+ var customer = new Customer { Id = "c1", OwnerId = "owner-1" };
+
+ await _customerViewModelService.UpdateCustomerModel(customer,
+ new CustomerModel { Email = "c1@firm.com", Owner = "" });
+
+ Assert.AreEqual(string.Empty, customer.OwnerId);
+ }
+
+ [TestMethod]
+ public async Task InsertCustomerModel_WithOwner_LinksIt()
+ {
+ _customerServiceMock.Setup(c => c.GetCustomerByEmail("boss@firm.com", ""))
+ .ReturnsAsync(new Customer { Id = "owner-1" });
+
+ var customer = await _customerViewModelService.InsertCustomerModel(
+ new CustomerModel { Email = "new@firm.com", Owner = "boss@firm.com" });
+
+ Assert.AreEqual("owner-1", customer.OwnerId);
+ }
+
+ [TestMethod]
+ public async Task PrepareCustomerModel_Owner_ListsItsSubAccounts()
+ {
+ var owner = new Customer { Id = "owner-1", Email = "boss@firm.com" };
+ _customerServiceMock
+ .Setup(c => c.GetAllCustomers(It.IsAny(), It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny(), "owner-1", It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny(), It.IsAny(),
+ It.IsAny>>()))
+ .ReturnsAsync(new PagedList {
+ new() { Id = "sub-1", Email = "a@firm.com", Active = true, OwnerId = "owner-1" },
+ new() { Id = "sub-2", Email = "b@firm.com", Active = false, OwnerId = "owner-1" }
+ });
+
+ var model = new CustomerModel();
+ await _customerViewModelService.PrepareCustomerModel(model, owner, false);
+
+ CollectionAssert.AreEqual(new[] { "sub-1", "sub-2" }, model.SubAccounts.Select(x => x.Id).ToArray());
+ Assert.IsFalse(model.SubAccounts[1].Active);
+ Assert.IsTrue(string.IsNullOrEmpty(model.OwnerCustomerId));
+ }
+
+ [TestMethod]
+ public async Task PrepareCustomerModel_StorePanel_QueriesOnlyItsStoreAndSkipsOutOfScopeRows()
+ {
+ _scopeMock.Setup(s => s.GetDefaultStoreIdAsync()).ReturnsAsync("store-7");
+ var foreign = new Customer { Id = "sub-x", Email = "x@firm.com", OwnerId = "owner-1" };
+ _scopeMock.Setup(s => s.HasAccess(foreign)).ReturnsAsync(false);
+ SetupGetAllCustomers(new PagedList {
+ new() { Id = "sub-1", Email = "a@firm.com", OwnerId = "owner-1" }, foreign
+ });
+
+ var model = new CustomerModel();
+ await _customerViewModelService.PrepareCustomerModel(model, new Customer { Id = "owner-1" }, false);
+
+ CollectionAssert.AreEqual(new[] { "sub-1" }, model.SubAccounts.Select(x => x.Id).ToArray());
+ _customerServiceMock.Verify(c => c.GetAllCustomers(It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny(), "store-7", "owner-1", It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny(), It.IsAny(),
+ It.IsAny>>()), Times.Once);
+ }
+
+ [TestMethod]
+ public async Task PrepareCustomerModel_SalesManager_ListsOnlyTheirSubAccounts()
+ {
+ _currentCustomer.SeId = "se-1";
+
+ await _customerViewModelService.PrepareCustomerModel(new CustomerModel(), new Customer { Id = "owner-1" },
+ false);
+
+ _customerServiceMock.Verify(c => c.GetAllCustomers(It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny(), It.IsAny(), "owner-1", "se-1", It.IsAny(),
+ It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny(), It.IsAny(),
+ It.IsAny>>()), Times.Once);
+ }
+
+ [TestMethod]
+ public async Task PrepareCustomerModel_SubAccount_LinksTheOwnerInReach()
+ {
+ var owner = new Customer { Id = "owner-1", Email = "boss@firm.com" };
+ _customerServiceMock.Setup(c => c.GetCustomerById("owner-1")).ReturnsAsync(owner);
+
+ var model = new CustomerModel();
+ await _customerViewModelService.PrepareCustomerModel(model,
+ new Customer { Id = "sub-1", OwnerId = "owner-1" }, false);
+
+ Assert.AreEqual("owner-1", model.OwnerCustomerId);
+ Assert.AreEqual("boss@firm.com", model.OwnerCustomerEmail);
+ Assert.AreEqual(0, model.SubAccounts.Count);
+ }
+
+ [TestMethod]
+ public async Task PrepareCustomerModel_SubAccount_OwnerOutOfReach_IsNotShown()
+ {
+ var owner = new Customer { Id = "owner-1", Email = "boss@other-store.com" };
+ _customerServiceMock.Setup(c => c.GetCustomerById("owner-1")).ReturnsAsync(owner);
+ _scopeMock.Setup(s => s.HasAccess(owner)).ReturnsAsync(false);
+
+ var model = new CustomerModel();
+ await _customerViewModelService.PrepareCustomerModel(model,
+ new Customer { Id = "sub-1", OwnerId = "owner-1" }, false);
+
+ Assert.IsTrue(string.IsNullOrEmpty(model.OwnerCustomerId));
+ Assert.IsTrue(string.IsNullOrEmpty(model.OwnerCustomerEmail));
+ }
+
+ #endregion
+
private void SetupGetAllCustomers(IPagedList result)
{
_customerServiceMock
@@ -441,7 +658,8 @@ private void SetupGetAllCustomers(IPagedList result)
It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(),
It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(),
It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(),
- It.IsAny>>()))
+ It.IsAny>>(),
+ It.IsAny(), It.IsAny()))
.ReturnsAsync(result);
}
}
diff --git a/src/Tests/Grand.Web.Admin.Tests/Validators/CustomerValidatorOwnerTests.cs b/src/Tests/Grand.Web.Admin.Tests/Validators/CustomerValidatorOwnerTests.cs
new file mode 100644
index 000000000..3c8aa3911
--- /dev/null
+++ b/src/Tests/Grand.Web.Admin.Tests/Validators/CustomerValidatorOwnerTests.cs
@@ -0,0 +1,254 @@
+using Grand.Business.Core.Interfaces.Common.Directory;
+using Grand.Business.Core.Interfaces.Customers;
+using Grand.Domain;
+using Grand.Domain.Customers;
+using Grand.Domain.Orders;
+using Grand.Domain.Stores;
+using Grand.Infrastructure;
+using Grand.Infrastructure.Configuration;
+using Grand.Infrastructure.Validators;
+using Grand.Web.AdminShared.Interfaces;
+using Grand.Web.AdminShared.Models.Customers;
+using Grand.Web.AdminShared.Validators.Customers;
+using Grand.Web.Common.Localization;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Moq;
+using System.Linq.Expressions;
+
+namespace Grand.Web.Admin.Tests.Validators;
+
+///
+/// The owner field of the admin customer form: sub-accounts stay one level deep, and the owner already
+/// linked never blocks an unrelated save.
+///
+[TestClass]
+public class CustomerValidatorOwnerTests
+{
+ private const string NotFound = "Admin.Customers.Customers.Fields.Owner.NotFound";
+ private const string IsSubAccount = "Admin.Customers.Customers.Fields.Owner.IsSubAccount";
+ private const string HasSubAccounts = "Admin.Customers.Customers.Fields.Owner.HasSubAccounts";
+ private const string NotRegistered = "Admin.Customers.Customers.Fields.Owner.NotRegistered";
+ private const string Self = "Admin.Customers.Customers.Fields.Owner.Self";
+
+ private Mock _customerService;
+ private Mock _groupService;
+ private CustomerConfig _customerConfig;
+ private Mock> _scope;
+
+ [TestInitialize]
+ public void Setup()
+ {
+ //Admin panel: no default store, the owner field is editable
+ _scope = new Mock>();
+ _scope.Setup(s => s.GetDefaultStoreIdAsync()).ReturnsAsync((string)null);
+
+ _groupService = new Mock();
+ _groupService
+ .Setup(g => g.GetAllCustomerGroups(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new PagedList());
+ _groupService.Setup(g => g.IsAdmin(It.IsAny())).ReturnsAsync(true);
+ _groupService.Setup(g => g.IsRegistered(It.IsAny())).ReturnsAsync(true);
+
+ //nobody has sub-accounts unless a test says so
+ _customerService = new Mock();
+ _customerService
+ .Setup(c => c.GetAllCustomers(It.IsAny(), It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny(), It.IsAny(),
+ It.IsAny>>()))
+ .ReturnsAsync(new PagedList());
+ _customerConfig = new CustomerConfig();
+ }
+
+ private CustomerValidator Validator()
+ {
+ var translation = new Mock();
+ translation.Setup(t => t.GetResource(It.IsAny())).Returns(k => k);
+ var work = new Mock();
+ work.Setup(w => w.CurrentCustomer).Returns(new Customer { Id = "admin" });
+ var store = new Mock();
+ store.Setup(s => s.CurrentStore).Returns(new 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 CustomerValidator(new List>(), translation.Object,
+ new Mock().Object, context.Object, _customerService.Object, _groupService.Object,
+ new CustomerSettings(), _customerConfig, _scope.Object);
+ }
+
+ private void SubAccountsOf(string ownerId, int count)
+ {
+ var list = new PagedList();
+ for (var i = 0; i < count; i++) list.Add(new Customer { OwnerId = ownerId });
+ _customerService
+ .Setup(c => c.GetAllCustomers(It.IsAny(), It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny(), ownerId, It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny(), It.IsAny(),
+ It.IsAny>>()))
+ .ReturnsAsync(list);
+ }
+
+ private Customer Existing(string id = "c1", string ownerId = "")
+ {
+ var customer = new Customer { Id = id, Email = $"{id}@firm.com", OwnerId = ownerId };
+ _customerService.Setup(c => c.GetCustomerById(id)).ReturnsAsync(customer);
+ return customer;
+ }
+
+ private Customer OwnerAccount(string email = "boss@firm.com", string id = "owner-1", string ownerId = "")
+ {
+ var owner = new Customer { Id = id, Email = email, OwnerId = ownerId };
+ _customerService.Setup(c => c.GetCustomerByEmail(email, It.IsAny())).ReturnsAsync(owner);
+ _customerService.Setup(c => c.GetCustomerById(id)).ReturnsAsync(owner);
+ return owner;
+ }
+
+ private async Task OwnerErrors(CustomerModel model) =>
+ (await Validator().ValidateAsync(model)).Errors
+ .Where(e => e.PropertyName == nameof(CustomerModel.Owner)).Select(e => e.ErrorMessage).ToArray();
+
+ private static CustomerModel Edit(string owner, string id = "c1") =>
+ new() { Id = id, Email = $"{id}@firm.com", StoreId = "s1", Owner = owner };
+
+ private static CustomerModel Create(string owner) =>
+ new() { Email = "new@firm.com", StoreId = "s1", Owner = owner };
+
+ [TestMethod]
+ public async Task ValidOwner_Passes()
+ {
+ Existing();
+ OwnerAccount();
+
+ CollectionAssert.AreEqual(Array.Empty(), await OwnerErrors(Edit("boss@firm.com")));
+ CollectionAssert.AreEqual(Array.Empty(), await OwnerErrors(Create("boss@firm.com")));
+ }
+
+ [TestMethod]
+ public async Task NoOwner_Passes()
+ {
+ Existing();
+
+ CollectionAssert.AreEqual(Array.Empty(), await OwnerErrors(Edit("")));
+ }
+
+ [TestMethod]
+ public async Task UnknownOwner_Fails()
+ {
+ Existing();
+
+ CollectionAssert.AreEqual(new[] { NotFound }, await OwnerErrors(Edit("nobody@firm.com")));
+ CollectionAssert.AreEqual(new[] { NotFound }, await OwnerErrors(Create("nobody@firm.com")));
+ }
+
+ [TestMethod]
+ public async Task DeletedOwner_Fails()
+ {
+ Existing();
+ OwnerAccount().Deleted = true;
+
+ CollectionAssert.AreEqual(new[] { NotFound }, await OwnerErrors(Edit("boss@firm.com")));
+ }
+
+ [TestMethod]
+ public async Task OwnerThatIsASubAccount_Fails()
+ {
+ //a chain would let the top account reach orders two levels down
+ Existing();
+ OwnerAccount(ownerId: "top-owner");
+
+ CollectionAssert.AreEqual(new[] { IsSubAccount }, await OwnerErrors(Edit("boss@firm.com")));
+ }
+
+ [TestMethod]
+ public async Task CustomerWithSubAccounts_CannotGetAnOwner()
+ {
+ Existing();
+ OwnerAccount();
+ SubAccountsOf("c1", 2);
+
+ CollectionAssert.AreEqual(new[] { HasSubAccounts }, await OwnerErrors(Edit("boss@firm.com")));
+ }
+
+ [TestMethod]
+ public async Task UnregisteredOwner_Fails()
+ {
+ Existing();
+ var owner = OwnerAccount();
+ _groupService.Setup(g => g.IsRegistered(owner)).ReturnsAsync(false);
+
+ CollectionAssert.AreEqual(new[] { NotRegistered }, await OwnerErrors(Edit("boss@firm.com")));
+ }
+
+ [TestMethod]
+ public async Task OwnEmail_Fails()
+ {
+ Existing();
+
+ CollectionAssert.AreEqual(new[] { Self }, await OwnerErrors(Edit("C1@Firm.com")));
+ }
+
+ [TestMethod]
+ public async Task OwnAccountUnderAnotherEmail_Fails()
+ {
+ //the email lookup resolves to the customer being edited (e.g. its email is being changed at the same time)
+ Existing();
+ _customerService.Setup(c => c.GetCustomerByEmail("old@firm.com", It.IsAny()))
+ .ReturnsAsync(new Customer { Id = "c1" });
+
+ CollectionAssert.AreEqual(new[] { Self }, await OwnerErrors(Edit("old@firm.com")));
+ }
+
+ [TestMethod]
+ public async Task OwnerAlreadyLinked_NeverBlocksTheSave()
+ {
+ //legacy data the rules would now refuse (the owner became a sub-account) must not lock the form
+ Existing(ownerId: "owner-1");
+ OwnerAccount(ownerId: "top-owner");
+
+ CollectionAssert.AreEqual(Array.Empty(), await OwnerErrors(Edit("Boss@Firm.com")));
+ }
+
+ [TestMethod]
+ public async Task StorePanel_DoesNotCheckThePostedOwner()
+ {
+ //the Store panel replaces the posted owner before saving; checking it would answer a store manager
+ //whether another store's e-mail exists, is a sub-account, is registered or has sub-accounts
+ _scope.Setup(s => s.GetDefaultStoreIdAsync()).ReturnsAsync("s1");
+ Existing();
+ OwnerAccount(ownerId: "top-owner");
+
+ CollectionAssert.AreEqual(Array.Empty(), await OwnerErrors(Edit("boss@firm.com")));
+ _customerService.Verify(c => c.GetCustomerByEmail("boss@firm.com", It.IsAny()), Times.Never);
+ }
+
+ [TestMethod]
+ public async Task OwnerAlreadyLinked_CustomerMovedToAnotherStore_MustHoldInTheNewStore()
+ {
+ _customerConfig.RegisterCustomersPerStore = true;
+ var customer = Existing(ownerId: "owner-1");
+ customer.StoreId = "store-a";
+ var owner = new Customer { Id = "owner-1", Email = "boss@firm.com" };
+ _customerService.Setup(c => c.GetCustomerById("owner-1")).ReturnsAsync(owner);
+ _customerService.Setup(c => c.GetCustomerByEmail("boss@firm.com", "store-a")).ReturnsAsync(owner);
+
+ //Edit() posts StoreId "s1": the owner lives in store-a only, so the link would cross stores
+ CollectionAssert.AreEqual(new[] { NotFound }, await OwnerErrors(Edit("boss@firm.com")));
+ }
+
+ [TestMethod]
+ public async Task PerStoreIdentity_LooksTheOwnerUpInTheCustomersStore()
+ {
+ _customerConfig.RegisterCustomersPerStore = true;
+ Existing();
+ OwnerAccount();
+
+ await OwnerErrors(Edit("boss@firm.com"));
+
+ _customerService.Verify(c => c.GetCustomerByEmail("boss@firm.com", "s1"), Times.Once);
+ }
+}
diff --git a/src/Tests/Grand.Web.Admin.Tests/Validators/CustomerValidatorTests.cs b/src/Tests/Grand.Web.Admin.Tests/Validators/CustomerValidatorTests.cs
index c3666870f..818d1bdf0 100644
--- a/src/Tests/Grand.Web.Admin.Tests/Validators/CustomerValidatorTests.cs
+++ b/src/Tests/Grand.Web.Admin.Tests/Validators/CustomerValidatorTests.cs
@@ -6,6 +6,7 @@
using Grand.Infrastructure;
using Grand.Infrastructure.Configuration;
using Grand.Infrastructure.Validators;
+using Grand.Web.AdminShared.Interfaces;
using Grand.Web.AdminShared.Models.Customers;
using Grand.Web.AdminShared.Validators.Customers;
using Microsoft.VisualStudio.TestTools.UnitTesting;
@@ -52,7 +53,8 @@ public void Setup()
new Mock().Object,
_groupServiceMock.Object,
new CustomerSettings(),
- new CustomerConfig());
+ new CustomerConfig(),
+ new Mock>().Object);
}
private static CustomerModel BuildModel(string storeId) =>
diff --git a/src/Tests/Grand.Web.Store.Tests/Controllers/CustomerControllerTests.cs b/src/Tests/Grand.Web.Store.Tests/Controllers/CustomerControllerTests.cs
index 3b57d7369..aff78fec1 100644
--- a/src/Tests/Grand.Web.Store.Tests/Controllers/CustomerControllerTests.cs
+++ b/src/Tests/Grand.Web.Store.Tests/Controllers/CustomerControllerTests.cs
@@ -209,6 +209,60 @@ public async Task ApplyPostConstraints_CraftedPost_CannotSmuggleOwnershipFields(
CollectionAssert.AreEqual(new[] { "registered-id" }, craftedModel.CustomerGroups.ToArray());
}
+ [TestMethod]
+ public async Task ApplyPostConstraints_EditedSubAccount_KeepsItsRealOwner()
+ {
+ //blanking the owner here detached every sub-account a store manager saved; a crafted value must not
+ //move it to another owner either
+ _customerServiceMock.Setup(c => c.GetCustomerById("sub-1"))
+ .ReturnsAsync(new Customer { Id = "sub-1", OwnerId = "owner-1" });
+ _customerServiceMock.Setup(c => c.GetCustomerById("owner-1"))
+ .ReturnsAsync(new Customer { Id = "owner-1", Email = "boss@firm.com" });
+ var controller = BuildStoreController();
+
+ var model = new CustomerModel { Id = "sub-1", Owner = "attacker@evil.com" };
+ await controller.ApplyPostConstraintsPublic(model);
+
+ Assert.AreEqual("boss@firm.com", model.Owner);
+ }
+
+ [TestMethod]
+ public async Task ApplyPostConstraints_EditedCustomer_KeepsWhereItBelongs_IgnoringCraftedValues()
+ {
+ //blanking used to strip these on every save: the sales employee, the price groups, a colleague's
+ //Store manager role
+ _customerServiceMock.Setup(c => c.GetCustomerById("c1")).ReturnsAsync(new Customer {
+ Id = "c1", SeId = "se-1", VendorId = "vendor-1", StaffStoreId = StoreId,
+ Groups = { "registered-id", "wholesale-id", "storemanager-id" }
+ });
+ var controller = BuildStoreController();
+
+ var model = new CustomerModel {
+ Id = "c1", SeId = "attacker-se", VendorId = "attacker-vendor", StaffStoreId = "attacker-store",
+ CustomerGroups = new[] { "attacker-admin-group-id" }
+ };
+ await controller.ApplyPostConstraintsPublic(model);
+
+ Assert.AreEqual(StoreId, model.StoreId);
+ Assert.AreEqual("se-1", model.SeId);
+ Assert.AreEqual("vendor-1", model.VendorId);
+ Assert.AreEqual(StoreId, model.StaffStoreId);
+ CollectionAssert.AreEqual(new[] { "registered-id", "wholesale-id", "storemanager-id" },
+ model.CustomerGroups.ToArray());
+ }
+
+ [TestMethod]
+ public async Task ApplyPostConstraints_EditedCustomerWithoutOwner_GetsNone()
+ {
+ _customerServiceMock.Setup(c => c.GetCustomerById("c1")).ReturnsAsync(new Customer { Id = "c1" });
+ var controller = BuildStoreController();
+
+ var model = new CustomerModel { Id = "c1", Owner = "attacker@evil.com" };
+ await controller.ApplyPostConstraintsPublic(model);
+
+ Assert.AreEqual("", model.Owner);
+ }
+
[TestMethod]
public async Task ApplyPostConstraints_NoRegisteredGroupFound_ClearsCustomerGroups()
{
diff --git a/src/Tests/Grand.Web.Tests/Commands/Customers/SubAccountDeleteCommandHandlerTests.cs b/src/Tests/Grand.Web.Tests/Commands/Customers/SubAccountDeleteCommandHandlerTests.cs
new file mode 100644
index 000000000..393b0d887
--- /dev/null
+++ b/src/Tests/Grand.Web.Tests/Commands/Customers/SubAccountDeleteCommandHandlerTests.cs
@@ -0,0 +1,70 @@
+using Grand.Business.Core.Interfaces.Customers;
+using Grand.Domain.Customers;
+using Grand.Web.Commands.Handler.Customers;
+using Grand.Web.Commands.Models.Customers;
+using Grand.Web.Common.Localization;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Moq;
+
+namespace Grand.Web.Tests.Commands.Customers;
+
+[TestClass]
+public class SubAccountDeleteCommandHandlerTests
+{
+ private Mock _customerServiceMock;
+ private SubAccountDeleteCommandHandler _handler;
+
+ [TestInitialize]
+ public void Init()
+ {
+ _customerServiceMock = new Mock();
+ var translation = new Mock();
+ translation.Setup(t => t.GetResource(It.IsAny())).Returns(key => key);
+ _handler = new SubAccountDeleteCommandHandler(_customerServiceMock.Object, translation.Object);
+ }
+
+ private static SubAccountDeleteCommand Command(string id) =>
+ new() { CurrentCustomer = new Customer { Id = "owner" }, CustomerId = id };
+
+ [TestMethod]
+ public async Task Handle_OwnSubAccount_DeletesIt()
+ {
+ var subAccount = new Customer { Id = "sub", OwnerId = "owner" };
+ _customerServiceMock.Setup(c => c.GetCustomerById("sub")).ReturnsAsync(subAccount);
+
+ var (success, error) = await _handler.Handle(Command("sub"), default);
+
+ Assert.IsTrue(success);
+ Assert.AreEqual(string.Empty, error);
+ _customerServiceMock.Verify(c => c.DeleteCustomer(subAccount, false), Times.Once);
+ }
+
+ [TestMethod]
+ [DataRow("another-owner")]
+ [DataRow("")]
+ public async Task Handle_SomeoneElsesAccount_DeletesNothing(string ownerId)
+ {
+ _customerServiceMock.Setup(c => c.GetCustomerById("victim"))
+ .ReturnsAsync(new Customer { Id = "victim", OwnerId = ownerId });
+
+ var (success, error) = await _handler.Handle(Command("victim"), default);
+
+ Assert.IsFalse(success);
+ Assert.AreEqual("Account.SubAccounts.NotOwner", error);
+ _customerServiceMock.Verify(c => c.DeleteCustomer(It.IsAny(), It.IsAny()), Times.Never);
+ }
+
+ [TestMethod]
+ public async Task Handle_MissingOrDeletedAccount_GivesTheSameAnswer()
+ {
+ _customerServiceMock.Setup(c => c.GetCustomerById("gone"))
+ .ReturnsAsync(new Customer { Id = "gone", OwnerId = "owner", Deleted = true });
+
+ var deleted = await _handler.Handle(Command("gone"), default);
+ var missing = await _handler.Handle(Command("missing"), default);
+
+ Assert.AreEqual(missing, deleted);
+ Assert.IsFalse(deleted.success);
+ _customerServiceMock.Verify(c => c.DeleteCustomer(It.IsAny(), It.IsAny()), Times.Never);
+ }
+}
diff --git a/src/Tests/Grand.Web.Tests/Commands/Customers/SubAccountEditCommandHandlerTests.cs b/src/Tests/Grand.Web.Tests/Commands/Customers/SubAccountEditCommandHandlerTests.cs
index 39337c109..aa09ae098 100644
--- a/src/Tests/Grand.Web.Tests/Commands/Customers/SubAccountEditCommandHandlerTests.cs
+++ b/src/Tests/Grand.Web.Tests/Commands/Customers/SubAccountEditCommandHandlerTests.cs
@@ -6,6 +6,7 @@
using Grand.Web.Models.Customer;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
+using System.Linq.Expressions;
namespace Grand.Web.Tests.Commands.Customers;
@@ -14,6 +15,7 @@ public class SubAccountEditCommandHandlerTests
{
private Mock _customerServiceMock;
private Mock _customerManagerServiceMock;
+ private CustomerSettings _customerSettings;
private SubAccountEditCommandHandler _handler;
[TestInitialize]
@@ -21,25 +23,44 @@ public void Init()
{
_customerServiceMock = new Mock();
_customerManagerServiceMock = new Mock();
+ _customerSettings = new CustomerSettings { AllowUsersToChangeEmail = true };
_handler = new SubAccountEditCommandHandler(_customerServiceMock.Object,
- _customerManagerServiceMock.Object, new CustomerSettings { AllowUsersToChangeEmail = true });
+ _customerManagerServiceMock.Object, _customerSettings);
}
- [TestMethod]
- public async Task Handle_WithPassword_ChangesPasswordScopedToCustomerStore()
+ private Customer SubAccount(string ownerId = "owner")
{
- var customer = new Customer { Id = "c1", Email = "sub@x.com", StoreId = "store-9" };
+ var customer = new Customer {
+ Id = "c1", Email = "sub@x.com", Username = "sub@x.com", StoreId = "store-9", OwnerId = ownerId
+ };
_customerServiceMock.Setup(c => c.GetCustomerById("c1")).ReturnsAsync(customer);
+ return customer;
+ }
- var command = new SubAccountEditCommand {
- CurrentCustomer = new Customer { Id = "owner" },
- EditModel = new SubAccountEditModel {
- Id = "c1", Email = "sub@x.com", Password = "newpass", Active = true,
- FirstName = "John", LastName = "Doe"
- }
- };
+ private static SubAccountEditCommand Command(string email = "sub@x.com", string password = null) => new() {
+ CurrentCustomer = new Customer { Id = "owner" },
+ EditModel = new SubAccountEditModel {
+ Id = "c1", Email = email, Password = password, Active = true, FirstName = "John", LastName = "Doe"
+ }
+ };
+
+ private void VerifyNothingWritten()
+ {
+ _customerServiceMock.Verify(c => c.UpdateCustomerField(It.IsAny(),
+ It.IsAny>>(), It.IsAny()), Times.Never);
+ _customerServiceMock.Verify(c => c.UpdateActive(It.IsAny()), Times.Never);
+ _customerServiceMock.Verify(c => c.UpdateUserField(It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny()), Times.Never);
+ _customerManagerServiceMock.Verify(m => m.ChangePassword(It.IsAny(),
+ It.IsAny()), Times.Never);
+ }
+
+ [TestMethod]
+ public async Task Handle_WithPassword_ChangesPasswordScopedToCustomerStore()
+ {
+ SubAccount();
- var result = await _handler.Handle(command, default);
+ var result = await _handler.Handle(Command(password: "newpass"), default);
Assert.IsTrue(result);
//the sub-account's store must scope the password change (safe with or without per-store identity)
@@ -50,18 +71,127 @@ public async Task Handle_WithPassword_ChangesPasswordScopedToCustomerStore()
[TestMethod]
public async Task Handle_WithoutPassword_DoesNotChangePassword()
{
- var customer = new Customer { Id = "c1", Email = "sub@x.com", StoreId = "store-9" };
- _customerServiceMock.Setup(c => c.GetCustomerById("c1")).ReturnsAsync(customer);
-
- var command = new SubAccountEditCommand {
- CurrentCustomer = new Customer { Id = "owner" },
- EditModel = new SubAccountEditModel { Id = "c1", Email = "sub@x.com", Active = true }
- };
+ SubAccount();
- var result = await _handler.Handle(command, default);
+ var result = await _handler.Handle(Command(), default);
Assert.IsTrue(result);
_customerManagerServiceMock.Verify(m => m.ChangePassword(
It.IsAny(), It.IsAny()), Times.Never);
}
+
+ [TestMethod]
+ public async Task Handle_SomeoneElsesAccount_ChangesNothing()
+ {
+ SubAccount(ownerId: "another-owner");
+
+ var result = await _handler.Handle(Command(email: "taken@x.com", password: "newpass"), default);
+
+ Assert.IsFalse(result);
+ VerifyNothingWritten();
+ }
+
+ [TestMethod]
+ public async Task Handle_AccountWithoutOwner_ChangesNothing()
+ {
+ //any main account's id, the current owner's own included: it has no owner, so it is nobody's sub-account
+ SubAccount(ownerId: "");
+
+ var result = await _handler.Handle(Command(), default);
+
+ Assert.IsFalse(result);
+ VerifyNothingWritten();
+ }
+
+ [TestMethod]
+ public async Task Handle_DeletedAccount_ChangesNothing()
+ {
+ SubAccount().Deleted = true;
+
+ var result = await _handler.Handle(Command(), default);
+
+ Assert.IsFalse(result);
+ VerifyNothingWritten();
+ }
+
+ [TestMethod]
+ public async Task Handle_MissingAccount_ChangesNothing()
+ {
+ var result = await _handler.Handle(Command(), default);
+
+ Assert.IsFalse(result);
+ VerifyNothingWritten();
+ }
+
+ [TestMethod]
+ public async Task Handle_MixedCaseEmail_IsStoredLowercased()
+ {
+ //stored as typed, the lowercased login lookup missed it and the sub-account could no longer sign in
+ var customer = SubAccount();
+
+ await _handler.Handle(Command(email: " New.Sub@X.com "), default);
+
+ Assert.AreEqual("new.sub@x.com", customer.Email);
+ _customerServiceMock.Verify(c => c.UpdateCustomerField(customer,
+ It.IsAny>>(), "new.sub@x.com"), Times.AtLeastOnce);
+ }
+
+ [TestMethod]
+ public async Task Handle_NewEmailAndPassword_ChangesPasswordOfTheNewEmail()
+ {
+ SubAccount();
+
+ await _handler.Handle(Command(email: "New@X.com", password: "newpass"), default);
+
+ _customerManagerServiceMock.Verify(m => m.ChangePassword(
+ It.Is(r => r.Email == "new@x.com"), "store-9"), Times.Once);
+ }
+
+ [TestMethod]
+ public async Task Handle_NewEmail_UsernamesDisabled_KeepsUsernameEqualToEmail()
+ {
+ var customer = SubAccount();
+
+ await _handler.Handle(Command(email: "new@x.com"), default);
+
+ Assert.AreEqual("new@x.com", customer.Username);
+ }
+
+ [TestMethod]
+ public async Task Handle_NewEmail_UsernamesEnabled_LeavesUsername()
+ {
+ _customerSettings.UsernamesEnabled = true;
+ var customer = SubAccount();
+
+ await _handler.Handle(Command(email: "new@x.com"), default);
+
+ Assert.AreEqual("new@x.com", customer.Email);
+ Assert.AreEqual("sub@x.com", customer.Username);
+ }
+
+ [TestMethod]
+ public async Task Handle_EmailStoredInMixedCaseByOlderEdits_IsRepairedEvenWhenChangesAreNotAllowed()
+ {
+ //such a row cannot sign in, and its password change looked it up by that e-mail and threw
+ _customerSettings.AllowUsersToChangeEmail = false;
+ var customer = SubAccount();
+ customer.Email = "Sub@X.com";
+
+ await _handler.Handle(Command(email: "Sub@X.com", password: "newpass"), default);
+
+ Assert.AreEqual("sub@x.com", customer.Email);
+ _customerManagerServiceMock.Verify(m => m.ChangePassword(
+ It.Is(r => r.Email == "sub@x.com"), "store-9"), Times.Once);
+ }
+
+ [TestMethod]
+ public async Task Handle_EmailChangeNotAllowed_KeepsEmail()
+ {
+ _customerSettings.AllowUsersToChangeEmail = false;
+ var customer = SubAccount();
+
+ await _handler.Handle(Command(email: "new@x.com"), default);
+
+ Assert.AreEqual("sub@x.com", customer.Email);
+ }
}
diff --git a/src/Tests/Grand.Web.Tests/Commands/Customers/UpdateCustomerInfoCommandHandlerTests.cs b/src/Tests/Grand.Web.Tests/Commands/Customers/UpdateCustomerInfoCommandHandlerTests.cs
index b5b033ed8..3970c8b70 100644
--- a/src/Tests/Grand.Web.Tests/Commands/Customers/UpdateCustomerInfoCommandHandlerTests.cs
+++ b/src/Tests/Grand.Web.Tests/Commands/Customers/UpdateCustomerInfoCommandHandlerTests.cs
@@ -10,6 +10,7 @@
using Grand.Web.Models.Customer;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
+using System.Linq.Expressions;
namespace Grand.Web.Tests.Commands.Customers;
@@ -18,17 +19,19 @@ public class UpdateCustomerInfoCommandHandlerTests
{
private Mock _customerService;
- private async Task Update(OidcLoginConfig config)
+ private async Task Update(OidcLoginConfig config, CustomerSettings settings = null,
+ Customer customer = null, string email = "new@x.com", string username = null)
{
_customerService = new Mock();
var handler = new UpdateCustomerInfoCommandHandler(new Mock().Object,
new Mock().Object, new Mock().Object,
- _customerService.Object, new CustomerSettings { AllowUsersToChangeEmail = true }, new TaxSettings(), config);
- var customer = new Customer { Id = "c1", Email = "old@x.com" };
+ _customerService.Object, settings ?? new CustomerSettings { AllowUsersToChangeEmail = true },
+ new TaxSettings(), config);
+ customer ??= new Customer { Id = "c1", Email = "old@x.com", Username = "old@x.com" };
await handler.Handle(new UpdateCustomerInfoCommand {
Customer = customer,
Store = new Domain.Stores.Store { Id = "s1" },
- Model = new CustomerInfoModel { Email = "new@x.com", FirstName = "A", LastName = "B" },
+ Model = new CustomerInfoModel { Email = email, Username = username, FirstName = "A", LastName = "B" },
CustomerAttributes = []
}, CancellationToken.None);
return customer;
@@ -47,4 +50,77 @@ public async Task UpdateInfo_Mixed_EmailChanged()
var customer = await Update(new OidcLoginConfig());
Assert.AreEqual("new@x.com", customer.Email);
}
+
+ [TestMethod]
+ public async Task UpdateInfo_MixedCaseEmail_IsStoredLowercased()
+ {
+ //stored as typed, the lowercased sign-in lookup missed it and the customer could no longer sign in
+ var customer = await Update(new OidcLoginConfig(), email: " New.Me@X.COM ");
+
+ Assert.AreEqual("new.me@x.com", customer.Email);
+ _customerService.Verify(c => c.UpdateCustomerField(customer,
+ It.IsAny>>(), "new.me@x.com"), Times.AtLeastOnce);
+ }
+
+ [TestMethod]
+ public async Task UpdateInfo_NewEmail_UsernamesDisabled_UsernameFollows()
+ {
+ var customer = await Update(new OidcLoginConfig());
+
+ Assert.AreEqual("new@x.com", customer.Username);
+ }
+
+ [TestMethod]
+ public async Task UpdateInfo_SameEmailInOtherCase_ChangesNothing()
+ {
+ var customer = await Update(new OidcLoginConfig(), email: "OLD@X.com");
+
+ Assert.AreEqual("old@x.com", customer.Email);
+ _customerService.Verify(c => c.UpdateCustomerField(It.IsAny(),
+ It.IsAny>>(), It.IsAny()), Times.Never);
+ }
+
+ [TestMethod]
+ [DataRow(false, OidcSignInMode.Mixed)]
+ [DataRow(true, OidcSignInMode.ProviderOnly)]
+ public async Task UpdateInfo_EmailStoredInMixedCaseByOlderEdits_IsRepaired(bool allowChange, OidcSignInMode mode)
+ {
+ //the same address, so repaired even where changing the e-mail is not allowed
+ var customer = await Update(new OidcLoginConfig { SignInMode = mode },
+ new CustomerSettings { AllowUsersToChangeEmail = allowChange },
+ new Customer { Id = "c1", Email = "Old@X.com", Username = "Old@X.com" }, email: "Old@X.com");
+
+ Assert.AreEqual("old@x.com", customer.Email);
+ }
+
+ [TestMethod]
+ public async Task UpdateInfo_EmailChangeNotAllowed_OtherAddressIsNotTaken()
+ {
+ var customer = await Update(new OidcLoginConfig(), new CustomerSettings { AllowUsersToChangeEmail = false });
+
+ Assert.AreEqual("old@x.com", customer.Email);
+ }
+
+ [TestMethod]
+ public async Task UpdateInfo_MixedCaseUsername_IsStoredLowercased()
+ {
+ var settings = new CustomerSettings { UsernamesEnabled = true, AllowUsersToChangeUsernames = true };
+ var customer = await Update(new OidcLoginConfig(), settings,
+ new Customer { Id = "c1", Email = "old@x.com", Username = "old" }, email: "old@x.com", username: " John ");
+
+ Assert.AreEqual("john", customer.Username);
+ //with usernames on, the e-mail does not drive the username
+ Assert.AreEqual("old@x.com", customer.Email);
+ }
+
+ [TestMethod]
+ public async Task UpdateInfo_SameUsernameInOtherCase_ChangesNothing()
+ {
+ var settings = new CustomerSettings { UsernamesEnabled = true, AllowUsersToChangeUsernames = true };
+ await Update(new OidcLoginConfig(), settings,
+ new Customer { Id = "c1", Email = "old@x.com", Username = "john" }, email: "old@x.com", username: "John");
+
+ _customerService.Verify(c => c.UpdateCustomerField(It.IsAny