[CLAUDE] Auth: user tự đổi mật khẩu (self-service change-password) — BE + 2 FE + test
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m10s

Go-live request anh Kiệt FDC (Zalo "user chưa tự đổi pass được hả em"): trước đây chỉ admin
Reset hộ (POST users/{id}/reset-password), user KHÔNG tự đổi được. Thêm self-service:

- BE: ChangePasswordCommand + POST /api/auth/change-password ([Authorize]). Lấy user qua
  ICurrentUser.UserId; UserManager.ChangePasswordAsync verify mật khẩu HIỆN TẠI; sai →
  ValidationException field CurrentPassword "Mật khẩu hiện tại không đúng."; rule mật khẩu mới
  ≥ 12 + khác mật khẩu cũ (validator); sau đổi vô hiệu refresh token (mirror ResetPassword).
  Qualify SolutionErp...ValidationException (tránh CS0104 clash với FluentValidation).
- FE 2 app (SHA-mirror): ChangePasswordDialog (3 ô current/new/confirm + validate client:
  ≥12, khớp, khác cũ) wire vào menu tài khoản TopBar — "Đổi mật khẩu" trên "Đăng xuất".
- Test: +ChangePasswordCommandTests (đúng/sai pass cũ keyed-field + msg, <12 boundary, ==cũ,
  chưa-auth/UserId-null/user-not-found, RefreshToken cleared). Full suite 431 PASS (45D + 386I).

Build: BE 0 warn/0 err · fe-user + fe-admin build OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-06-26 12:27:52 +07:00
parent 55494ad477
commit e81e87ff9f
7 changed files with 569 additions and 2 deletions

View File

