Forgot password flow
This commit is contained in:
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Auth\Controllers;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\ResetPasswordAttempt;
|
||||||
|
use App\Domains\Auth\Requests\CreateResetPasswordAttemptRequest;
|
||||||
|
use App\Domains\Auth\Services\ResetPasswordAttemptService;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
class CreateResetPasswordAttemptController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ResetPasswordAttemptService $resetPasswordAttemptService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(CreateResetPasswordAttemptRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$data = $request->validated();
|
||||||
|
|
||||||
|
$this->resetPasswordAttemptService->createForEmail(
|
||||||
|
$data['email'],
|
||||||
|
$data['tenant_codigo'],
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Si el email está registrado, recibirás un código para recuperar tu contraseña.',
|
||||||
|
'status' => ResetPasswordAttempt::STATUS_PENDING,
|
||||||
|
], 202);
|
||||||
|
}
|
||||||
|
}
|
||||||
40
app/Domains/Auth/Controllers/ResetPasswordController.php
Normal file
40
app/Domains/Auth/Controllers/ResetPasswordController.php
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Auth\Controllers;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\ResetPasswordAttempt;
|
||||||
|
use App\Domains\Auth\Requests\ResetPasswordRequest;
|
||||||
|
use App\Domains\Auth\Services\ResetPasswordAttemptService;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
class ResetPasswordController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ResetPasswordAttemptService $resetPasswordAttemptService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws ValidationException
|
||||||
|
*/
|
||||||
|
public function __invoke(ResetPasswordRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$data = $request->validated();
|
||||||
|
|
||||||
|
if (! $this->resetPasswordAttemptService->resetPassword(
|
||||||
|
$data['email'],
|
||||||
|
$data['codigo'],
|
||||||
|
$data['password'],
|
||||||
|
)) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'codigo' => 'La solicitud de recuperación es inválida o ya fue utilizada.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Contraseña modificada correctamente.',
|
||||||
|
'status' => ResetPasswordAttempt::STATUS_USED,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Auth\Controllers;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\ResetPasswordAttempt;
|
||||||
|
use App\Domains\Auth\Requests\ValidateResetPasswordAttemptRequest;
|
||||||
|
use App\Domains\Auth\Services\ResetPasswordAttemptService;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
class ValidateResetPasswordAttemptController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ResetPasswordAttemptService $resetPasswordAttemptService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws ValidationException
|
||||||
|
*/
|
||||||
|
public function __invoke(ValidateResetPasswordAttemptRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$data = $request->validated();
|
||||||
|
|
||||||
|
if (! $this->resetPasswordAttemptService->validateCode(
|
||||||
|
$data['email'],
|
||||||
|
$data['codigo'],
|
||||||
|
)) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'codigo' => 'El código ingresado es inválido.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Código validado correctamente.',
|
||||||
|
'status' => ResetPasswordAttempt::STATUS_VALIDATED,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
36
app/Domains/Auth/Models/ResetPasswordAttempt.php
Normal file
36
app/Domains/Auth/Models/ResetPasswordAttempt.php
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Auth\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
#[Fillable(['user_id', 'codigo', 'status'])]
|
||||||
|
#[Hidden(['codigo'])]
|
||||||
|
class ResetPasswordAttempt extends Model
|
||||||
|
{
|
||||||
|
public const STATUS_PENDING = 'pending';
|
||||||
|
|
||||||
|
public const STATUS_VALIDATED = 'validated';
|
||||||
|
|
||||||
|
public const STATUS_USED = 'used';
|
||||||
|
|
||||||
|
public const STATUS_EXPIRED = 'expired';
|
||||||
|
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'user_id' => 'integer',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<User, $this> */
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ use Database\Factories\UserFactory;
|
|||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
use Laravel\Sanctum\HasApiTokens;
|
use Laravel\Sanctum\HasApiTokens;
|
||||||
@@ -22,6 +23,12 @@ class User extends Authenticatable
|
|||||||
return UserFactory::new();
|
return UserFactory::new();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return HasMany<ResetPasswordAttempt, $this> */
|
||||||
|
public function resetPasswordAttempts(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(ResetPasswordAttempt::class);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, string>
|
* @return array<string, string>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Auth\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class CreateResetPasswordAttemptRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
$email = $this->input('email');
|
||||||
|
|
||||||
|
if (is_string($email)) {
|
||||||
|
$this->merge([
|
||||||
|
'email' => Str::lower(trim($email)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'tenant_codigo' => ['required', 'string', Rule::exists('tenants', 'codigo')],
|
||||||
|
'email' => ['required', 'string', 'email', 'max:255'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
43
app/Domains/Auth/Requests/ResetPasswordRequest.php
Normal file
43
app/Domains/Auth/Requests/ResetPasswordRequest.php
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Auth\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Validation\Rules\Password;
|
||||||
|
|
||||||
|
class ResetPasswordRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
$email = $this->input('email');
|
||||||
|
|
||||||
|
if (is_string($email)) {
|
||||||
|
$this->merge([
|
||||||
|
'email' => Str::lower(trim($email)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'email' => ['required', 'string', 'email', 'max:255'],
|
||||||
|
'codigo' => ['required', 'string', 'regex:/^\d{4}$/'],
|
||||||
|
'password' => [
|
||||||
|
'required',
|
||||||
|
'string',
|
||||||
|
'confirmed',
|
||||||
|
Password::min(8)->mixedCase()->symbols(),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Auth\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class ValidateResetPasswordAttemptRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
$email = $this->input('email');
|
||||||
|
|
||||||
|
if (is_string($email)) {
|
||||||
|
$this->merge([
|
||||||
|
'email' => Str::lower(trim($email)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'email' => ['required', 'string', 'email', 'max:255'],
|
||||||
|
'codigo' => ['required', 'string', 'regex:/^\d{4}$/'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
177
app/Domains/Auth/Services/ResetPasswordAttemptService.php
Normal file
177
app/Domains/Auth/Services/ResetPasswordAttemptService.php
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Auth\Services;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\ResetPasswordAttempt;
|
||||||
|
use App\Domains\Auth\Models\User;
|
||||||
|
use App\Domains\Notification\Events\PasswordResetRequested;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class ResetPasswordAttemptService
|
||||||
|
{
|
||||||
|
public function createForEmail(string $email, string $tenantCode): void
|
||||||
|
{
|
||||||
|
$emailFingerprint = $this->emailFingerprint($email);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$attemptId = DB::transaction(function () use ($email, $emailFingerprint): ?int {
|
||||||
|
$user = User::query()
|
||||||
|
->where('email', $email)
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($user === null) {
|
||||||
|
Log::warning('Password reset attempt was not created because the user was not found.', [
|
||||||
|
'email_fingerprint' => $emailFingerprint,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->resetPasswordAttempts()
|
||||||
|
->whereIn('status', [
|
||||||
|
ResetPasswordAttempt::STATUS_PENDING,
|
||||||
|
ResetPasswordAttempt::STATUS_VALIDATED,
|
||||||
|
])
|
||||||
|
->update(['status' => ResetPasswordAttempt::STATUS_EXPIRED]);
|
||||||
|
|
||||||
|
$attempt = $user->resetPasswordAttempts()->create([
|
||||||
|
'codigo' => $this->generateCode(),
|
||||||
|
'status' => ResetPasswordAttempt::STATUS_PENDING,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $attempt->getKey();
|
||||||
|
});
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
Log::error('Failed to create password reset attempt.', [
|
||||||
|
'email_fingerprint' => $emailFingerprint,
|
||||||
|
'exception' => $exception,
|
||||||
|
]);
|
||||||
|
|
||||||
|
throw $exception;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($attemptId !== null) {
|
||||||
|
try {
|
||||||
|
PasswordResetRequested::dispatch($attemptId, $tenantCode);
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
Log::error('Failed to dispatch password reset email.', [
|
||||||
|
'attempt_id' => $attemptId,
|
||||||
|
'tenant_code' => $tenantCode,
|
||||||
|
'email_fingerprint' => $emailFingerprint,
|
||||||
|
'exception' => $exception,
|
||||||
|
]);
|
||||||
|
|
||||||
|
throw $exception;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function validateCode(string $email, string $code): bool
|
||||||
|
{
|
||||||
|
$emailFingerprint = $this->emailFingerprint($email);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return DB::transaction(function () use ($email, $code, $emailFingerprint): bool {
|
||||||
|
$user = User::query()
|
||||||
|
->where('email', $email)
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$attempt = $user?->resetPasswordAttempts()
|
||||||
|
->where('codigo', $code)
|
||||||
|
->where('status', ResetPasswordAttempt::STATUS_PENDING)
|
||||||
|
->latest('id')
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($attempt === null) {
|
||||||
|
Log::warning('Password reset code validation failed: no matching pending attempt.', [
|
||||||
|
'email_fingerprint' => $emailFingerprint,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$attempt->update([
|
||||||
|
'status' => ResetPasswordAttempt::STATUS_VALIDATED,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
Log::error('Failed to validate password reset code.', [
|
||||||
|
'email_fingerprint' => $emailFingerprint,
|
||||||
|
'exception' => $exception,
|
||||||
|
]);
|
||||||
|
|
||||||
|
throw $exception;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resetPassword(string $email, string $code, string $password): bool
|
||||||
|
{
|
||||||
|
$emailFingerprint = $this->emailFingerprint($email);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return DB::transaction(function () use ($email, $code, $password, $emailFingerprint): bool {
|
||||||
|
$user = User::query()
|
||||||
|
->where('email', $email)
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$attempt = $user?->resetPasswordAttempts()
|
||||||
|
->where('codigo', $code)
|
||||||
|
->where('status', ResetPasswordAttempt::STATUS_VALIDATED)
|
||||||
|
->latest('id')
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($user === null || $attempt === null) {
|
||||||
|
Log::warning('Password reset failed: no matching validated attempt.', [
|
||||||
|
'email_fingerprint' => $emailFingerprint,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->password = $password;
|
||||||
|
$user->save();
|
||||||
|
$user->tokens()->delete();
|
||||||
|
|
||||||
|
$user->resetPasswordAttempts()
|
||||||
|
->whereKeyNot($attempt->getKey())
|
||||||
|
->whereIn('status', [
|
||||||
|
ResetPasswordAttempt::STATUS_PENDING,
|
||||||
|
ResetPasswordAttempt::STATUS_VALIDATED,
|
||||||
|
])
|
||||||
|
->update(['status' => ResetPasswordAttempt::STATUS_EXPIRED]);
|
||||||
|
|
||||||
|
$attempt->update([
|
||||||
|
'status' => ResetPasswordAttempt::STATUS_USED,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
Log::error('Failed to reset user password.', [
|
||||||
|
'email_fingerprint' => $emailFingerprint,
|
||||||
|
'exception' => $exception,
|
||||||
|
]);
|
||||||
|
|
||||||
|
throw $exception;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function generateCode(): string
|
||||||
|
{
|
||||||
|
return str_pad((string) random_int(0, 9999), 4, '0', STR_PAD_LEFT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function emailFingerprint(string $email): string
|
||||||
|
{
|
||||||
|
return substr(hash('sha256', strtolower(trim($email))), 0, 12);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,24 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Domains\Auth\Controllers\CreateResetPasswordAttemptController;
|
||||||
use App\Domains\Auth\Controllers\GoogleTokenExchangeController;
|
use App\Domains\Auth\Controllers\GoogleTokenExchangeController;
|
||||||
use App\Domains\Auth\Controllers\LoginController;
|
use App\Domains\Auth\Controllers\LoginController;
|
||||||
use App\Domains\Auth\Controllers\LogoutController;
|
use App\Domains\Auth\Controllers\LogoutController;
|
||||||
use App\Domains\Auth\Controllers\MeController;
|
use App\Domains\Auth\Controllers\MeController;
|
||||||
use App\Domains\Auth\Controllers\RegisterController;
|
use App\Domains\Auth\Controllers\RegisterController;
|
||||||
|
use App\Domains\Auth\Controllers\ResetPasswordController;
|
||||||
use App\Domains\Auth\Controllers\UpdateProfileController;
|
use App\Domains\Auth\Controllers\UpdateProfileController;
|
||||||
|
use App\Domains\Auth\Controllers\ValidateResetPasswordAttemptController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::post('/register', RegisterController::class);
|
Route::post('/register', RegisterController::class);
|
||||||
Route::post('/login', LoginController::class);
|
Route::post('/login', LoginController::class);
|
||||||
|
Route::post('/password/reset-attempts', CreateResetPasswordAttemptController::class)
|
||||||
|
->middleware('throttle:5,1');
|
||||||
|
Route::post('/password/reset-attempts/validate', ValidateResetPasswordAttemptController::class)
|
||||||
|
->middleware('throttle:10,1');
|
||||||
|
Route::post('/password/reset', ResetPasswordController::class)
|
||||||
|
->middleware('throttle:5,1');
|
||||||
Route::post('/auth/google/exchange', GoogleTokenExchangeController::class);
|
Route::post('/auth/google/exchange', GoogleTokenExchangeController::class);
|
||||||
Route::middleware('auth:sanctum')->post('/logout', LogoutController::class);
|
Route::middleware('auth:sanctum')->post('/logout', LogoutController::class);
|
||||||
Route::middleware('auth:sanctum')->get('/me', MeController::class);
|
Route::middleware('auth:sanctum')->get('/me', MeController::class);
|
||||||
|
|||||||
16
app/Domains/Notification/Events/PasswordResetRequested.php
Normal file
16
app/Domains/Notification/Events/PasswordResetRequested.php
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Notification\Events;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
class PasswordResetRequested
|
||||||
|
{
|
||||||
|
use Dispatchable, SerializesModels;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public readonly int $attemptId,
|
||||||
|
public readonly string $tenantCode,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Notification\Listeners;
|
||||||
|
|
||||||
|
use App\Domains\Notification\Events\PasswordResetRequested;
|
||||||
|
use App\Domains\Notification\Services\NotificationMailService;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class SendPasswordResetEmail implements ShouldQueueAfterCommit
|
||||||
|
{
|
||||||
|
use InteractsWithQueue;
|
||||||
|
|
||||||
|
public string $queue = 'emails';
|
||||||
|
|
||||||
|
public int $tries = 3;
|
||||||
|
|
||||||
|
/** @var array<int, int> */
|
||||||
|
public array $backoff = [30, 120, 300];
|
||||||
|
|
||||||
|
public function handle(PasswordResetRequested $event): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
app(NotificationMailService::class)->sendPasswordResetCode(
|
||||||
|
$event->attemptId,
|
||||||
|
$event->tenantCode,
|
||||||
|
);
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
Log::error('Failed to send password reset email.', [
|
||||||
|
'attempt_id' => $event->attemptId,
|
||||||
|
'tenant_code' => $event->tenantCode,
|
||||||
|
'exception' => $exception,
|
||||||
|
]);
|
||||||
|
|
||||||
|
throw $exception;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,12 +2,14 @@
|
|||||||
|
|
||||||
namespace App\Domains\Notification\Services;
|
namespace App\Domains\Notification\Services;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\ResetPasswordAttempt;
|
||||||
use App\Domains\Auth\Models\User;
|
use App\Domains\Auth\Models\User;
|
||||||
use App\Domains\Integration\Services\MailService;
|
use App\Domains\Integration\Services\MailService;
|
||||||
use App\Domains\Purchase\Models\Purchase;
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use App\Domains\Ticket\Models\Ticket;
|
use App\Domains\Ticket\Models\Ticket;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
class NotificationMailService
|
class NotificationMailService
|
||||||
{
|
{
|
||||||
@@ -29,6 +31,32 @@ class NotificationMailService
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function sendPasswordResetCode(int $attemptId, string $tenantCode): void
|
||||||
|
{
|
||||||
|
$tenant = Tenant::query()->where('codigo', $tenantCode)->firstOrFail();
|
||||||
|
$attempt = ResetPasswordAttempt::query()
|
||||||
|
->with('user')
|
||||||
|
->findOrFail($attemptId);
|
||||||
|
|
||||||
|
if ($attempt->status !== ResetPasswordAttempt::STATUS_PENDING) {
|
||||||
|
Log::warning('Password reset email was skipped because the attempt is no longer pending.', [
|
||||||
|
'attempt_id' => $attemptId,
|
||||||
|
'tenant_code' => $tenantCode,
|
||||||
|
'attempt_status' => $attempt->status,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->mailService
|
||||||
|
->forTenant($tenantCode)
|
||||||
|
->send(
|
||||||
|
$attempt->user->email,
|
||||||
|
"Código para recuperar tu contraseña - {$tenant->nombre}",
|
||||||
|
view('mail.notifications.password-reset', compact('tenant', 'attempt'))->render(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public function sendPurchasePaid(int $purchaseId): void
|
public function sendPurchasePaid(int $purchaseId): void
|
||||||
{
|
{
|
||||||
$purchase = Purchase::query()
|
$purchase = Purchase::query()
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use App\Domains\Notification\Events\PasswordResetRequested;
|
||||||
use App\Domains\Notification\Events\TicketsAvailable;
|
use App\Domains\Notification\Events\TicketsAvailable;
|
||||||
use App\Domains\Notification\Events\UserRegistered;
|
use App\Domains\Notification\Events\UserRegistered;
|
||||||
|
use App\Domains\Notification\Listeners\SendPasswordResetEmail;
|
||||||
use App\Domains\Notification\Listeners\SendPurchasePaidEmail;
|
use App\Domains\Notification\Listeners\SendPurchasePaidEmail;
|
||||||
use App\Domains\Notification\Listeners\SendTicketsAvailableEmail;
|
use App\Domains\Notification\Listeners\SendTicketsAvailableEmail;
|
||||||
use App\Domains\Notification\Listeners\SendWelcomeEmail;
|
use App\Domains\Notification\Listeners\SendWelcomeEmail;
|
||||||
@@ -32,6 +34,7 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
Event::listen(PurchasePaid::class, GenerateTicketsForPaidPurchase::class);
|
Event::listen(PurchasePaid::class, GenerateTicketsForPaidPurchase::class);
|
||||||
Event::listen(TicketsAvailable::class, SendTicketsAvailableEmail::class);
|
Event::listen(TicketsAvailable::class, SendTicketsAvailableEmail::class);
|
||||||
Event::listen(UserRegistered::class, SendWelcomeEmail::class);
|
Event::listen(UserRegistered::class, SendWelcomeEmail::class);
|
||||||
|
Event::listen(PasswordResetRequested::class, SendPasswordResetEmail::class);
|
||||||
|
|
||||||
Builder::macro('paginateFromRequest', function (int $defaultPerPage = 15, int $maxPerPage = 100, ?int $page = null) {
|
Builder::macro('paginateFromRequest', function (int $defaultPerPage = 15, int $maxPerPage = 100, ?int $page = null) {
|
||||||
/** @var Builder $this */
|
/** @var Builder $this */
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('reset_password_attempts', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('user_id')
|
||||||
|
->constrained()
|
||||||
|
->cascadeOnUpdate()
|
||||||
|
->cascadeOnDelete();
|
||||||
|
$table->string('codigo');
|
||||||
|
$table->string('status')->default('pending');
|
||||||
|
|
||||||
|
$table->index(['user_id', 'codigo', 'status']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('reset_password_attempts');
|
||||||
|
}
|
||||||
|
};
|
||||||
20
resources/views/mail/notifications/password-reset.blade.php
Normal file
20
resources/views/mail/notifications/password-reset.blade.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<h1 style="margin: 0 0 20px; color: {{ $tenant->primary_color }};">
|
||||||
|
Recuperá tu contraseña
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Hola {{ $attempt->user->nombre_apellido }}, recibimos una solicitud para restablecer
|
||||||
|
la contraseña de tu cuenta.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>Ingresá este código en {{ $tenant->nombre }}:</p>
|
||||||
|
|
||||||
|
<div style="margin: 28px 0; padding: 20px; border: 2px solid {{ $tenant->primary_color }}; border-radius: 8px; text-align: center;">
|
||||||
|
<span style="color: {{ $tenant->primary_color }}; font-size: 36px; font-weight: 700; letter-spacing: 12px;">
|
||||||
|
{{ $attempt->codigo }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="color: #64748b; font-size: 14px;">
|
||||||
|
Si no solicitaste recuperar tu contraseña, podés ignorar este mensaje.
|
||||||
|
</p>
|
||||||
123
tests/Feature/Auth/CreateResetPasswordAttemptControllerTest.php
Normal file
123
tests/Feature/Auth/CreateResetPasswordAttemptControllerTest.php
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Auth;
|
||||||
|
|
||||||
|
use App\Domains\Attachable\Enums\AttachmentType;
|
||||||
|
use App\Domains\Attachable\Models\Attachment;
|
||||||
|
use App\Domains\Auth\Models\ResetPasswordAttempt;
|
||||||
|
use App\Domains\Auth\Models\User;
|
||||||
|
use App\Domains\Notification\Events\PasswordResetRequested;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Event;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class CreateResetPasswordAttemptControllerTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private Tenant $tenant;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
Event::fake([PasswordResetRequested::class]);
|
||||||
|
|
||||||
|
$header = Attachment::query()->create([
|
||||||
|
'path' => 'test/reset-header.png',
|
||||||
|
'filename' => 'header.png',
|
||||||
|
'type' => AttachmentType::Image,
|
||||||
|
'mime_type' => 'image/png',
|
||||||
|
]);
|
||||||
|
$footer = Attachment::query()->create([
|
||||||
|
'path' => 'test/reset-footer.png',
|
||||||
|
'filename' => 'footer.png',
|
||||||
|
'type' => AttachmentType::Image,
|
||||||
|
'mime_type' => 'image/png',
|
||||||
|
]);
|
||||||
|
$this->tenant = Tenant::query()->create([
|
||||||
|
'codigo' => 'reset-tenant',
|
||||||
|
'nombre' => 'Reset Tenant',
|
||||||
|
'dominio' => 'reset.local',
|
||||||
|
'primary_color' => '#112233',
|
||||||
|
'secondary_color' => '#000000',
|
||||||
|
'danger_color' => '#000000',
|
||||||
|
'success_color' => '#000000',
|
||||||
|
'header_bg_color' => '#000000',
|
||||||
|
'footer_bg_color' => '#000000',
|
||||||
|
'header_logo_id' => $header->id,
|
||||||
|
'footer_logo_id' => $footer->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_creates_a_pending_attempt_for_a_registered_email(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create(['email' => 'ada@example.com']);
|
||||||
|
|
||||||
|
$response = $this->postJson('/api/password/reset-attempts', [
|
||||||
|
'tenant_codigo' => $this->tenant->codigo,
|
||||||
|
'email' => ' ADA@EXAMPLE.COM ',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertAccepted()
|
||||||
|
->assertJsonPath('status', ResetPasswordAttempt::STATUS_PENDING);
|
||||||
|
|
||||||
|
$attempt = ResetPasswordAttempt::query()->sole();
|
||||||
|
|
||||||
|
$this->assertTrue($attempt->user->is($user));
|
||||||
|
$this->assertMatchesRegularExpression('/^\d{4}$/', $attempt->codigo);
|
||||||
|
$this->assertSame(ResetPasswordAttempt::STATUS_PENDING, $attempt->status);
|
||||||
|
Event::assertDispatched(
|
||||||
|
PasswordResetRequested::class,
|
||||||
|
fn (PasswordResetRequested $event): bool => $event->attemptId === $attempt->id
|
||||||
|
&& $event->tenantCode === $this->tenant->codigo,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_expires_previous_pending_and_validated_attempts(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create(['email' => 'ada@example.com']);
|
||||||
|
$pendingAttempt = $user->resetPasswordAttempts()->create([
|
||||||
|
'codigo' => '1234',
|
||||||
|
]);
|
||||||
|
$validatedAttempt = $user->resetPasswordAttempts()->create([
|
||||||
|
'codigo' => '5678',
|
||||||
|
'status' => ResetPasswordAttempt::STATUS_VALIDATED,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->postJson('/api/password/reset-attempts', [
|
||||||
|
'tenant_codigo' => $this->tenant->codigo,
|
||||||
|
'email' => 'ada@example.com',
|
||||||
|
])->assertAccepted();
|
||||||
|
|
||||||
|
$this->assertSame(ResetPasswordAttempt::STATUS_EXPIRED, $pendingAttempt->fresh()->status);
|
||||||
|
$this->assertSame(ResetPasswordAttempt::STATUS_EXPIRED, $validatedAttempt->fresh()->status);
|
||||||
|
$this->assertSame(1, $user->resetPasswordAttempts()
|
||||||
|
->where('status', ResetPasswordAttempt::STATUS_PENDING)
|
||||||
|
->count());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_unknown_email_gets_the_same_response_without_creating_an_attempt(): void
|
||||||
|
{
|
||||||
|
$response = $this->postJson('/api/password/reset-attempts', [
|
||||||
|
'tenant_codigo' => $this->tenant->codigo,
|
||||||
|
'email' => 'unknown@example.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertAccepted()
|
||||||
|
->assertJsonPath('status', ResetPasswordAttempt::STATUS_PENDING);
|
||||||
|
$this->assertDatabaseCount('reset_password_attempts', 0);
|
||||||
|
Event::assertNotDispatched(PasswordResetRequested::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_validates_the_email(): void
|
||||||
|
{
|
||||||
|
$this->postJson('/api/password/reset-attempts', [
|
||||||
|
'tenant_codigo' => $this->tenant->codigo,
|
||||||
|
'email' => 'invalid-email',
|
||||||
|
])->assertUnprocessable()->assertJsonValidationErrors('email');
|
||||||
|
}
|
||||||
|
}
|
||||||
54
tests/Feature/Auth/ResetPasswordAttemptTest.php
Normal file
54
tests/Feature/Auth/ResetPasswordAttemptTest.php
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Auth;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\ResetPasswordAttempt;
|
||||||
|
use App\Domains\Auth\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class ResetPasswordAttemptTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_table_has_the_expected_columns(): void
|
||||||
|
{
|
||||||
|
$this->assertEqualsCanonicalizing([
|
||||||
|
'id',
|
||||||
|
'user_id',
|
||||||
|
'codigo',
|
||||||
|
'status',
|
||||||
|
], Schema::getColumnListing('reset_password_attempts'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_attempt_belongs_to_a_user_and_defaults_to_pending(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$attempt = $user->resetPasswordAttempts()->create([
|
||||||
|
'codigo' => '123456',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertSame(ResetPasswordAttempt::STATUS_PENDING, $attempt->status);
|
||||||
|
$this->assertTrue($attempt->user->is($user));
|
||||||
|
$this->assertTrue($user->resetPasswordAttempts->contains($attempt));
|
||||||
|
$this->assertFalse($attempt->usesTimestamps());
|
||||||
|
$this->assertArrayNotHasKey('codigo', $attempt->toArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_attempts_are_deleted_with_their_user(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$attempt = ResetPasswordAttempt::query()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'codigo' => '123456',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user->delete();
|
||||||
|
|
||||||
|
$this->assertDatabaseMissing('reset_password_attempts', [
|
||||||
|
'id' => $attempt->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
104
tests/Feature/Auth/ResetPasswordControllerTest.php
Normal file
104
tests/Feature/Auth/ResetPasswordControllerTest.php
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Auth;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\ResetPasswordAttempt;
|
||||||
|
use App\Domains\Auth\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class ResetPasswordControllerTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_it_resets_the_password_consumes_the_attempt_and_revokes_tokens(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create([
|
||||||
|
'email' => 'ada@example.com',
|
||||||
|
'password' => 'OldSecret!123',
|
||||||
|
]);
|
||||||
|
$user->createToken('existing-session');
|
||||||
|
$attempt = $user->resetPasswordAttempts()->create([
|
||||||
|
'codigo' => '0123',
|
||||||
|
'status' => ResetPasswordAttempt::STATUS_VALIDATED,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->postJson('/api/password/reset', [
|
||||||
|
'email' => ' ADA@EXAMPLE.COM ',
|
||||||
|
'codigo' => '0123',
|
||||||
|
'password' => 'NewSecret!456',
|
||||||
|
'password_confirmation' => 'NewSecret!456',
|
||||||
|
])->assertOk()
|
||||||
|
->assertJsonPath('status', ResetPasswordAttempt::STATUS_USED);
|
||||||
|
|
||||||
|
$user->refresh();
|
||||||
|
|
||||||
|
$this->assertTrue(Hash::check('NewSecret!456', $user->password));
|
||||||
|
$this->assertFalse(Hash::check('OldSecret!123', $user->password));
|
||||||
|
$this->assertSame(ResetPasswordAttempt::STATUS_USED, $attempt->fresh()->status);
|
||||||
|
$this->assertDatabaseCount('personal_access_tokens', 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_rejects_a_pending_expired_or_used_attempt(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create([
|
||||||
|
'email' => 'ada@example.com',
|
||||||
|
'password' => 'OldSecret!123',
|
||||||
|
]);
|
||||||
|
|
||||||
|
foreach ([
|
||||||
|
ResetPasswordAttempt::STATUS_PENDING,
|
||||||
|
ResetPasswordAttempt::STATUS_EXPIRED,
|
||||||
|
ResetPasswordAttempt::STATUS_USED,
|
||||||
|
] as $status) {
|
||||||
|
$user->resetPasswordAttempts()->create([
|
||||||
|
'codigo' => '1234',
|
||||||
|
'status' => $status,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->postJson('/api/password/reset', [
|
||||||
|
'email' => 'ada@example.com',
|
||||||
|
'codigo' => '1234',
|
||||||
|
'password' => 'NewSecret!456',
|
||||||
|
'password_confirmation' => 'NewSecret!456',
|
||||||
|
])->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors('codigo');
|
||||||
|
|
||||||
|
$this->assertTrue(Hash::check('OldSecret!123', $user->fresh()->password));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_a_used_attempt_cannot_be_reused(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create(['email' => 'ada@example.com']);
|
||||||
|
$attempt = $user->resetPasswordAttempts()->create([
|
||||||
|
'codigo' => '1234',
|
||||||
|
'status' => ResetPasswordAttempt::STATUS_VALIDATED,
|
||||||
|
]);
|
||||||
|
$payload = [
|
||||||
|
'email' => 'ada@example.com',
|
||||||
|
'codigo' => '1234',
|
||||||
|
'password' => 'NewSecret!456',
|
||||||
|
'password_confirmation' => 'NewSecret!456',
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->postJson('/api/password/reset', $payload)->assertOk();
|
||||||
|
$this->postJson('/api/password/reset', $payload)
|
||||||
|
->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors('codigo');
|
||||||
|
|
||||||
|
$this->assertSame(ResetPasswordAttempt::STATUS_USED, $attempt->fresh()->status);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_validates_password_confirmation_and_strength(): void
|
||||||
|
{
|
||||||
|
$this->postJson('/api/password/reset', [
|
||||||
|
'email' => 'ada@example.com',
|
||||||
|
'codigo' => '1234',
|
||||||
|
'password' => 'weak',
|
||||||
|
'password_confirmation' => 'different',
|
||||||
|
])->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors('password');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Auth;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\ResetPasswordAttempt;
|
||||||
|
use App\Domains\Auth\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class ValidateResetPasswordAttemptControllerTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_it_validates_a_pending_four_digit_code(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create(['email' => 'ada@example.com']);
|
||||||
|
$attempt = $user->resetPasswordAttempts()->create([
|
||||||
|
'codigo' => '0123',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->postJson('/api/password/reset-attempts/validate', [
|
||||||
|
'email' => ' ADA@EXAMPLE.COM ',
|
||||||
|
'codigo' => '0123',
|
||||||
|
])->assertOk()
|
||||||
|
->assertJsonPath('status', ResetPasswordAttempt::STATUS_VALIDATED);
|
||||||
|
|
||||||
|
$this->assertSame(
|
||||||
|
ResetPasswordAttempt::STATUS_VALIDATED,
|
||||||
|
$attempt->fresh()->status,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_rejects_an_incorrect_code_without_consuming_the_attempt(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create(['email' => 'ada@example.com']);
|
||||||
|
$attempt = $user->resetPasswordAttempts()->create([
|
||||||
|
'codigo' => '1234',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->postJson('/api/password/reset-attempts/validate', [
|
||||||
|
'email' => 'ada@example.com',
|
||||||
|
'codigo' => '9999',
|
||||||
|
])->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors('codigo');
|
||||||
|
|
||||||
|
$this->assertSame(
|
||||||
|
ResetPasswordAttempt::STATUS_PENDING,
|
||||||
|
$attempt->fresh()->status,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_rejects_an_expired_or_already_validated_attempt(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create(['email' => 'ada@example.com']);
|
||||||
|
|
||||||
|
foreach ([
|
||||||
|
ResetPasswordAttempt::STATUS_EXPIRED,
|
||||||
|
ResetPasswordAttempt::STATUS_VALIDATED,
|
||||||
|
] as $status) {
|
||||||
|
$user->resetPasswordAttempts()->create([
|
||||||
|
'codigo' => '1234',
|
||||||
|
'status' => $status,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->postJson('/api/password/reset-attempts/validate', [
|
||||||
|
'email' => 'ada@example.com',
|
||||||
|
'codigo' => '1234',
|
||||||
|
])->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors('codigo');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_requires_exactly_four_numeric_digits(): void
|
||||||
|
{
|
||||||
|
foreach (['123', '12345', '12a4'] as $invalidCode) {
|
||||||
|
$this->postJson('/api/password/reset-attempts/validate', [
|
||||||
|
'email' => 'ada@example.com',
|
||||||
|
'codigo' => $invalidCode,
|
||||||
|
])->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors('codigo');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -80,6 +80,29 @@ class NotificationMailServiceTest extends TestCase
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_it_sends_a_branded_password_reset_email(): void
|
||||||
|
{
|
||||||
|
$attempt = $this->user->resetPasswordAttempts()->create([
|
||||||
|
'codigo' => '0123',
|
||||||
|
]);
|
||||||
|
|
||||||
|
app(NotificationMailService::class)->sendPasswordResetCode(
|
||||||
|
$attempt->id,
|
||||||
|
$this->tenant->codigo,
|
||||||
|
);
|
||||||
|
|
||||||
|
Mail::assertSent(Mailable::class, function (Mailable $mail): bool {
|
||||||
|
$mail->assertTo('ada@example.com');
|
||||||
|
$mail->assertHasSubject('Código para recuperar tu contraseña - Mail Tenant');
|
||||||
|
$rendered = $mail->render();
|
||||||
|
|
||||||
|
return str_contains($rendered, '0123')
|
||||||
|
&& str_contains($rendered, 'Ada Lovelace')
|
||||||
|
&& str_contains($rendered, 'Mail Tenant')
|
||||||
|
&& str_contains($rendered, '#112233');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public function test_it_sends_purchase_and_ticket_emails_to_the_purchase_recipient(): void
|
public function test_it_sends_purchase_and_ticket_emails_to_the_purchase_recipient(): void
|
||||||
{
|
{
|
||||||
$purchase = Purchase::query()->create([
|
$purchase = Purchase::query()->create([
|
||||||
|
|||||||
79
tests/Unit/Auth/ResetPasswordAttemptServiceTest.php
Normal file
79
tests/Unit/Auth/ResetPasswordAttemptServiceTest.php
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit\Auth;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Services\ResetPasswordAttemptService;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use RuntimeException;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class ResetPasswordAttemptServiceTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_it_logs_and_rethrows_unexpected_failures(): void
|
||||||
|
{
|
||||||
|
$exception = new RuntimeException('Database unavailable.');
|
||||||
|
|
||||||
|
DB::shouldReceive('transaction')
|
||||||
|
->once()
|
||||||
|
->andThrow($exception);
|
||||||
|
|
||||||
|
Log::shouldReceive('error')
|
||||||
|
->once()
|
||||||
|
->with(
|
||||||
|
'Failed to create password reset attempt.',
|
||||||
|
\Mockery::on(fn (array $context): bool => $context['email_fingerprint'] === 'b5fc85e55755'
|
||||||
|
&& $context['exception'] === $exception),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->expectExceptionObject($exception);
|
||||||
|
|
||||||
|
(new ResetPasswordAttemptService)->createForEmail('ada@example.com', 'tenant-test');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_code_validation_logs_and_rethrows_unexpected_failures(): void
|
||||||
|
{
|
||||||
|
$exception = new RuntimeException('Database unavailable.');
|
||||||
|
|
||||||
|
DB::shouldReceive('transaction')
|
||||||
|
->once()
|
||||||
|
->andThrow($exception);
|
||||||
|
|
||||||
|
Log::shouldReceive('error')
|
||||||
|
->once()
|
||||||
|
->with(
|
||||||
|
'Failed to validate password reset code.',
|
||||||
|
\Mockery::on(fn (array $context): bool => $context['email_fingerprint'] === 'b5fc85e55755'
|
||||||
|
&& $context['exception'] === $exception),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->expectExceptionObject($exception);
|
||||||
|
|
||||||
|
(new ResetPasswordAttemptService)->validateCode('ada@example.com', '1234');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_password_reset_logs_and_rethrows_unexpected_failures(): void
|
||||||
|
{
|
||||||
|
$exception = new RuntimeException('Database unavailable.');
|
||||||
|
|
||||||
|
DB::shouldReceive('transaction')
|
||||||
|
->once()
|
||||||
|
->andThrow($exception);
|
||||||
|
|
||||||
|
Log::shouldReceive('error')
|
||||||
|
->once()
|
||||||
|
->with(
|
||||||
|
'Failed to reset user password.',
|
||||||
|
\Mockery::on(fn (array $context): bool => $context['email_fingerprint'] === 'b5fc85e55755'
|
||||||
|
&& $context['exception'] === $exception),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->expectExceptionObject($exception);
|
||||||
|
|
||||||
|
(new ResetPasswordAttemptService)->resetPassword(
|
||||||
|
'ada@example.com',
|
||||||
|
'1234',
|
||||||
|
'NewSecret!456',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
39
tests/Unit/Notification/SendPasswordResetEmailTest.php
Normal file
39
tests/Unit/Notification/SendPasswordResetEmailTest.php
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit\Notification;
|
||||||
|
|
||||||
|
use App\Domains\Notification\Events\PasswordResetRequested;
|
||||||
|
use App\Domains\Notification\Listeners\SendPasswordResetEmail;
|
||||||
|
use App\Domains\Notification\Services\NotificationMailService;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use RuntimeException;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class SendPasswordResetEmailTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_it_logs_and_rethrows_mail_failures(): void
|
||||||
|
{
|
||||||
|
$exception = new RuntimeException('SMTP unavailable.');
|
||||||
|
$mailService = \Mockery::mock(NotificationMailService::class);
|
||||||
|
$mailService->shouldReceive('sendPasswordResetCode')
|
||||||
|
->once()
|
||||||
|
->with(10, 'tenant-test')
|
||||||
|
->andThrow($exception);
|
||||||
|
$this->app->instance(NotificationMailService::class, $mailService);
|
||||||
|
|
||||||
|
Log::shouldReceive('error')
|
||||||
|
->once()
|
||||||
|
->with(
|
||||||
|
'Failed to send password reset email.',
|
||||||
|
\Mockery::on(fn (array $context): bool => $context['attempt_id'] === 10
|
||||||
|
&& $context['tenant_code'] === 'tenant-test'
|
||||||
|
&& $context['exception'] === $exception),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->expectExceptionObject($exception);
|
||||||
|
|
||||||
|
(new SendPasswordResetEmail)->handle(
|
||||||
|
new PasswordResetRequested(10, 'tenant-test'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user