[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,115 @@
import { useState } from 'react'
import { useMutation } from '@tanstack/react-query'
import { toast } from 'sonner'
import { KeyRound } from 'lucide-react'
import { Dialog } from '@/components/ui/Dialog'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Label } from '@/components/ui/Label'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
type Props = { open: boolean; onClose: () => void }
// Self-service đổi mật khẩu — user tự đổi (nhập mật khẩu hiện tại để xác thực).
// BE: POST /auth/change-password (Identity verify pass cũ). Khác admin Reset.
export function ChangePasswordDialog({ open, onClose }: Props) {
const [current, setCurrent] = useState('')
const [next, setNext] = useState('')
const [confirm, setConfirm] = useState('')
const close = () => {
setCurrent('')
setNext('')
setConfirm('')
onClose()
}
const mutation = useMutation({
mutationFn: () =>
api.post('/auth/change-password', { currentPassword: current, newPassword: next }),
onSuccess: () => {
toast.success('Đổi mật khẩu thành công. Lần đăng nhập sau dùng mật khẩu mới.')
close()
},
onError: err => toast.error(getErrorMessage(err)),
})
const newTooShort = next.length > 0 && next.length < 12
const sameAsOld = next.length > 0 && next === current
const mismatch = confirm.length > 0 && next !== confirm
const canSubmit =
current.length > 0 && next.length >= 12 && next === confirm && next !== current && !mutation.isPending
const submit = () => {
if (canSubmit) mutation.mutate()
}
return (
<Dialog
open={open}
onClose={close}
size="sm"
title={
<span className="flex items-center gap-2">
<KeyRound className="h-4 w-4" /> Đi mật khẩu
</span>
}
footer={
<>
<Button variant="outline" onClick={close} disabled={mutation.isPending}>
Hủy
</Button>
<Button onClick={submit} disabled={!canSubmit}>
{mutation.isPending ? 'Đang lưu…' : 'Đổi mật khẩu'}
</Button>
</>
}
>
<form
className="space-y-3"
onSubmit={e => {
e.preventDefault()
submit()
}}
>
<div className="space-y-1">
<Label htmlFor="cp-current">Mật khẩu hiện tại</Label>
<Input
id="cp-current"
type="password"
autoComplete="current-password"
value={current}
onChange={e => setCurrent(e.target.value)}
/>
</div>
<div className="space-y-1">
<Label htmlFor="cp-new">Mật khẩu mới</Label>
<Input
id="cp-new"
type="password"
autoComplete="new-password"
value={next}
onChange={e => setNext(e.target.value)}
/>
{newTooShort && <p className="text-xs text-red-600">Mật khẩu mới phải ít nhất 12 tự.</p>}
{sameAsOld && <p className="text-xs text-red-600">Mật khẩu mới phải khác mật khẩu hiện tại.</p>}
</div>
<div className="space-y-1">
<Label htmlFor="cp-confirm">Nhập lại mật khẩu mới</Label>
<Input
id="cp-confirm"
type="password"
autoComplete="new-password"
value={confirm}
onChange={e => setConfirm(e.target.value)}
/>
{mismatch && <p className="text-xs text-red-600">Mật khẩu nhập lại không khớp.</p>}
</div>
<p className="text-xs text-slate-500">Mật khẩu cần tối thiểu 12 tự.</p>
{/* cho phép submit bằng Enter */}
<button type="submit" className="hidden" aria-hidden tabIndex={-1} />
</form>
</Dialog>
)
}

View File

@ -1,12 +1,14 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { ChevronDown, LogOut } from 'lucide-react' import { ChevronDown, LogOut, KeyRound } from 'lucide-react'
import { useAuth } from '@/contexts/AuthContext' import { useAuth } from '@/contexts/AuthContext'
import { ChangePasswordDialog } from '@/components/ChangePasswordDialog'
import { NotificationBell } from '@/components/NotificationBell' import { NotificationBell } from '@/components/NotificationBell'
import { cn } from '@/lib/cn' import { cn } from '@/lib/cn'
function UserMenu() { function UserMenu() {
const { user, logout } = useAuth() const { user, logout } = useAuth()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [pwOpen, setPwOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null) const ref = useRef<HTMLDivElement>(null)
useEffect(() => { useEffect(() => {
@ -53,6 +55,16 @@ function UserMenu() {
</div> </div>
)} )}
</div> </div>
<button
onClick={() => {
setPwOpen(true)
setOpen(false)
}}
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-slate-600 transition hover:bg-slate-50"
>
<KeyRound className="h-4 w-4" />
Đi mật khẩu
</button>
<button <button
onClick={logout} onClick={logout}
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-slate-600 transition hover:bg-slate-50" className="flex w-full items-center gap-2 px-4 py-2 text-sm text-slate-600 transition hover:bg-slate-50"
@ -62,6 +74,7 @@ function UserMenu() {
</button> </button>
</div> </div>
)} )}
<ChangePasswordDialog open={pwOpen} onClose={() => setPwOpen(false)} />
</div> </div>
) )
} }

