Forgot password flow

This commit is contained in:
2026-07-27 09:43:00 -03:00
parent 7b565e7fa7
commit c37b8894e4
23 changed files with 1095 additions and 0 deletions

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

View 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,
]);
}
}

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

View File

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