Forgot password flow
This commit is contained in:
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
|
||||
{
|
||||
$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