[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
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:
@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user