From 5ef2bf7247d58f553ab3c1dd2bdf50806b7ea522 Mon Sep 17 00:00:00 2001 From: wimski Date: Sat, 24 Apr 2021 13:49:49 +0200 Subject: [PATCH] Create reset password mutation --- README.md | 18 + graphql/sanctum.graphql | 16 +- src/Enums/ResetPasswordStatus.php | 15 + src/Exceptions/ResetPasswordException.php | 39 +++ src/GraphQL/Mutations/ResetPassword.php | 80 +++++ .../LighthouseSanctumServiceProvider.php | 5 + .../GraphQL/Mutations/ResetPasswordTest.php | 325 ++++++++++++++++++ .../GraphQL/Mutations/ResetPasswordTest.php | 141 ++++++++ 8 files changed, 638 insertions(+), 1 deletion(-) create mode 100644 src/Enums/ResetPasswordStatus.php create mode 100644 src/Exceptions/ResetPasswordException.php create mode 100644 src/GraphQL/Mutations/ResetPassword.php create mode 100644 tests/Integration/GraphQL/Mutations/ResetPasswordTest.php create mode 100644 tests/Unit/GraphQL/Mutations/ResetPasswordTest.php diff --git a/README.md b/README.md index fe078da..f01046f 100644 --- a/README.md +++ b/README.md @@ -206,6 +206,24 @@ mutation ForgotPassword { } ``` +### Reset Password + +Reset the user's password. + +```graphql +mutation ResetPassword { + resetPassword(input: { + email: "john.doe@gmail.com", + token: "af269947ed80d4a7bc3f78a6dfd05ec369373f9d" + password: "secret" + password_confirmation: "secret" + }) { + status + message + } +} +``` + ## Testing ```bash diff --git a/graphql/sanctum.graphql b/graphql/sanctum.graphql index d4bc08d..3d018bd 100644 --- a/graphql/sanctum.graphql +++ b/graphql/sanctum.graphql @@ -70,6 +70,18 @@ type ForgotPasswordResponse { message: String } +input ResetPasswordInput { + email: String! @rules(apply: ["email"]) + token: String! + password: String! @rules(apply: ["confirmed"]) + password_confirmation: String! +} + +type ResetPasswordResponse { + status: ResetPasswordStatus! + message: String +} + extend type Mutation { login(input: LoginInput @spread): AccessToken! @field(resolver: "DanielDeWit\\LighthouseSanctum\\GraphQL\\Mutations\\Login") @@ -80,5 +92,7 @@ extend type Mutation { verifyEmail(input: VerifyEmailInput! @spread): EmailVerificationResponse! @field(resolver: "DanielDeWit\\LighthouseSanctum\\GraphQL\\Mutations\\VerifyEmail") forgotPassword(input: ForgotPasswordInput! @spread): ForgotPasswordResponse! - @field(resolver: "DanielDeWit\\LighthouseSanctum\\GraphQL\\Mutations\\ForgotPassword") + @field(resolver: "DanielDeWit\\LighthouseSanctum\\GraphQL\\Mutations\\ForgotPassword") + resetPassword(input: ResetPasswordInput! @spread): ResetPasswordResponse! + @field(resolver: "DanielDeWit\\LighthouseSanctum\\GraphQL\\Mutations\\ResetPassword") } diff --git a/src/Enums/ResetPasswordStatus.php b/src/Enums/ResetPasswordStatus.php new file mode 100644 index 0000000..2d90bae --- /dev/null +++ b/src/Enums/ResetPasswordStatus.php @@ -0,0 +1,15 @@ +validationMessage = $message; + + parent::__construct("Validation failed for the field [{$path}]."); + } + + public function isClientSafe(): bool + { + return true; + } + + public function getCategory(): string + { + return 'validation'; + } + + public function extensionsContent(): array + { + return [ + 'validation' => [ + 'input' => [$this->validationMessage], + ], + ]; + } +} diff --git a/src/GraphQL/Mutations/ResetPassword.php b/src/GraphQL/Mutations/ResetPassword.php new file mode 100644 index 0000000..3b4ce85 --- /dev/null +++ b/src/GraphQL/Mutations/ResetPassword.php @@ -0,0 +1,80 @@ +passwordBroker = $passwordBroker; + $this->hash = $hash; + $this->dispatcher = $dispatcher; + $this->translator = $translator; + } + + /** + * @param mixed $_ + * @param array $args + * @param GraphQLContext $context + * @param ResolveInfo $resolveInfo + * @return array + * @throws Exception + */ + public function __invoke($_, array $args, GraphQLContext $context, ResolveInfo $resolveInfo): array + { + $credentials = Arr::except($args, [ + 'directive', + 'password_confirmation', + ]); + + $response = $this->passwordBroker->reset($credentials, function (Authenticatable $user, string $password) { + $this->resetPassword($user, $password); + + $this->dispatcher->dispatch(new PasswordReset($user)); + }); + + if ($response === PasswordBroker::PASSWORD_RESET) { + return [ + 'status' => ResetPasswordStatus::PASSWORD_RESET(), + 'message' => $this->translator->get($response), + ]; + } + + throw new ResetPasswordException( + $this->translator->get($response), + implode('.', $resolveInfo->path), + ); + } + + protected function resetPassword(Authenticatable $user, string $password): void + { + /** @var Model $user */ + $user->setAttribute('password', $this->hash->make($password)); + $user->save(); + } +} diff --git a/src/Providers/LighthouseSanctumServiceProvider.php b/src/Providers/LighthouseSanctumServiceProvider.php index 0e38c7c..87dd4c8 100644 --- a/src/Providers/LighthouseSanctumServiceProvider.php +++ b/src/Providers/LighthouseSanctumServiceProvider.php @@ -10,6 +10,7 @@ use DanielDeWit\LighthouseSanctum\Enums\ForgotPasswordStatus; use DanielDeWit\LighthouseSanctum\Enums\LogoutStatus; use DanielDeWit\LighthouseSanctum\Enums\RegisterStatus; +use DanielDeWit\LighthouseSanctum\Enums\ResetPasswordStatus; use DanielDeWit\LighthouseSanctum\Services\EmailVerificationService; use DanielDeWit\LighthouseSanctum\Services\ResetPasswordService; use Illuminate\Contracts\Events\Dispatcher; @@ -78,5 +79,9 @@ protected function registerEnums(): void $this->typeRegistry->register( new LaravelEnumType(ForgotPasswordStatus::class), ); + + $this->typeRegistry->register( + new LaravelEnumType(ResetPasswordStatus::class), + ); } } diff --git a/tests/Integration/GraphQL/Mutations/ResetPasswordTest.php b/tests/Integration/GraphQL/Mutations/ResetPasswordTest.php new file mode 100644 index 0000000..be3c75e --- /dev/null +++ b/tests/Integration/GraphQL/Mutations/ResetPasswordTest.php @@ -0,0 +1,325 @@ +create([ + 'email' => 'foo@bar.com', + ]); + + $token = ''; + + /** @var PasswordBroker $passwordBroker */ + $passwordBroker = $this->app->make(PasswordBroker::class); + $passwordBroker->sendResetLink( + ['email' => 'foo@bar.com'], + function ($user, $resetToken) use (&$token) { + $token = $resetToken; + } + ); + + $response = $this->graphQL(/** @lang GraphQL */ ' + mutation { + resetPassword(input: { + email: "foo@bar.com", + token: "' . $token . '", + password: "supersecret", + password_confirmation: "supersecret" + }) { + status + message + } + } + ')->assertJsonStructure([ + 'data' => [ + 'resetPassword' => [ + 'status', + 'message', + ], + ], + ]); + + static::assertTrue(ResetPasswordStatus::PASSWORD_RESET()->is($response->json('data.resetPassword.status'))); + static::assertSame('Your password has been reset!', $response->json('data.resetPassword.message')); + + Event::assertDispatched(function (PasswordReset $event) use ($user) { + /** @var Model $eventUser */ + $eventUser = $event->user; + + return $eventUser->is($user); + }); + } + + /** + * @test + */ + public function it_returns_an_error_if_the_email_field_is_missing(): void + { + $this->graphQL(/** @lang GraphQL */ ' + mutation { + resetPassword(input: { + token: "1234567890", + password: "supersecret", + password_confirmation: "supersecret" + }) { + status + message + } + } + ')->assertGraphQLErrorMessage('Field ResetPasswordInput.email of required type String! was not provided.'); + } + + /** + * @test + */ + public function it_returns_an_error_if_the_email_field_is_not_a_string(): void + { + $this->graphQL(/** @lang GraphQL */ ' + mutation { + resetPassword(input: { + email: 12345, + token: "1234567890", + password: "supersecret", + password_confirmation: "supersecret" + }) { + status + message + } + } + ')->assertGraphQLErrorMessage('Field "resetPassword" argument "input" requires type String!, found 12345.'); + } + + /** + * @test + */ + public function it_returns_an_error_if_the_email_field_is_not_an_email(): void + { + $this->graphQL(/** @lang GraphQL */ ' + mutation { + resetPassword(input: { + email: "foobar", + token: "1234567890", + password: "supersecret", + password_confirmation: "supersecret" + }) { + status + message + } + } + ') + ->assertGraphQLErrorMessage('Validation failed for the field [resetPassword].') + ->assertGraphQLValidationError( + 'input.email', + 'The input.email must be a valid email address.', + ); + } + + /** + * @test + */ + public function it_returns_an_error_if_the_email_is_not_found(): void + { + $this->graphQL(/** @lang GraphQL */ ' + mutation { + resetPassword(input: { + email: "foo@bar.com", + token: "1234567890", + password: "supersecret", + password_confirmation: "supersecret" + }) { + status + message + } + } + ') + ->assertGraphQLErrorMessage('Validation failed for the field [resetPassword].') + ->assertGraphQLValidationError('input', "We can't find a user with that email address."); + } + + /** + * @test + */ + public function it_returns_an_error_if_the_token_field_is_missing(): void + { + $this->graphQL(/** @lang GraphQL */ ' + mutation { + resetPassword(input: { + email: "foo@bar.com", + password: "supersecret", + password_confirmation: "supersecret" + }) { + status + message + } + } + ')->assertGraphQLErrorMessage('Field ResetPasswordInput.token of required type String! was not provided.'); + } + + /** + * @test + */ + public function it_returns_an_error_if_the_token_field_is_not_a_string(): void + { + $this->graphQL(/** @lang GraphQL */ ' + mutation { + resetPassword(input: { + email: "foo@bar.com", + token: 12345, + password: "supersecret", + password_confirmation: "supersecret" + }) { + status + message + } + } + ')->assertGraphQLErrorMessage('Field "resetPassword" argument "input" requires type String!, found 12345.'); + } + + /** + * @test + */ + public function it_returns_an_error_if_the_token_is_not_found(): void + { + UserHasApiTokens::factory()->create([ + 'email' => 'foo@bar.com', + ]); + + $this->graphQL(/** @lang GraphQL */ ' + mutation { + resetPassword(input: { + email: "foo@bar.com", + token: "1234567890", + password: "supersecret", + password_confirmation: "supersecret" + }) { + status + message + } + } + ') + ->assertGraphQLErrorMessage('Validation failed for the field [resetPassword].') + ->assertGraphQLValidationError('input', 'This password reset token is invalid.'); + } + + /** + * @test + */ + public function it_returns_an_error_if_the_password_field_is_missing(): void + { + $this->graphQL(/** @lang GraphQL */ ' + mutation { + resetPassword(input: { + email: "foo@bar.com", + token: "1234567890", + password_confirmation: "supersecret" + }) { + status + message + } + } + ')->assertGraphQLErrorMessage('Field ResetPasswordInput.password of required type String! was not provided.'); + } + + /** + * @test + */ + public function it_returns_an_error_if_the_password_field_is_not_a_string(): void + { + $this->graphQL(/** @lang GraphQL */ ' + mutation { + resetPassword(input: { + email: "foo@bar.com", + token: "1234567890", + password: 12345, + password_confirmation: "supersecret" + }) { + status + message + } + } + ')->assertGraphQLErrorMessage('Field "resetPassword" argument "input" requires type String!, found 12345.'); + } + + /** + * @test + */ + public function it_returns_an_error_if_the_password_field_is_not_confirmed(): void + { + $this->graphQL(/** @lang GraphQL */ ' + mutation { + resetPassword(input: { + email: "foo@bar.com", + token: "1234567890", + password: "supersecret", + password_confirmation: "somethingelse" + }) { + status + message + } + } + ') + ->assertGraphQLErrorMessage('Validation failed for the field [resetPassword].') + ->assertGraphQLValidationError( + 'input.password', + 'The input.password confirmation does not match.', + ); + } + + /** + * @test + */ + public function it_returns_an_error_if_the_password_confirmation_field_is_missing(): void + { + $this->graphQL(/** @lang GraphQL */ ' + mutation { + resetPassword(input: { + email: "foo@bar.com", + token: "1234567890", + password: "supersecret", + }) { + status + message + } + } + ')->assertGraphQLErrorMessage('Field ResetPasswordInput.password_confirmation of required type String! was not provided.'); + } + + /** + * @test + */ + public function it_returns_an_error_if_the_password_confirmation_field_is_not_a_string(): void + { + $this->graphQL(/** @lang GraphQL */ ' + mutation { + resetPassword(input: { + email: "foo@bar.com", + token: "1234567890", + password: "supersecret", + password_confirmation: 12345 + }) { + status + message + } + } + ')->assertGraphQLErrorMessage('Field "resetPassword" argument "input" requires type String!, found 12345.'); + } +} diff --git a/tests/Unit/GraphQL/Mutations/ResetPasswordTest.php b/tests/Unit/GraphQL/Mutations/ResetPasswordTest.php new file mode 100644 index 0000000..bc60174 --- /dev/null +++ b/tests/Unit/GraphQL/Mutations/ResetPasswordTest.php @@ -0,0 +1,141 @@ +shouldReceive('setAttribute') + ->with('password', 'some-hash') + ->andReturnSelf() + ->getMock() + ->shouldReceive('save') + ->getMock(); + + /** @var PasswordBroker|MockInterface $passwordBroker */ + $passwordBroker = Mockery::mock(PasswordBroker::class) + ->shouldReceive('reset') + ->withArgs(function (array $credentials, Closure $callback) use ($user) { + $callback($user, 'supersecret'); + + return empty(array_diff($credentials, [ + 'email' => 'foo@bar.com', + 'token' => '1234567890', + 'password' => 'supersecret', + ])); + }) + ->andReturn('passwords.reset') + ->getMock(); + + /** @var Hasher|MockInterface $hash */ + $hash = Mockery::mock(Hasher::class) + ->shouldReceive('make') + ->with('supersecret') + ->andReturn('some-hash') + ->getMock(); + + /** @var Dispatcher|MockInterface $dispatcher */ + $dispatcher = Mockery::mock(Dispatcher::class) + ->shouldReceive('dispatch') + ->withArgs(function (PasswordReset $event) use ($user) { + return $event->user === $user; + }) + ->getMock(); + + /** @var Translator|MockInterface $translator */ + $translator = Mockery::mock(Translator::class) + ->shouldReceive('get') + ->with('passwords.reset') + ->andReturn('response-translation') + ->getMock(); + + $mutation = new ResetPassword( + $passwordBroker, + $hash, + $dispatcher, + $translator, + ); + + $result = $mutation(null, [ + 'email' => 'foo@bar.com', + 'token' => '1234567890', + 'password' => 'supersecret', + 'password_confirmation' => 'supersecret', + ], Mockery::mock(GraphQLContext::class), Mockery::mock(ResolveInfo::class)); + + static::assertIsArray($result); + static::assertCount(2, $result); + static::assertTrue(ResetPasswordStatus::PASSWORD_RESET()->is($result['status'])); + static::assertSame('response-translation', $result['message']); + } + + /** + * @test + */ + public function it_throws_an_exception_if_the_reset_failed(): void + { + static::expectException(ResetPasswordException::class); + static::expectExceptionMessage('Validation failed for the field [some.dotted.path].'); + + /** @var PasswordBroker|MockInterface $passwordBroker */ + $passwordBroker = Mockery::mock(PasswordBroker::class) + ->shouldReceive('reset') + ->withArgs(function (array $credentials, Closure $callback) { + return empty(array_diff($credentials, [ + 'email' => 'foo@bar.com', + 'token' => '1234567890', + 'password' => 'supersecret', + ])); + }) + ->andReturn('some-error') + ->getMock(); + + /** @var Translator|MockInterface $translator */ + $translator = Mockery::mock(Translator::class) + ->shouldReceive('get') + ->with('some-error') + ->andReturn('error-translation') + ->getMock(); + + $resolveInfo = Mockery::mock(ResolveInfo::class); + $resolveInfo->path = ['some', 'dotted', 'path']; + + $mutation = new ResetPassword( + $passwordBroker, + Mockery::mock(Hasher::class), + Mockery::mock(Dispatcher::class), + $translator, + ); + + $mutation(null, [ + 'email' => 'foo@bar.com', + 'token' => '1234567890', + 'password' => 'supersecret', + 'password_confirmation' => 'supersecret', + ], Mockery::mock(GraphQLContext::class), $resolveInfo); + } +}