From 882ffca6c342940c04b430946f27f85d070598e9 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Sun, 27 Jul 2025 14:32:58 +0400 Subject: [PATCH 1/3] Password reset endpoints --- config/services/managers.yml | 3 + .../Controller/PasswordResetController.php | 179 ++++++++++++++++++ .../Request/RequestPasswordResetRequest.php | 21 ++ src/Identity/Request/ResetPasswordRequest.php | 25 +++ src/Identity/Request/ValidateTokenRequest.php | 20 ++ 5 files changed, 248 insertions(+) create mode 100644 src/Identity/Controller/PasswordResetController.php create mode 100644 src/Identity/Request/RequestPasswordResetRequest.php create mode 100644 src/Identity/Request/ResetPasswordRequest.php create mode 100644 src/Identity/Request/ValidateTokenRequest.php diff --git a/config/services/managers.yml b/config/services/managers.yml index aa0da43..37f0b02 100644 --- a/config/services/managers.yml +++ b/config/services/managers.yml @@ -52,3 +52,6 @@ services: autowire: true autoconfigure: true + PhpList\Core\Domain\Identity\Service\PasswordManager: + autowire: true + autoconfigure: true diff --git a/src/Identity/Controller/PasswordResetController.php b/src/Identity/Controller/PasswordResetController.php new file mode 100644 index 0000000..de5d3d6 --- /dev/null +++ b/src/Identity/Controller/PasswordResetController.php @@ -0,0 +1,179 @@ +passwordManager = $passwordManager; + } + + #[Route('/request', name: 'request', methods: ['POST'])] + #[OA\Post( + path: '/api/v2/password-reset/request', + description: 'Request a password reset token for an administrator account.', + summary: 'Request a password reset.', + requestBody: new OA\RequestBody( + description: 'Administrator email', + required: true, + content: new OA\JsonContent( + required: ['email'], + properties: [ + new OA\Property(property: 'email', type: 'string', format: 'email', example: 'admin@example.com'), + ] + ) + ), + tags: ['password-reset'], + responses: [ + new OA\Response( + response: 204, + description: 'Password reset token generated', + ), + new OA\Response( + response: 400, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/BadRequestResponse') + ), + new OA\Response( + response: 404, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/NotFoundErrorResponse') + ) + ] + )] + public function requestPasswordReset(Request $request): JsonResponse + { + /** @var RequestPasswordResetRequest $resetRequest */ + $resetRequest = $this->validator->validate($request, RequestPasswordResetRequest::class); + + $this->passwordManager->generatePasswordResetToken($resetRequest->email); + + return $this->json(null, Response::HTTP_NO_CONTENT); + } + + #[Route('/validate', name: 'validate', methods: ['POST'])] + #[OA\Post( + path: '/api/v2/password-reset/validate', + description: 'Validate a password reset token.', + summary: 'Validate a password reset token.', + requestBody: new OA\RequestBody( + description: 'Password reset token', + required: true, + content: new OA\JsonContent( + required: ['token'], + properties: [ + new OA\Property(property: 'token', type: 'string', example: 'a1b2c3d4e5f6'), + ] + ) + ), + tags: ['password-reset'], + responses: [ + new OA\Response( + response: 200, + description: 'Success', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'valid', type: 'boolean', example: true), + ] + ) + ), + new OA\Response( + response: 400, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/BadRequestResponse') + ) + ] + )] + public function validateToken(Request $request): JsonResponse + { + /** @var ValidateTokenRequest $validateRequest */ + $validateRequest = $this->validator->validate($request, ValidateTokenRequest::class); + + $administrator = $this->passwordManager->validatePasswordResetToken($validateRequest->token); + + return $this->json([ 'valid' => $administrator !== null]); + } + + #[Route('/reset', name: 'reset', methods: ['POST'])] + #[OA\Post( + path: '/api/v2/password-reset/reset', + description: 'Reset an administrator password using a token.', + summary: 'Reset password with token.', + requestBody: new OA\RequestBody( + description: 'Password reset information', + required: true, + content: new OA\JsonContent( + required: ['token', 'newPassword'], + properties: [ + new OA\Property(property: 'token', type: 'string', example: 'a1b2c3d4e5f6'), + new OA\Property( + property: 'newPassword', + type: 'string', + format: 'password', + example: 'newSecurePassword123', + ), + ] + ) + ), + tags: ['password-reset'], + responses: [ + new OA\Response( + response: 200, + description: 'Success', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'message', type: 'string', example: 'Password updated successfully'), + ] + ) + ), + new OA\Response( + response: 400, + description: 'Invalid or expired token', + content: new OA\JsonContent(ref: '#/components/schemas/BadRequestResponse') + ) + ] + )] + public function resetPassword(Request $request): JsonResponse + { + /** @var ResetPasswordRequest $resetRequest */ + $resetRequest = $this->validator->validate($request, ResetPasswordRequest::class); + + $success = $this->passwordManager->updatePasswordWithToken( + $resetRequest->token, + $resetRequest->newPassword + ); + + if ($success) { + return $this->json([ 'message' => 'Password updated successfully']); + } + + return $this->json(['message' => 'Invalid or expired token'], Response::HTTP_BAD_REQUEST); + } +} diff --git a/src/Identity/Request/RequestPasswordResetRequest.php b/src/Identity/Request/RequestPasswordResetRequest.php new file mode 100644 index 0000000..de49c8e --- /dev/null +++ b/src/Identity/Request/RequestPasswordResetRequest.php @@ -0,0 +1,21 @@ + Date: Sun, 27 Jul 2025 14:42:41 +0400 Subject: [PATCH 2/3] Tests --- .../PasswordResetControllerTest.php | 109 ++++++++++++++++++ .../RequestPasswordResetRequestTest.php | 22 ++++ .../Request/ResetPasswordRequestTest.php | 24 ++++ .../Request/ValidateTokenRequestTest.php | 22 ++++ 4 files changed, 177 insertions(+) create mode 100644 tests/Integration/Identity/Controller/PasswordResetControllerTest.php create mode 100644 tests/Unit/Identity/Request/RequestPasswordResetRequestTest.php create mode 100644 tests/Unit/Identity/Request/ResetPasswordRequestTest.php create mode 100644 tests/Unit/Identity/Request/ValidateTokenRequestTest.php diff --git a/tests/Integration/Identity/Controller/PasswordResetControllerTest.php b/tests/Integration/Identity/Controller/PasswordResetControllerTest.php new file mode 100644 index 0000000..7bb207d --- /dev/null +++ b/tests/Integration/Identity/Controller/PasswordResetControllerTest.php @@ -0,0 +1,109 @@ +get(PasswordResetController::class) + ); + } + + public function testRequestPasswordResetWithNoJsonReturnsError400(): void + { + $this->jsonRequest('post', '/api/v2/password-reset/request'); + + $this->assertHttpBadRequest(); + $data = $this->getDecodedJsonResponseContent(); + $this->assertStringContainsString('Invalid JSON:', $data['message']); + } + + public function testRequestPasswordResetWithInvalidEmailReturnsError422(): void + { + $jsonData = json_encode(['email' => 'not-an-email']); + $this->jsonRequest('post', '/api/v2/password-reset/request', [], [], [], $jsonData); + + $this->assertHttpUnprocessableEntity(); + $data = $this->getDecodedJsonResponseContent(); + $this->assertStringContainsString('This value is not a valid email address', $data['message']); + } + + public function testRequestPasswordResetWithNonExistentEmailReturnsError404(): void + { + $this->loadFixtures([AdministratorFixture::class]); + $jsonData = json_encode(['email' => 'nonexistent@example.com']); + $this->jsonRequest('post', '/api/v2/password-reset/request', [], [], [], $jsonData); + + $this->assertHttpNotFound(); + } + + public function testRequestPasswordResetWithValidEmailReturnsSuccess(): void + { + $this->loadFixtures([AdministratorFixture::class]); + $jsonData = json_encode(['email' => 'john@example.com']); + $this->jsonRequest('post', '/api/v2/password-reset/request', [], [], [], $jsonData); + + $this->assertHttpNoContent(); + } + + public function testValidateTokenWithNoJsonReturnsError400(): void + { + $this->jsonRequest('post', '/api/v2/password-reset/validate'); + + $this->assertHttpBadRequest(); + $data = $this->getDecodedJsonResponseContent(); + $this->assertStringContainsString('Invalid JSON:', $data['message']); + } + + public function testValidateTokenWithInvalidTokenReturnsInvalidResult(): void + { + $this->loadFixtures([AdministratorFixture::class]); + $jsonData = json_encode(['token' => 'invalid-token']); + $this->jsonRequest('post', '/api/v2/password-reset/validate', [], [], [], $jsonData); + + $this->assertHttpOkay(); + $data = $this->getDecodedJsonResponseContent(); + $this->assertFalse($data['valid']); + } + + public function testResetPasswordWithNoJsonReturnsError400(): void + { + $this->jsonRequest('post', '/api/v2/password-reset/reset'); + + $this->assertHttpBadRequest(); + $data = $this->getDecodedJsonResponseContent(); + $this->assertStringContainsString('Invalid JSON:', $data['message']); + } + + public function testResetPasswordWithInvalidTokenReturnsBadRequest(): void + { + $this->loadFixtures([AdministratorFixture::class]); + $jsonData = json_encode(['token' => 'invalid-token', 'newPassword' => 'newPassword123']); + $this->jsonRequest('post', '/api/v2/password-reset/reset', [], [], [], $jsonData); + + $this->assertHttpBadRequest(); + $data = $this->getDecodedJsonResponseContent(); + $this->assertEquals('Invalid or expired token', $data['message']); + } + + public function testResetPasswordWithShortPasswordReturnsError422(): void + { + $this->loadFixtures([AdministratorFixture::class]); + $jsonData = json_encode(['token' => 'valid-token', 'newPassword' => 'short']); + $this->jsonRequest('post', '/api/v2/password-reset/reset', [], [], [], $jsonData); + + $this->assertHttpUnprocessableEntity(); + $data = $this->getDecodedJsonResponseContent(); + $this->assertStringContainsString('This value is too short', $data['message']); + } +} diff --git a/tests/Unit/Identity/Request/RequestPasswordResetRequestTest.php b/tests/Unit/Identity/Request/RequestPasswordResetRequestTest.php new file mode 100644 index 0000000..e4a61ed --- /dev/null +++ b/tests/Unit/Identity/Request/RequestPasswordResetRequestTest.php @@ -0,0 +1,22 @@ +email = 'test@example.com'; + + $dto = $request->getDto(); + + $this->assertSame($request, $dto); + $this->assertEquals('test@example.com', $dto->email); + } +} diff --git a/tests/Unit/Identity/Request/ResetPasswordRequestTest.php b/tests/Unit/Identity/Request/ResetPasswordRequestTest.php new file mode 100644 index 0000000..aff493f --- /dev/null +++ b/tests/Unit/Identity/Request/ResetPasswordRequestTest.php @@ -0,0 +1,24 @@ +token = 'test-token-123'; + $request->newPassword = 'newSecurePassword123'; + + $dto = $request->getDto(); + + $this->assertSame($request, $dto); + $this->assertEquals('test-token-123', $dto->token); + $this->assertEquals('newSecurePassword123', $dto->newPassword); + } +} diff --git a/tests/Unit/Identity/Request/ValidateTokenRequestTest.php b/tests/Unit/Identity/Request/ValidateTokenRequestTest.php new file mode 100644 index 0000000..9346881 --- /dev/null +++ b/tests/Unit/Identity/Request/ValidateTokenRequestTest.php @@ -0,0 +1,22 @@ +token = 'test-token-123'; + + $dto = $request->getDto(); + + $this->assertSame($request, $dto); + $this->assertEquals('test-token-123', $dto->token); + } +} From 86b2755aea2405454238d9c2751303e7da64ccf8 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Sun, 27 Jul 2025 14:59:19 +0400 Subject: [PATCH 3/3] Remove unused import --- .../Identity/Controller/PasswordResetControllerTest.php | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/Integration/Identity/Controller/PasswordResetControllerTest.php b/tests/Integration/Identity/Controller/PasswordResetControllerTest.php index 7bb207d..4c73d3c 100644 --- a/tests/Integration/Identity/Controller/PasswordResetControllerTest.php +++ b/tests/Integration/Identity/Controller/PasswordResetControllerTest.php @@ -4,7 +4,6 @@ namespace PhpList\RestBundle\Tests\Integration\Identity\Controller; -use PhpList\Core\Domain\Identity\Service\PasswordManager; use PhpList\RestBundle\Identity\Controller\PasswordResetController; use PhpList\RestBundle\Tests\Integration\Common\AbstractTestController; use PhpList\RestBundle\Tests\Integration\Identity\Fixtures\AdministratorFixture;