Skip to content

Commit 2eba3f3

Browse files
Keep sub-account edits on the owner's accounts and change passwords on the account (2.4.x) (#1008)
1 parent 019fd31 commit 2eba3f3

16 files changed

Lines changed: 708 additions & 126 deletions

File tree

‎src/Business/Grand.Business.Core/Interfaces/Customers/ICustomerManagerService.cs‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,4 +39,14 @@ public interface ICustomerManagerService
3939
/// <param name="request">Request</param>
4040
/// <param name="storeId">Store identifier - used to resolve the customer when per-store customer identity is enabled</param>
4141
Task ChangePassword(ChangePasswordRequest request, string storeId = "");
42+
43+
/// <summary>
44+
/// Change password of this customer, without resolving the account by e-mail
45+
/// </summary>
46+
/// <param name="customer">Customer</param>
47+
/// <param name="passwordFormat">Password format</param>
48+
/// <param name="newPassword">New password</param>
49+
/// <remarks>The default body keeps implementations built for an earlier 2.4 release loading without a rebuild</remarks>
50+
Task ChangePassword(Customer customer, PasswordFormat passwordFormat, string newPassword) =>
51+
ChangePassword(new ChangePasswordRequest(customer.Email, passwordFormat, newPassword), customer.StoreId);
4252
}