@ -0,0 +1,243 @@
using FluentAssertions;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;
using SolutionErp.Application.Auth.Commands.ChangePassword;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Domain.Identity;
using SolutionErp.Infrastructure.Tests.Common;
using AppValidationException = SolutionErp.Application.Common.Exceptions.ValidationException;
using AppUnauthorizedException = SolutionErp.Application.Common.Exceptions.UnauthorizedException;
namespace SolutionErp.Infrastructure.Tests.Application;
// ===== NEW (go-live 2026-06-26) — Self-service đổi mật khẩu =====
// ChangePasswordCommand.cs (Application/Auth/Commands/ChangePassword).
// Security-sensitive (xử lý mật khẩu) → test-before-merge per docs/rules.md §7.
// Test theo CODE đã land (S34 rule — KHÔNG touch production).
//
// Spec handler:
// - !IsAuthenticated || UserId is null → UnauthorizedException.
// - FindByIdAsync(UserId) null → UnauthorizedException.
// - userManager.ChangePasswordAsync fail → ValidationException (CUSTOM app
// exception, Errors là Dictionary<string,string[]> key=PropertyName):
// code "PasswordMismatch" → key "CurrentPassword" ("Mật khẩu hiện tại không đúng.")
// lỗi khác → key "NewPassword".
// - Success → user.RefreshToken=null + RefreshTokenExpiresAt=null + UpdateAsync.
//
// Validator (test trực tiếp .Validate() — mirror PeSuggestedPriceSetterAuthzTests):
// CurrentPassword NotEmpty; NewPassword NotEmpty + MinimumLength(12);
// NewPassword NotEqual CurrentPassword.
//
// ⚠️ GOTCHA harness: IdentityFixture cấu hình Password.RequiredLength=4 + tắt
// mọi complexity rule (NV codegen tests dùng "Test@123"). Nên ràng-buộc-độ-dài-12
// là DO VALIDATOR enforce, KHÔNG phải Identity ở tầng test → case "<12" test ở
// validator (Identity tầng này sẽ pass length=4). "PasswordMismatch" thì Identity
// VẪN verify mật khẩu cũ bất kể RequiredLength → test #2 đi qua handler thật.
//
// ⚠️ Tạo user bằng UserManager.CreateAsync(user, "<pass cụ thể>") trực tiếp (KHÔNG
// dùng fix.CreateUserAsync — helper đó hardcode "Test@123", không kiểm soát được
// pass cũ cần cho đổi-mật-khẩu).
public class ChangePasswordCommandTests
{
private const string OldPassword = "OldPass@12345"; // ≥12 ký tự
private const string NewPassword = "NewPass@12345"; // ≥12 ký tự, khác pass cũ
// ICurrentUser stub — UserId settable + IsAuthenticated cấu hình riêng để
// mô phỏng cả nhánh "chưa đăng nhập" (IsAuthenticated=false) lẫn "UserId null".
private sealed class FakeCurrentUser : ICurrentUser
{
public Guid? UserId { get; init; }
public string? Email { get; init; } = "actor@test.local";
public string? FullName { get; init; } = "Actor Test";
public IReadOnlyList<string> Roles { get; init; } = Array.Empty<string>();
public bool IsAuthenticated { get; init; } = true;
}
// Tạo user với mật khẩu cụ thể (faithful cho password-change test).
private static async Task<User> CreateUserWithPasswordAsync(
IdentityFixture fix, string password, string email = "changepass@test.local")
{
var um = fix.Services.GetRequiredService<UserManager<User>>();
var user = new User
{
Id = Guid.NewGuid(),
UserName = email,
Email = email,
EmailConfirmed = true,
FullName = "Đổi Mật Khẩu",
IsActive = true,
// Pre-seed refresh token để chứng minh handler XÓA nó khi đổi pass thành công.
RefreshToken = "stale-refresh-token",
RefreshTokenExpiresAt = DateTime.UtcNow.AddDays(7),
};
var created = await um.CreateAsync(user, password);
created.Succeeded.Should().BeTrue(
"seed user phải tạo được: " + string.Join(",", created.Errors.Select(e => e.Description)));
return user;
}
// ====================================================================
// ===== Handler =====
// ====================================================================
// 1. Đổi mật khẩu thành công → KHÔNG throw; pass mới hiệu lực + refresh token xóa.
[Fact]
public async Task Handle_CurrentCorrect_NewValid_ChangesPasswordAndClearsRefreshToken()
{
using var fix = new IdentityFixture();
var um = fix.Services.GetRequiredService<UserManager<User>>();
var user = await CreateUserWithPasswordAsync(fix, OldPassword);
var handler = new ChangePasswordCommandHandler(
um, new FakeCurrentUser { UserId = user.Id });
var act = async () => await handler.Handle(
new ChangePasswordCommand(OldPassword, NewPassword), CancellationToken.None);
await act.Should().NotThrowAsync();
// Re-load sạch để verify state đã persist.
var reloaded = await um.FindByIdAsync(user.Id.ToString());
reloaded.Should().NotBeNull();
(await um.CheckPasswordAsync(reloaded!, NewPassword)).Should().BeTrue("pass mới phải hiệu lực");
(await um.CheckPasswordAsync(reloaded!, OldPassword)).Should().BeFalse("pass cũ phải hết hiệu lực");
reloaded!.RefreshToken.Should().BeNull("đổi pass phải vô hiệu refresh token");
reloaded.RefreshTokenExpiresAt.Should().BeNull();
}
// 2. Mật khẩu hiện tại SAI → ValidationException (custom), failure key chứa "CurrentPassword".
[Fact]
public async Task Handle_WrongCurrentPassword_ThrowsValidationOnCurrentPassword()
{
using var fix = new IdentityFixture();
var um = fix.Services.GetRequiredService<UserManager<User>>();
var user = await CreateUserWithPasswordAsync(fix, OldPassword);
var handler = new ChangePasswordCommandHandler(
um, new FakeCurrentUser { UserId = user.Id });
var act = async () => await handler.Handle(
new ChangePasswordCommand("WrongCurrent@999", NewPassword), CancellationToken.None);
var ex = (await act.Should().ThrowAsync<AppValidationException>()).Which;
ex.Errors.Keys.Should().Contain(nameof(ChangePasswordCommand.CurrentPassword));
ex.Errors[nameof(ChangePasswordCommand.CurrentPassword)]
.Should().Contain("Mật khẩu hiện tại không đúng.");
// Pass cũ KHÔNG bị đổi (fail-safe) + refresh token còn nguyên (handler thoát trước khi clear).
var reloaded = await um.FindByIdAsync(user.Id.ToString());
(await um.CheckPasswordAsync(reloaded!, OldPassword)).Should().BeTrue();
reloaded!.RefreshToken.Should().Be("stale-refresh-token");
}
// 5. Chưa đăng nhập (IsAuthenticated=false) → UnauthorizedException trước mọi side-effect.
[Fact]
public async Task Handle_NotAuthenticated_ThrowsUnauthorized()
{
using var fix = new IdentityFixture();
var um = fix.Services.GetRequiredService<UserManager<User>>();
var user = await CreateUserWithPasswordAsync(fix, OldPassword);
// IsAuthenticated=false dù UserId có giá trị → guard đầu tiên phải bắt.
var handler = new ChangePasswordCommandHandler(
um, new FakeCurrentUser { UserId = user.Id, IsAuthenticated = false });
var act = async () => await handler.Handle(
new ChangePasswordCommand(OldPassword, NewPassword), CancellationToken.None);
await act.Should().ThrowAsync<AppUnauthorizedException>();
// Không có side-effect: pass cũ giữ nguyên.
var reloaded = await um.FindByIdAsync(user.Id.ToString());
(await um.CheckPasswordAsync(reloaded!, OldPassword)).Should().BeTrue();
}
// 5-bis. UserId null (kể cả IsAuthenticated=true) → UnauthorizedException.
[Fact]
public async Task Handle_NullUserId_ThrowsUnauthorized()
{
using var fix = new IdentityFixture();
var um = fix.Services.GetRequiredService<UserManager<User>>();
var handler = new ChangePasswordCommandHandler(
um, new FakeCurrentUser { UserId = null, IsAuthenticated = true });
var act = async () => await handler.Handle(
new ChangePasswordCommand(OldPassword, NewPassword), CancellationToken.None);
await act.Should().ThrowAsync<AppUnauthorizedException>();
}
// 5-ter. Authenticated nhưng user không tồn tại (UserId lạ) → UnauthorizedException (FindByIdAsync null).
[Fact]
public async Task Handle_UserNotFound_ThrowsUnauthorized()
{
using var fix = new IdentityFixture();
var um = fix.Services.GetRequiredService<UserManager<User>>();
var handler = new ChangePasswordCommandHandler(
um, new FakeCurrentUser { UserId = Guid.NewGuid(), IsAuthenticated = true });
var act = async () => await handler.Handle(
new ChangePasswordCommand(OldPassword, NewPassword), CancellationToken.None);
await act.Should().ThrowAsync<AppUnauthorizedException>();
}
// ====================================================================
// ===== Validator =====
// ====================================================================
// 3. Mật khẩu mới <12 ký tự → validator reject (Identity tầng test pass length=4,
// nên độ-dài-12 là do VALIDATOR enforce).
[Fact]
public void Validator_NewPasswordTooShort_Fails()
{
var validator = new ChangePasswordCommandValidator();
var result = validator.Validate(
new ChangePasswordCommand(OldPassword, NewPassword: "Short@12345")); // 11 ký tự
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.PropertyName == nameof(ChangePasswordCommand.NewPassword));
}
// 3-bis. Đúng 12 ký tự (biên) → hợp lệ về độ dài.
[Fact]
public void Validator_NewPasswordExactly12_PassesLengthRule()
{
var validator = new ChangePasswordCommandValidator();
// "Abcdef@12345" = 12 ký tự, khác CurrentPassword → toàn bộ rule pass.
validator.Validate(new ChangePasswordCommand("Current@9999", "Abcdef@12345"))
.IsValid.Should().BeTrue();
}
// 4. Mật khẩu mới == mật khẩu cũ → validator reject (NotEqual), trên field NewPassword.
[Fact]
public void Validator_NewEqualsCurrent_Fails()
{
var validator = new ChangePasswordCommandValidator();
var result = validator.Validate(
new ChangePasswordCommand(OldPassword, NewPassword: OldPassword));
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e =>
e.PropertyName == nameof(ChangePasswordCommand.NewPassword)
&& e.ErrorMessage == "Mật khẩu mới phải khác mật khẩu hiện tại.");
}
// 4-bis. CurrentPassword rỗng → validator reject (NotEmpty).
[Fact]
public void Validator_EmptyCurrentPassword_Fails()
{
var validator = new ChangePasswordCommandValidator();
var result = validator.Validate(
new ChangePasswordCommand(CurrentPassword: "", NewPassword: NewPassword));
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.PropertyName == nameof(ChangePasswordCommand.CurrentPassword));
}
}