diff --git a/packages/app/resources/lang/en/pages/auth/password-reset/request-password-reset.php b/packages/app/resources/lang/en/pages/auth/password-reset/request-password-reset.php index f8e8bd18b8..c01f10def0 100644 --- a/packages/app/resources/lang/en/pages/auth/password-reset/request-password-reset.php +++ b/packages/app/resources/lang/en/pages/auth/password-reset/request-password-reset.php @@ -27,7 +27,7 @@ return [ ], 'messages' => [ - 'throttled' => 'Too many reset attempts. Please try again in :seconds seconds.', + 'throttled' => 'Too many requests. Please try again in :seconds seconds.', ], ]; diff --git a/packages/app/src/Context.php b/packages/app/src/Context.php index 8237b378af..1cc46a8e0a 100644 --- a/packages/app/src/Context.php +++ b/packages/app/src/Context.php @@ -217,7 +217,14 @@ class Context public function emailVerification(?string $promptPage = EmailVerificationPrompt::class, bool $isRequired = true): static { $this->emailVerificationPromptPage = $promptPage; - $this->isEmailVerificationRequired = $isRequired; + $this->requiresEmailVerification($isRequired); + + return $this; + } + + public function requiresEmailVerification(bool $condition = true): static + { + $this->isEmailVerificationRequired = $condition; return $this; } diff --git a/packages/app/src/Pages/Auth/PasswordReset/ResetPassword.php b/packages/app/src/Pages/Auth/PasswordReset/ResetPassword.php index 9c22e92bab..63589c8558 100644 --- a/packages/app/src/Pages/Auth/PasswordReset/ResetPassword.php +++ b/packages/app/src/Pages/Auth/PasswordReset/ResetPassword.php @@ -19,6 +19,7 @@ use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Password; use Illuminate\Support\Str; use Illuminate\Validation\Rules\Password as PasswordRule; +use Livewire\Livewire; /** * @property Form $form @@ -106,9 +107,6 @@ class ResetPassword extends CardPage ->schema([ TextInput::make('email') ->label(__('filament::pages/auth/password-reset/reset-password.fields.email.label')) - ->email() - ->required() - ->maxLength(255) ->disabled(), TextInput::make('password') ->label(__('filament::pages/auth/password-reset/reset-password.fields.password.label')) @@ -126,7 +124,7 @@ class ResetPassword extends CardPage public function propertyIsPublicAndNotDefinedOnBaseClass($propertyName): bool { - if (in_array($propertyName, [ + if ((! app()->runningUnitTests()) && in_array($propertyName, [ 'email', 'token', ])) { diff --git a/tests/database/migrations/create_password_resets_table.php b/tests/database/migrations/create_password_resets_table.php new file mode 100644 index 0000000000..0b70e56099 --- /dev/null +++ b/tests/database/migrations/create_password_resets_table.php @@ -0,0 +1,22 @@ +string('email')->index(); + $table->string('token'); + $table->timestamp('created_at')->nullable(); + }); + } + + public function down(): void + { + Schema::dropIfExists('password_resets'); + } +}; diff --git a/tests/src/App/Auth/EmailVerification/EmailVerificationPromptTest.php b/tests/src/App/Auth/EmailVerification/EmailVerificationPromptTest.php new file mode 100644 index 0000000000..0d9ffb789a --- /dev/null +++ b/tests/src/App/Auth/EmailVerification/EmailVerificationPromptTest.php @@ -0,0 +1,59 @@ +create([ + 'email_verified_at' => null, + ]); + + $this->actingAs($userToVerify); + + $this->get(Filament::getEmailVerificationPromptUrl())->assertSuccessful(); +}); + +it('can resend notification', function () { + Notification::fake(); + + $userToVerify = User::factory()->create([ + 'email_verified_at' => null, + ]); + + $this->actingAs($userToVerify); + + livewire(EmailVerificationPrompt::class) + ->call('resendNotification') + ->assertNotified(); + + Notification::assertSentTo($userToVerify, VerifyEmail::class); +}); + +it('can throttle resend notification attempts', function () { + Notification::fake(); + + $userToVerify = User::factory()->create([ + 'email_verified_at' => null, + ]); + + $this->actingAs($userToVerify); + + livewire(EmailVerificationPrompt::class) + ->call('resendNotification') + ->assertNotified(); + + Notification::assertSentToTimes($userToVerify, VerifyEmail::class, times: 1); + + livewire(EmailVerificationPrompt::class) + ->call('resendNotification') + ->assertNotified(); + + Notification::assertSentToTimes($userToVerify, VerifyEmail::class, times: 1); +}); diff --git a/tests/src/App/Auth/EmailVerification/EmailVerificationTest.php b/tests/src/App/Auth/EmailVerification/EmailVerificationTest.php new file mode 100644 index 0000000000..89646928d6 --- /dev/null +++ b/tests/src/App/Auth/EmailVerification/EmailVerificationTest.php @@ -0,0 +1,26 @@ +create([ + 'email_verified_at' => null, + ]); + + expect($userToVerify) + ->hasVerifiedEmail()->toBeFalse(); + + $this + ->actingAs($userToVerify) + ->get(Filament::getVerifyEmailUrl($userToVerify)) + ->assertRedirect(Filament::getUrl()); + + expect($userToVerify->refresh()) + ->hasVerifiedEmail()->toBeTrue(); +}); diff --git a/tests/src/App/Auth/LoginTest.php b/tests/src/App/Auth/LoginTest.php index 0fd5f7c52e..dadaef29d7 100644 --- a/tests/src/App/Auth/LoginTest.php +++ b/tests/src/App/Auth/LoginTest.php @@ -22,7 +22,7 @@ it('can authenticate', function () { ->set('email', $userToAuthenticate->email) ->set('password', 'password') ->call('authenticate') - ->assertRedirect(route('filament.admin.pages.dashboard')); + ->assertRedirect(Filament::getUrl()); $this->assertAuthenticatedAs($userToAuthenticate); }); @@ -82,7 +82,7 @@ it('can throttle authentication attempts', function () { it('can validate `email` is required', function () { livewire(Login::class) - ->assertSet('email', '') + ->set('email', '') ->call('authenticate') ->assertHasErrors(['email' => ['required']]); }); @@ -96,7 +96,7 @@ it('can validate `email` is valid email', function () { it('can validate `password` is required', function () { livewire(Login::class) - ->assertSet('password', '') + ->set('password', '') ->call('authenticate') ->assertHasErrors(['password' => ['required']]); }); diff --git a/tests/src/App/Auth/PasswordReset/RequestPasswordResetTest.php b/tests/src/App/Auth/PasswordReset/RequestPasswordResetTest.php new file mode 100644 index 0000000000..57de982285 --- /dev/null +++ b/tests/src/App/Auth/PasswordReset/RequestPasswordResetTest.php @@ -0,0 +1,66 @@ +get(Filament::getRequestPasswordResetUrl())->assertSuccessful(); +}); + +it('can request password reset', function () { + Notification::fake(); + + $this->assertGuest(); + + $userToResetPassword = User::factory()->create(); + + livewire(RequestPasswordReset::class) + ->set('email', $userToResetPassword->email) + ->call('request') + ->assertNotified(); + + Notification::assertSentTo($userToResetPassword, ResetPassword::class); +}); + +it('can throttle requests', function () { + Notification::fake(); + + $this->assertGuest(); + + $userToResetPassword = User::factory()->create(); + + livewire(RequestPasswordReset::class) + ->set('email', $userToResetPassword->email) + ->call('request') + ->assertNotified(); + + Notification::assertSentToTimes($userToResetPassword, ResetPassword::class, times: 1); + + livewire(RequestPasswordReset::class) + ->set('email', $userToResetPassword->email) + ->call('request') + ->assertNotified(); + + Notification::assertSentToTimes($userToResetPassword, ResetPassword::class, times: 1); +}); + +it('can validate `email` is required', function () { + livewire(RequestPasswordReset::class) + ->set('email', '') + ->call('request') + ->assertHasErrors(['email' => ['required']]); +}); + +it('can validate `email` is valid email', function () { + livewire(RequestPasswordReset::class) + ->set('email', 'invalid-email') + ->call('request') + ->assertHasErrors(['email' => ['email']]); +}); diff --git a/tests/src/App/Auth/PasswordReset/ResetPasswordTest.php b/tests/src/App/Auth/PasswordReset/ResetPasswordTest.php new file mode 100644 index 0000000000..e9c3b3c05b --- /dev/null +++ b/tests/src/App/Auth/PasswordReset/ResetPasswordTest.php @@ -0,0 +1,155 @@ +make(); + $token = Password::createToken($userToResetPassword); + + $this->get(Filament::getResetPasswordUrl( + $token, + $userToResetPassword, + ))->assertSuccessful(); +}); + +it('can reset password', function () { + Event::fake(); + + $this->assertGuest(); + + $userToResetPassword = User::factory()->create(); + $token = Password::createToken($userToResetPassword); + + livewire(ResetPassword::class) + ->set('email', $userToResetPassword->email) + ->set('token', $token) + ->set('password', 'new-password') + ->set('passwordConfirmation', 'new-password') + ->call('resetPassword') + ->assertNotified() + ->assertRedirect(Filament::getLoginUrl()); + + Event::assertDispatched(PasswordReset::class); + + $this->assertCredentials([ + 'email' => $userToResetPassword->email, + 'password' => 'new-password', + ]); +}); + +it('requires request signature', function () { + $userToResetPassword = User::factory()->make(); + $token = Password::createToken($userToResetPassword); + + $this->get(route("filament.admin.auth.password-reset.reset", [ + 'email' => $userToResetPassword->getEmailForPasswordReset(), + 'token' => $token, + ]))->assertForbidden(); +}); + +it('requires valid email and token', function () { + Event::fake(); + + $this->assertGuest(); + + $userToResetPassword = User::factory()->create(); + $token = Password::createToken($userToResetPassword); + + livewire(ResetPassword::class) + ->set('email', $userToResetPassword->email) + ->set('token', Str::random()) + ->set('password', 'new-password') + ->set('passwordConfirmation', 'new-password') + ->call('resetPassword') + ->assertNotified() + ->assertNoRedirect(); + + Event::assertNotDispatched(PasswordReset::class); + + livewire(ResetPassword::class) + ->set('email', fake()->email()) + ->set('token', $token) + ->set('password', 'new-password') + ->set('passwordConfirmation', 'new-password') + ->call('resetPassword') + ->assertNotified() + ->assertNoRedirect(); + + Event::assertNotDispatched(PasswordReset::class); +}); + +it('can throttle reset password attempts', function () { + Event::fake(); + + $this->assertGuest(); + + $userToResetPassword = User::factory()->create(); + $token = Password::createToken($userToResetPassword); + + livewire(ResetPassword::class) + ->set('email', $userToResetPassword->email) + ->set('token', $token) + ->set('password', 'new-password') + ->set('passwordConfirmation', 'new-password') + ->call('resetPassword') + ->assertNotified() + ->assertRedirect(Filament::getLoginUrl()); + + Event::assertDispatchedTimes(PasswordReset::class, times: 1); + + $this->assertCredentials([ + 'email' => $userToResetPassword->email, + 'password' => 'new-password', + ]); + + livewire(ResetPassword::class) + ->set('email', $userToResetPassword->email) + ->set('token', $token) + ->set('password', 'newer-password') + ->set('passwordConfirmation', 'newer-password') + ->call('resetPassword') + ->assertNotified() + ->assertNoRedirect(); + + Event::assertDispatchedTimes(PasswordReset::class, times: 1); + + $this->assertCredentials([ + 'email' => $userToResetPassword->email, + 'password' => 'new-password', + ]); +}); + +it('can validate `password` is required', function () { + livewire(ResetPassword::class) + ->set('password', '') + ->call('resetPassword') + ->assertHasErrors(['password' => ['required']]); +}); + +it('can validate `password` is confirmed', function () { + livewire(ResetPassword::class) + ->set('password', Str::random()) + ->set('passwordConfirmation', Str::random()) + ->call('resetPassword') + ->assertHasErrors(['password' => ['same']]); +}); + +it('can validate `passwordConfirmation` is required', function () { + livewire(ResetPassword::class) + ->set('passwordConfirmation', '') + ->call('resetPassword') + ->assertHasErrors(['passwordConfirmation' => ['required']]); +}); diff --git a/tests/src/App/Auth/RegisterTest.php b/tests/src/App/Auth/RegisterTest.php new file mode 100644 index 0000000000..197c373e54 --- /dev/null +++ b/tests/src/App/Auth/RegisterTest.php @@ -0,0 +1,160 @@ +get(Filament::getRegistrationUrl())->assertSuccessful(); +}); + +it('can register', function () { + Event::fake(); + + $this->assertGuest(); + + Filament::getCurrentContext()->requiresEmailVerification(false); + + $userToRegister = User::factory()->make(); + + livewire(Register::class) + ->set('name', $userToRegister->name) + ->set('email', $userToRegister->email) + ->set('password', 'password') + ->set('passwordConfirmation', 'password') + ->call('register') + ->assertNotified() + ->assertRedirect(Filament::getUrl()); + + Event::assertDispatched(Registered::class); + + $this->assertAuthenticated(); + + $this->assertCredentials([ + 'email' => $userToRegister->email, + 'password' => 'password', + ]); +}); + +it('can register and redirect user to their intended URL', function () { + session()->put('url.intended', $intendedUrl = Str::random()); + + Filament::getCurrentContext()->requiresEmailVerification(false); + + $userToRegister = User::factory()->make(); + + livewire(Register::class) + ->set('name', $userToRegister->name) + ->set('email', $userToRegister->email) + ->set('password', 'password') + ->set('passwordConfirmation', 'password') + ->call('register') + ->assertRedirect($intendedUrl); +}); + +it('can throttle registration attempts', function () { + Event::fake(); + + $this->assertGuest(); + + $userToRegister = User::factory()->make(); + + livewire(Register::class) + ->set('name', $userToRegister->name) + ->set('email', $userToRegister->email) + ->set('password', 'password') + ->set('passwordConfirmation', 'password') + ->call('register') + ->assertNotified(); + + Event::assertDispatchedTimes(Registered::class, times: 1); + + $this->assertAuthenticated(); + + auth()->logout(); + + livewire(Register::class) + ->set('name', $userToRegister->name) + ->set('email', $userToRegister->email) + ->set('password', 'password') + ->set('passwordConfirmation', 'password') + ->call('register') + ->assertNotified(); + + Event::assertDispatchedTimes(Registered::class, times: 1); + + $this->assertGuest(); +}); + +it('can validate `name` is required', function () { + livewire(Register::class) + ->set('name', '') + ->call('register') + ->assertHasErrors(['name' => ['required']]); +}); + +it('can validate `name` is max 255 characters', function () { + livewire(Register::class) + ->set('name', Str::random(256)) + ->call('register') + ->assertHasErrors(['name' => ['max']]); +}); + +it('can validate `email` is required', function () { + livewire(Register::class) + ->set('email', '') + ->call('register') + ->assertHasErrors(['email' => ['required']]); +}); + +it('can validate `email` is valid email', function () { + livewire(Register::class) + ->set('email', 'invalid-email') + ->call('register') + ->assertHasErrors(['email' => ['email']]); +}); + +it('can validate `email` is max 255 characters', function () { + livewire(Register::class) + ->set('email', Str::random(256)) + ->call('register') + ->assertHasErrors(['email' => ['max']]); +}); + +it('can validate `email` is unique', function () { + $existingEmail = User::factory()->create()->email; + + livewire(Register::class) + ->set('email', $existingEmail) + ->call('register') + ->assertHasErrors(['email' => ['unique']]); +}); + +it('can validate `password` is required', function () { + livewire(Register::class) + ->set('password', '') + ->call('register') + ->assertHasErrors(['password' => ['required']]); +}); + +it('can validate `password` is confirmed', function () { + livewire(Register::class) + ->set('password', Str::random()) + ->set('passwordConfirmation', Str::random()) + ->call('register') + ->assertHasErrors(['password' => ['same']]); +}); + +it('can validate `passwordConfirmation` is required', function () { + livewire(Register::class) + ->set('passwordConfirmation', '') + ->call('register') + ->assertHasErrors(['passwordConfirmation' => ['required']]); +}); diff --git a/tests/src/Models/User.php b/tests/src/Models/User.php index 0b38499e33..0203586e41 100644 --- a/tests/src/Models/User.php +++ b/tests/src/Models/User.php @@ -8,10 +8,12 @@ use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; +use Illuminate\Notifications\Notifiable; class User extends Authenticatable implements FilamentUser, MustVerifyEmail { use HasFactory; + use Notifiable; protected $guarded = [];