View File

@ -0,0 +1,115 @@
import { useState } from 'react'
import { useMutation } from '@tanstack/react-query'
import { toast } from 'sonner'
import { KeyRound } from 'lucide-react'
import { Dialog } from '@/components/ui/Dialog'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Label } from '@/components/ui/Label'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
type Props = { open: boolean; onClose: () => void }
// Self-service đổi mật khẩu — user tự đổi (nhập mật khẩu hiện tại để xác thực).
// BE: POST /auth/change-password (Identity verify pass cũ). Khác admin Reset.
export function ChangePasswordDialog({ open, onClose }: Props) {
const [current, setCurrent] = useState('')
const [next, setNext] = useState('')
const [confirm, setConfirm] = useState('')
const close = () => {
setCurrent('')
setNext('')
setConfirm('')
onClose()
}
const mutation = useMutation({
mutationFn: () =>
api.post('/auth/change-password', { currentPassword: current, newPassword: next }),
onSuccess: () => {
toast.success('Đổi mật khẩu thành công. Lần đăng nhập sau dùng mật khẩu mới.')
close()
},
onError: err => toast.error(getErrorMessage(err)),
})
const newTooShort = next.length > 0 && next.length < 12
const sameAsOld = next.length > 0 && next === current
const mismatch = confirm.length > 0 && next !== confirm
const canSubmit =
current.length > 0 && next.length >= 12 && next === confirm && next !== current && !mutation.isPending
const submit = () => {
if (canSubmit) mutation.mutate()
}
return (
<Dialog
open={open}
onClose={close}
size="sm"
title={
<span className="flex items-center gap-2">
<KeyRound className="h-4 w-4" /> Đi mật khẩu
</span>
}
footer={
<>
<Button variant="outline" onClick={close} disabled={mutation.isPending}>
Hủy
</Button>
<Button onClick={submit} disabled={!canSubmit}>
{mutation.isPending ? 'Đang lưu…' : 'Đổi mật khẩu'}
</Button>
</>
}
>
<form
className="space-y-3"
onSubmit={e => {
e.preventDefault()
submit()
}}
>
<div className="space-y-1">
<Label htmlFor="cp-current">Mật khẩu hiện tại</Label>
<Input
id="cp-current"
type="password"
autoComplete="current-password"
value={current}
onChange={e => setCurrent(e.target.value)}
/>
</div>
<div className="space-y-1">
<Label htmlFor="cp-new">Mật khẩu mới</Label>
<Input
id="cp-new"
type="password"
autoComplete="new-password"
value={next}
onChange={e => setNext(e.target.value)}
/>
{newTooShort && <p className="text-xs text-red-600">Mật khẩu mới phải ít nhất 12 tự.</p>}
{sameAsOld && <p className="text-xs text-red-600">Mật khẩu mới phải khác mật khẩu hiện tại.</p>}
</div>
<div className="space-y-1">
<Label htmlFor="cp-confirm">Nhập lại mật khẩu mới</Label>
<Input
id="cp-confirm"
type="password"
autoComplete="new-password"
value={confirm}
onChange={e => setConfirm(e.target.value)}
/>
{mismatch && <p className="text-xs text-red-600">Mật khẩu nhập lại không khớp.</p>}
</div>
<p className="text-xs text-slate-500">Mật khẩu cần tối thiểu 12 tự.</p>
{/* cho phép submit bằng Enter */}
<button type="submit" className="hidden" aria-hidden tabIndex={-1} />
</form>
</Dialog>
)
}

View File

