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