‎src/Business/Grand.Business.Core/Interfaces/Customers/ICustomerService.cs‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,13 @@ Task UpdateCustomerField<T>(string customerId,
190190
/// <param name="customer">Customer</param>
191191
Task UpdateActive(Customer customer);
192192

193+
/// <summary>
194+
/// Updates the password fields of the customer, and nothing else
195+
/// </summary>
196+
/// <param name="customer">Customer</param>
197+
/// <remarks>The default body keeps implementations built for an earlier 2.4 release loading without a rebuild</remarks>
198+
Task UpdateCustomerPassword(Customer customer) => UpdateCustomer(customer);
199+
193200
/// <summary>
194201
/// Update the customer
195202
/// </summary>

‎src/Business/Grand.Business.Customers/Services/CustomerManagerService.cs‎

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -173,33 +173,46 @@ public virtual async Task ChangePassword(ChangePasswordRequest request, string s
173173
ArgumentNullException.ThrowIfNull(request);
174174

175175
var customer = await _customerService.GetCustomerByEmail(request.Email, storeId);
176+
177+
await ChangePassword(customer, request.PasswordFormat, request.NewPassword);
178+
}
179+
180+
/// <summary>
181+
/// Change password of this customer, without resolving the account by e-mail
182+
/// </summary>
183+
/// <param name="customer">Customer</param>
184+
/// <param name="passwordFormat">Password format</param>
185+
/// <param name="newPassword">New password</param>
186+
public virtual async Task ChangePassword(Customer customer, PasswordFormat passwordFormat, string newPassword)
187+
{
176188
ArgumentNullException.ThrowIfNull(customer);
177189

178-
switch (request.PasswordFormat)
190+
switch (passwordFormat)
179191
{
180192
case PasswordFormat.Clear:
181193
{
182-
customer.Password = request.NewPassword;
194+
customer.Password = newPassword;
183195
}
184196
break;
185197
case PasswordFormat.Encrypted:
186198
{
187199
customer.PasswordSalt = CommonHelper.GenerateRandomDigitCode(24);
188-
customer.Password = _encryptionService.EncryptText(request.NewPassword, customer.PasswordSalt);
200+
customer.Password = _encryptionService.EncryptText(newPassword, customer.PasswordSalt);
189201
}
190202
break;
191203
case PasswordFormat.Hashed:
192204
{
193205
//modern self-describing PBKDF2 hash - salt and parameters are embedded in the value itself
194206
customer.PasswordSalt = string.Empty;
195-
customer.Password = _encryptionService.HashPassword(request.NewPassword);
207+
customer.Password = _encryptionService.HashPassword(newPassword);
196208
}
197209
break;
198210
}
199211

200212
customer.PasswordChangeDateUtc = DateTime.UtcNow;
201-
customer.PasswordFormatId = request.PasswordFormat;
202-
await _customerService.UpdateCustomer(customer);
213+
customer.PasswordFormatId = passwordFormat;
214+
//only the password fields: the caller's copy may be older than the account
215+
await _customerService.UpdateCustomerPassword(customer);
203216
//insert password history
204217
await _customerHistoryPasswordService.InsertCustomerPassword(customer);
205218

‎src/Business/Grand.Business.Customers/Services/CustomerService.cs‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -551,6 +551,26 @@ public virtual async Task UpdateCustomerInAdminPanel(Customer customer)
551551
}
552552

553553

554+
public virtual async Task UpdateCustomerPassword(Customer customer)
555+
{
556+
ArgumentNullException.ThrowIfNull(customer);
557+
558+
if (customer.IsSystemAccount)
559+
throw new GrandException(
560+
$"System customer account ({(string.IsNullOrEmpty(customer.SystemName) ? customer.Email : customer.SystemName)}) could not be updated");
561+
562+
var update = UpdateBuilder<Customer>.Create()
563+
.Set(x => x.Password, customer.Password)
564+
.Set(x => x.PasswordSalt, customer.PasswordSalt)
565+
.Set(x => x.PasswordFormatId, customer.PasswordFormatId)
566+
.Set(x => x.PasswordChangeDateUtc, customer.PasswordChangeDateUtc);
567+
568+
await _customerRepository.UpdateOneAsync(x => x.Id == customer.Id, update);
569+
570+
//event notification
571+
await _mediator.EntityUpdated(customer);
572+
}
573+
554574
public virtual async Task UpdateActive(Customer customer)
555575
{
556576
ArgumentNullException.ThrowIfNull(customer);

‎src/Tests/Grand.Business.Customers.Tests/Services/CustomerManagerServiceTests.cs‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,74 @@ public async Task ChangePassword_PassesStoreIdToLookup()
100100
_customerServiceMock.Verify(c => c.GetCustomerByEmail("admin@admin.com", "store-1"), Times.Once);
101101
}
102102

103+
[TestMethod]
104+
public async Task ChangePassword_ForCustomer_ChangesThatCustomerWithoutLookup()
105+
{
106+
//Arrange
107+
var customer = new Customer { Id = "c1", Email = "a@x.com", PasswordFormatId = PasswordFormat.Clear, Password = "123456" };
108+
//Act
109+
await _customerManagerService.ChangePassword(customer, PasswordFormat.Clear, "zxcvbn");
110+
//Assert
111+
Assert.AreEqual("zxcvbn", customer.Password);
112+
_customerServiceMock.Verify(c => c.UpdateCustomerPassword(customer), Times.Once);
113+
//the whole-customer update would write back everything else the caller's copy holds
114+
_customerServiceMock.Verify(c => c.UpdateCustomer(It.IsAny<Customer>()), Times.Never);
115+
_customerServiceMock.Verify(c => c.GetCustomerByEmail(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
116+
}
117+
118+
[TestMethod]
119+
public async Task ChangePassword_ImplementationBuiltForAnEarlierRelease_UsesItsExistingMethod()
120+
{
121+
ICustomerManagerService manager = new EarlierReleaseManager();
122+
123+
await manager.ChangePassword(new Customer { Email = "a@x.com", StoreId = "store-1" }, PasswordFormat.Hashed, "new");
124+
125+
var implementation = (EarlierReleaseManager)manager;
126+
Assert.AreEqual("a@x.com", implementation.Request.Email);
127+
Assert.AreEqual(PasswordFormat.Hashed, implementation.Request.PasswordFormat);
128+
Assert.AreEqual("new", implementation.Request.NewPassword);
129+
Assert.AreEqual("store-1", implementation.StoreId);
130+
}
131+
132+
[TestMethod]
133+
public void NewInterfaceMembers_HaveDefaultBodies_SoExistingImplementationsKeepLoading()
134+
{
135+
Assert.IsFalse(typeof(ICustomerService).GetMethod(nameof(ICustomerService.UpdateCustomerPassword))!.IsAbstract);
136+
Assert.IsFalse(typeof(ICustomerManagerService).GetMethod(nameof(ICustomerManagerService.ChangePassword),
137+
[typeof(Customer), typeof(PasswordFormat), typeof(string)])!.IsAbstract);
138+
}
139+
140+
private sealed class EarlierReleaseManager : ICustomerManagerService
141+
{
142+
public ChangePasswordRequest Request { get; private set; }
143+
public string StoreId { get; private set; }
144+
145+
public Task<CustomerLoginResults> LoginCustomer(string usernameOrEmail, string password, string storeId = "") =>
146+
throw new NotImplementedException();
147+
148+
public Task RegisterCustomer(RegistrationRequest request) => throw new NotImplementedException();
149+
150+
public bool PasswordMatch(PasswordFormat passwordFormat, string oldPassword, string newPassword,
151+
string passwordSalt) => throw new NotImplementedException();
152+
153+
public Task ChangePassword(ChangePasswordRequest request, string storeId = "")
154+
{
155+
Request = request;
156+
StoreId = storeId;
157+
return Task.CompletedTask;
158+
}
159+
}
160+
161+
[TestMethod]
162+
public async Task ChangePassword_UnknownEmail_Throws()
163+
{
164+
//Arrange
165+
var changepassword = new ChangePasswordRequest("nobody@x.com", PasswordFormat.Clear, "zxcvbn");
166+
//Act + Assert
167+
await Assert.ThrowsExactlyAsync<ArgumentNullException>(() =>
168+
_customerManagerService.ChangePassword(changepassword, "store-1"));
169+
}
170+
103171
[TestMethod]
104172
public async Task ChangePasswordTest_Success()
105173
{

‎src/Tests/Grand.Business.Customers.Tests/Services/CustomerServiceTests.cs‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
using Grand.Infrastructure.Configuration;
1010
using Grand.Infrastructure.Tests.Caching;
1111
using Grand.Mediator;
12+
using Grand.SharedKernel;
1213
using Microsoft.VisualStudio.TestTools.UnitTesting;
1314
using Moq;
1415

@@ -474,6 +475,43 @@ public async Task UpdateCustomerinAdminPanelTest()
474475
Assert.AreEqual("store-1", updated.StoreId);
475476
}
476477

478+
[TestMethod]
479+
public async Task UpdateCustomerPassword_WritesOnlyThePasswordFields()
480+
{
481+
//Arrange
482+
await _repository.InsertAsync(new Customer {
483+
Id = "c1", Email = "current@x.com", Username = "current", StoreId = "store-1", Active = false,
484+
Password = "old", PasswordSalt = "salt", PasswordFormatId = PasswordFormat.Clear
485+
});
486+
//taken before the account changed, as a caller holds it during a request
487+
var changedAt = new DateTime(2026, 11, 1, 12, 0, 0, DateTimeKind.Utc);
488+
var snapshot = new Customer {
489+
Id = "c1", Email = "stale@x.com", Username = "stale", StoreId = "store-2", Active = true,
490+
Password = "new", PasswordSalt = "", PasswordFormatId = PasswordFormat.Hashed,
491+
PasswordChangeDateUtc = changedAt
492+
};
493+
//Act
494+
await _customerService.UpdateCustomerPassword(snapshot);
495+
//Assert
496+
var stored = _repository.Table.First(x => x.Id == "c1");
497+
Assert.AreEqual("new", stored.Password);
498+
Assert.AreEqual("", stored.PasswordSalt);
499+
Assert.AreEqual(PasswordFormat.Hashed, stored.PasswordFormatId);
500+
Assert.AreEqual(changedAt, stored.PasswordChangeDateUtc);
501+
Assert.AreEqual("current@x.com", stored.Email);
502+
Assert.AreEqual("current", stored.Username);
503+
Assert.AreEqual("store-1", stored.StoreId);
504+
Assert.IsFalse(stored.Active);
505+
}
506+
507+
[TestMethod]
508+
public async Task UpdateCustomerPassword_SystemAccount_Throws()
509+
{
510+
var customer = new Customer { Id = "c1", IsSystemAccount = true, SystemName = "system" };
511+
512+
await Assert.ThrowsExactlyAsync<GrandException>(() => _customerService.UpdateCustomerPassword(customer));
513+
}
514+
477515
[TestMethod]
478516
public async Task UpdateActiveTest()
479517
{

0 commit comments

Comments
 (0)