@ -1,12 +1,14 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { ChevronDown, LogOut } from 'lucide-react' import { ChevronDown, LogOut, KeyRound } from 'lucide-react'
import { useAuth } from '@/contexts/AuthContext' import { useAuth } from '@/contexts/AuthContext'
import { ChangePasswordDialog } from '@/components/ChangePasswordDialog'
import { NotificationBell } from '@/components/NotificationBell' import { NotificationBell } from '@/components/NotificationBell'
import { cn } from '@/lib/cn' import { cn } from '@/lib/cn'
function UserMenu() { function UserMenu() {
const { user, logout } = useAuth() const { user, logout } = useAuth()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [pwOpen, setPwOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null) const ref = useRef<HTMLDivElement>(null)
useEffect(() => { useEffect(() => {
@ -53,6 +55,16 @@ function UserMenu() {
</div> </div>
)} )}
</div> </div>
<button
onClick={() => {
setPwOpen(true)
setOpen(false)
}}
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-slate-600 transition hover:bg-slate-50"
>
<KeyRound className="h-4 w-4" />
Đi mật khẩu
</button>
<button <button
onClick={logout} onClick={logout}
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-slate-600 transition hover:bg-slate-50" className="flex w-full items-center gap-2 px-4 py-2 text-sm text-slate-600 transition hover:bg-slate-50"
@ -62,6 +74,7 @@ function UserMenu() {
</button> </button>
</div> </div>
)} )}
<ChangePasswordDialog open={pwOpen} onClose={() => setPwOpen(false)} />
</div> </div>
) )
} }

View File

@ -1,6 +1,7 @@
using MediatR; using MediatR;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using SolutionErp.Application.Auth.Commands.ChangePassword;
using SolutionErp.Application.Auth.Commands.Login; using SolutionErp.Application.Auth.Commands.Login;
using SolutionErp.Application.Auth.Commands.Refresh; using SolutionErp.Application.Auth.Commands.Refresh;
using SolutionErp.Application.Auth.Dtos; using SolutionErp.Application.Auth.Dtos;
@ -38,4 +39,13 @@ public class AuthController : ControllerBase
{ {
return NoContent(); return NoContent();
} }
// Self-service: user đang đăng nhập tự đổi mật khẩu (xác thực mật khẩu hiện tại).
[HttpPost("change-password")]
[Authorize]
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordCommand command, CancellationToken ct)
{
await _mediator.Send(command, ct);
return NoContent();
}
} }

View File

@ -0,0 +1,58 @@
using FluentValidation;
using MediatR;
using Microsoft.AspNetCore.Identity;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Domain.Identity;
namespace SolutionErp.Application.Auth.Commands.ChangePassword;
// Self-service đổi mật khẩu — user đang đăng nhập TỰ đổi, BẮT BUỘC nhập mật khẩu hiện tại
// để xác thực. Khác ResetPasswordCommand (admin đặt pass hộ theo userId, KHÔNG cần pass cũ).
public record ChangePasswordCommand(string CurrentPassword, string NewPassword) : IRequest;
public class ChangePasswordCommandValidator : AbstractValidator<ChangePasswordCommand>
{
public ChangePasswordCommandValidator()
{
RuleFor(x => x.CurrentPassword)
.NotEmpty().WithMessage("Vui lòng nhập mật khẩu hiện tại.");
RuleFor(x => x.NewPassword)
.NotEmpty().WithMessage("Vui lòng nhập mật khẩu mới.")
.MinimumLength(12).WithMessage("Mật khẩu mới phải có ít nhất 12 ký tự.");
RuleFor(x => x.NewPassword)
.NotEqual(x => x.CurrentPassword).WithMessage("Mật khẩu mới phải khác mật khẩu hiện tại.")
.When(x => !string.IsNullOrEmpty(x.NewPassword) && !string.IsNullOrEmpty(x.CurrentPassword));
}
}
public class ChangePasswordCommandHandler(
UserManager<User> userManager,
ICurrentUser currentUser) : IRequestHandler<ChangePasswordCommand>
{
public async Task Handle(ChangePasswordCommand request, CancellationToken ct)
{
if (!currentUser.IsAuthenticated || currentUser.UserId is null)
throw new UnauthorizedException();
var user = await userManager.FindByIdAsync(currentUser.UserId.Value.ToString())
?? throw new UnauthorizedException();
// Identity tự verify CurrentPassword — sai mật khẩu cũ => result.Errors có code "PasswordMismatch".
// Luật mật khẩu mới (độ dài, ...) cũng do Identity + validator ở trên kiểm.
var result = await userManager.ChangePasswordAsync(user, request.CurrentPassword, request.NewPassword);
if (!result.Succeeded)
{
var failures = result.Errors.Select(e => new FluentValidation.Results.ValidationFailure(
e.Code == "PasswordMismatch" ? nameof(request.CurrentPassword) : nameof(request.NewPassword),
e.Code == "PasswordMismatch" ? "Mật khẩu hiện tại không đúng." : e.Description));
throw new SolutionErp.Application.Common.Exceptions.ValidationException(failures);
}
// Vô hiệu refresh token (mirror ResetPasswordCommand) → các phiên hiện có sẽ phải
// đăng nhập lại bằng mật khẩu mới khi access token hết hạn.
user.RefreshToken = null;
user.RefreshTokenExpiresAt = null;
await userManager.UpdateAsync(user);
}
}

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));
}
}