[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:
115
fe-admin/src/components/ChangePasswordDialog.tsx
Normal file
115
fe-admin/src/components/ChangePasswordDialog.tsx
Normal 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 có ít nhất 12 ký 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 ký tự.</p>
|
||||||
|
{/* cho phép submit bằng Enter */}
|
||||||
|
<button type="submit" className="hidden" aria-hidden tabIndex={-1} />
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
115
fe-user/src/components/ChangePasswordDialog.tsx
Normal file
115
fe-user/src/components/ChangePasswordDialog.tsx
Normal 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 có ít nhất 12 ký 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 ký tự.</p>
|
||||||
|
{/* cho phép submit bằng Enter */}
|
||||||
|
<button type="submit" className="hidden" aria-hidden tabIndex={-1} />
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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