From c37b8894e4c1d57ddac8c74cbb97fc4ca1c17b39 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 27 Jul 2026 09:43:00 -0300 Subject: [PATCH] Forgot password flow --- .../CreateResetPasswordAttemptController.php | 31 +++ .../Controllers/ResetPasswordController.php | 40 ++++ ...ValidateResetPasswordAttemptController.php | 39 ++++ .../Auth/Models/ResetPasswordAttempt.php | 36 ++++ app/Domains/Auth/Models/User.php | 7 + .../CreateResetPasswordAttemptRequest.php | 37 ++++ .../Auth/Requests/ResetPasswordRequest.php | 43 +++++ .../ValidateResetPasswordAttemptRequest.php | 36 ++++ .../Services/ResetPasswordAttemptService.php | 177 ++++++++++++++++++ app/Domains/Auth/routes/api.php | 9 + .../Events/PasswordResetRequested.php | 16 ++ .../Listeners/SendPasswordResetEmail.php | 40 ++++ .../Services/NotificationMailService.php | 28 +++ app/Providers/AppServiceProvider.php | 3 + ...0_create_reset_password_attempts_table.php | 28 +++ .../notifications/password-reset.blade.php | 20 ++ ...eateResetPasswordAttemptControllerTest.php | 123 ++++++++++++ .../Feature/Auth/ResetPasswordAttemptTest.php | 54 ++++++ .../Auth/ResetPasswordControllerTest.php | 104 ++++++++++ ...dateResetPasswordAttemptControllerTest.php | 83 ++++++++ .../NotificationMailServiceTest.php | 23 +++ .../Auth/ResetPasswordAttemptServiceTest.php | 79 ++++++++ .../SendPasswordResetEmailTest.php | 39 ++++ 23 files changed, 1095 insertions(+) create mode 100644 app/Domains/Auth/Controllers/CreateResetPasswordAttemptController.php create mode 100644 app/Domains/Auth/Controllers/ResetPasswordController.php create mode 100644 app/Domains/Auth/Controllers/ValidateResetPasswordAttemptController.php create mode 100644 app/Domains/Auth/Models/ResetPasswordAttempt.php create mode 100644 app/Domains/Auth/Requests/CreateResetPasswordAttemptRequest.php create mode 100644 app/Domains/Auth/Requests/ResetPasswordRequest.php create mode 100644 app/Domains/Auth/Requests/ValidateResetPasswordAttemptRequest.php create mode 100644 app/Domains/Auth/Services/ResetPasswordAttemptService.php create mode 100644 app/Domains/Notification/Events/PasswordResetRequested.php create mode 100644 app/Domains/Notification/Listeners/SendPasswordResetEmail.php create mode 100644 database/migrations/2026_07_27_000000_create_reset_password_attempts_table.php create mode 100644 resources/views/mail/notifications/password-reset.blade.php create mode 100644 tests/Feature/Auth/CreateResetPasswordAttemptControllerTest.php create mode 100644 tests/Feature/Auth/ResetPasswordAttemptTest.php create mode 100644 tests/Feature/Auth/ResetPasswordControllerTest.php create mode 100644 tests/Feature/Auth/ValidateResetPasswordAttemptControllerTest.php create mode 100644 tests/Unit/Auth/ResetPasswordAttemptServiceTest.php create mode 100644 tests/Unit/Notification/SendPasswordResetEmailTest.php diff --git a/app/Domains/Auth/Controllers/CreateResetPasswordAttemptController.php b/app/Domains/Auth/Controllers/CreateResetPasswordAttemptController.php new file mode 100644 index 0000000..919cf3c --- /dev/null +++ b/app/Domains/Auth/Controllers/CreateResetPasswordAttemptController.php @@ -0,0 +1,31 @@ +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); + } +} diff --git a/app/Domains/Auth/Controllers/ResetPasswordController.php b/app/Domains/Auth/Controllers/ResetPasswordController.php new file mode 100644 index 0000000..a355a1d --- /dev/null +++ b/app/Domains/Auth/Controllers/ResetPasswordController.php @@ -0,0 +1,40 @@ +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, + ]); + } +} diff --git a/app/Domains/Auth/Controllers/ValidateResetPasswordAttemptController.php b/app/Domains/Auth/Controllers/ValidateResetPasswordAttemptController.php new file mode 100644 index 0000000..7905602 --- /dev/null +++ b/app/Domains/Auth/Controllers/ValidateResetPasswordAttemptController.php @@ -0,0 +1,39 @@ +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, + ]); + } +} diff --git a/app/Domains/Auth/Models/ResetPasswordAttempt.php b/app/Domains/Auth/Models/ResetPasswordAttempt.php new file mode 100644 index 0000000..39dc274 --- /dev/null +++ b/app/Domains/Auth/Models/ResetPasswordAttempt.php @@ -0,0 +1,36 @@ + 'integer', + ]; + } + + /** @return BelongsTo */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Domains/Auth/Models/User.php b/app/Domains/Auth/Models/User.php index e5921df..58364c2 100644 --- a/app/Domains/Auth/Models/User.php +++ b/app/Domains/Auth/Models/User.php @@ -6,6 +6,7 @@ use Database\Factories\UserFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Hidden; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Laravel\Sanctum\HasApiTokens; @@ -22,6 +23,12 @@ class User extends Authenticatable return UserFactory::new(); } + /** @return HasMany */ + public function resetPasswordAttempts(): HasMany + { + return $this->hasMany(ResetPasswordAttempt::class); + } + /** * @return array */ diff --git a/app/Domains/Auth/Requests/CreateResetPasswordAttemptRequest.php b/app/Domains/Auth/Requests/CreateResetPasswordAttemptRequest.php new file mode 100644 index 0000000..7e554ba --- /dev/null +++ b/app/Domains/Auth/Requests/CreateResetPasswordAttemptRequest.php @@ -0,0 +1,37 @@ +input('email'); + + if (is_string($email)) { + $this->merge([ + 'email' => Str::lower(trim($email)), + ]); + } + } + + /** + * @return array + */ + public function rules(): array + { + return [ + 'tenant_codigo' => ['required', 'string', Rule::exists('tenants', 'codigo')], + 'email' => ['required', 'string', 'email', 'max:255'], + ]; + } +} diff --git a/app/Domains/Auth/Requests/ResetPasswordRequest.php b/app/Domains/Auth/Requests/ResetPasswordRequest.php new file mode 100644 index 0000000..98902c6 --- /dev/null +++ b/app/Domains/Auth/Requests/ResetPasswordRequest.php @@ -0,0 +1,43 @@ +input('email'); + + if (is_string($email)) { + $this->merge([ + 'email' => Str::lower(trim($email)), + ]); + } + } + + /** + * @return array + */ + 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(), + ], + ]; + } +} diff --git a/app/Domains/Auth/Requests/ValidateResetPasswordAttemptRequest.php b/app/Domains/Auth/Requests/ValidateResetPasswordAttemptRequest.php new file mode 100644 index 0000000..52357e3 --- /dev/null +++ b/app/Domains/Auth/Requests/ValidateResetPasswordAttemptRequest.php @@ -0,0 +1,36 @@ +input('email'); + + if (is_string($email)) { + $this->merge([ + 'email' => Str::lower(trim($email)), + ]); + } + } + + /** + * @return array + */ + public function rules(): array + { + return [ + 'email' => ['required', 'string', 'email', 'max:255'], + 'codigo' => ['required', 'string', 'regex:/^\d{4}$/'], + ]; + } +} diff --git a/app/Domains/Auth/Services/ResetPasswordAttemptService.php b/app/Domains/Auth/Services/ResetPasswordAttemptService.php new file mode 100644 index 0000000..fdab4c5 --- /dev/null +++ b/app/Domains/Auth/Services/ResetPasswordAttemptService.php @@ -0,0 +1,177 @@ +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); + } +} diff --git a/app/Domains/Auth/routes/api.php b/app/Domains/Auth/routes/api.php index 9e4cab8..7a46965 100644 --- a/app/Domains/Auth/routes/api.php +++ b/app/Domains/Auth/routes/api.php @@ -1,15 +1,24 @@ 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::middleware('auth:sanctum')->post('/logout', LogoutController::class); Route::middleware('auth:sanctum')->get('/me', MeController::class); diff --git a/app/Domains/Notification/Events/PasswordResetRequested.php b/app/Domains/Notification/Events/PasswordResetRequested.php new file mode 100644 index 0000000..df60117 --- /dev/null +++ b/app/Domains/Notification/Events/PasswordResetRequested.php @@ -0,0 +1,16 @@ + */ + 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; + } + } +} diff --git a/app/Domains/Notification/Services/NotificationMailService.php b/app/Domains/Notification/Services/NotificationMailService.php index 41c8367..1b34200 100644 --- a/app/Domains/Notification/Services/NotificationMailService.php +++ b/app/Domains/Notification/Services/NotificationMailService.php @@ -2,12 +2,14 @@ namespace App\Domains\Notification\Services; +use App\Domains\Auth\Models\ResetPasswordAttempt; use App\Domains\Auth\Models\User; use App\Domains\Integration\Services\MailService; use App\Domains\Purchase\Models\Purchase; use App\Domains\Tenant\Models\Tenant; use App\Domains\Ticket\Models\Ticket; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Log; 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 { $purchase = Purchase::query() diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 8dbfd20..51a3683 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,8 +2,10 @@ namespace App\Providers; +use App\Domains\Notification\Events\PasswordResetRequested; use App\Domains\Notification\Events\TicketsAvailable; use App\Domains\Notification\Events\UserRegistered; +use App\Domains\Notification\Listeners\SendPasswordResetEmail; use App\Domains\Notification\Listeners\SendPurchasePaidEmail; use App\Domains\Notification\Listeners\SendTicketsAvailableEmail; use App\Domains\Notification\Listeners\SendWelcomeEmail; @@ -32,6 +34,7 @@ class AppServiceProvider extends ServiceProvider Event::listen(PurchasePaid::class, GenerateTicketsForPaidPurchase::class); Event::listen(TicketsAvailable::class, SendTicketsAvailableEmail::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) { /** @var Builder $this */ diff --git a/database/migrations/2026_07_27_000000_create_reset_password_attempts_table.php b/database/migrations/2026_07_27_000000_create_reset_password_attempts_table.php new file mode 100644 index 0000000..fcc86a4 --- /dev/null +++ b/database/migrations/2026_07_27_000000_create_reset_password_attempts_table.php @@ -0,0 +1,28 @@ +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'); + } +}; diff --git a/resources/views/mail/notifications/password-reset.blade.php b/resources/views/mail/notifications/password-reset.blade.php new file mode 100644 index 0000000..422b214 --- /dev/null +++ b/resources/views/mail/notifications/password-reset.blade.php @@ -0,0 +1,20 @@ +

+ Recuperá tu contraseña +

+ +

+ Hola {{ $attempt->user->nombre_apellido }}, recibimos una solicitud para restablecer + la contraseña de tu cuenta. +

+ +

Ingresá este código en {{ $tenant->nombre }}:

+ +
+ + {{ $attempt->codigo }} + +
+ +

+ Si no solicitaste recuperar tu contraseña, podés ignorar este mensaje. +

diff --git a/tests/Feature/Auth/CreateResetPasswordAttemptControllerTest.php b/tests/Feature/Auth/CreateResetPasswordAttemptControllerTest.php new file mode 100644 index 0000000..bbba493 --- /dev/null +++ b/tests/Feature/Auth/CreateResetPasswordAttemptControllerTest.php @@ -0,0 +1,123 @@ +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'); + } +} diff --git a/tests/Feature/Auth/ResetPasswordAttemptTest.php b/tests/Feature/Auth/ResetPasswordAttemptTest.php new file mode 100644 index 0000000..eb8c003 --- /dev/null +++ b/tests/Feature/Auth/ResetPasswordAttemptTest.php @@ -0,0 +1,54 @@ +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, + ]); + } +} diff --git a/tests/Feature/Auth/ResetPasswordControllerTest.php b/tests/Feature/Auth/ResetPasswordControllerTest.php new file mode 100644 index 0000000..888daf0 --- /dev/null +++ b/tests/Feature/Auth/ResetPasswordControllerTest.php @@ -0,0 +1,104 @@ +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'); + } +} diff --git a/tests/Feature/Auth/ValidateResetPasswordAttemptControllerTest.php b/tests/Feature/Auth/ValidateResetPasswordAttemptControllerTest.php new file mode 100644 index 0000000..444f81d --- /dev/null +++ b/tests/Feature/Auth/ValidateResetPasswordAttemptControllerTest.php @@ -0,0 +1,83 @@ +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'); + } + } +} diff --git a/tests/Feature/Notification/NotificationMailServiceTest.php b/tests/Feature/Notification/NotificationMailServiceTest.php index e116511..fdf699f 100644 --- a/tests/Feature/Notification/NotificationMailServiceTest.php +++ b/tests/Feature/Notification/NotificationMailServiceTest.php @@ -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 { $purchase = Purchase::query()->create([ diff --git a/tests/Unit/Auth/ResetPasswordAttemptServiceTest.php b/tests/Unit/Auth/ResetPasswordAttemptServiceTest.php new file mode 100644 index 0000000..0fffb8b --- /dev/null +++ b/tests/Unit/Auth/ResetPasswordAttemptServiceTest.php @@ -0,0 +1,79 @@ +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', + ); + } +} diff --git a/tests/Unit/Notification/SendPasswordResetEmailTest.php b/tests/Unit/Notification/SendPasswordResetEmailTest.php new file mode 100644 index 0000000..cad33c5 --- /dev/null +++ b/tests/Unit/Notification/SendPasswordResetEmailTest.php @@ -0,0 +1,39 @@ +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'), + ); + } +}