setPasswordResetNonce('old-nonce'); $now = time(); $userRepository = $this->createMock(UserRepository::class); $entityManager = $this->createMock(EntityManagerInterface::class); $passwordHasher = $this->createMock(UserPasswordHasherInterface::class); $urlGenerator = $this->createMock(UrlGeneratorInterface::class); $mailer = $this->createMock(Mailer::class); $uriSigner = new UriSigner('test-secret'); $generatedRoute = null; $generatedParameters = null; $generatedReferenceType = null; $capturedContext = null; $capturedOptions = null; $userRepository ->expects(self::once()) ->method('findOneByNormalizedEmail') ->with('customer@example.com') ->willReturn($user); $entityManager->expects(self::once())->method('flush'); $passwordHasher->expects(self::never())->method('hashPassword'); $urlGenerator ->expects(self::once()) ->method('generate') ->willReturnCallback(static function (string $route, array $parameters, int $referenceType) use (&$generatedRoute, &$generatedParameters, &$generatedReferenceType): string { $generatedRoute = $route; $generatedParameters = $parameters; $generatedReferenceType = $referenceType; return 'https://example.test/reset-password/confirm?'.http_build_query($parameters); }); $mailer ->expects(self::once()) ->method('createAndSendEmail') ->willReturnCallback(static function (array $context, array $options) use (&$capturedContext, &$capturedOptions): void { $capturedContext = $context; $capturedOptions = $options; }); $manager = $this->createManager($userRepository, $entityManager, $passwordHasher, $urlGenerator, $mailer); self::assertTrue($manager->requestPasswordReset(' Customer@Example.COM ')); self::assertSame('app_reset_password_confirm', $generatedRoute); self::assertSame(UrlGeneratorInterface::ABSOLUTE_URL, $generatedReferenceType); self::assertSame('customer@example.com', $generatedParameters['email']); self::assertSame($user->getPasswordResetNonce(), $generatedParameters['nonce']); self::assertIsInt($generatedParameters['expires']); self::assertGreaterThan($now, $generatedParameters['expires']); self::assertLessThanOrEqual($now + 3600, $generatedParameters['expires']); self::assertSame('customer@example.com', $capturedOptions['to']); self::assertSame('Dein MyE&P Passwort zurücksetzen', $capturedOptions['subject']); self::assertSame('email/password_reset.html.twig', $capturedOptions['template']); self::assertArrayHasKey('resetUrl', $capturedContext); self::assertTrue($uriSigner->check($capturedContext['resetUrl'])); $parts = parse_url($capturedContext['resetUrl']); parse_str($parts['query'] ?? '', $query); self::assertSame('customer@example.com', $query['email']); self::assertSame($user->getPasswordResetNonce(), $query['nonce']); self::assertArrayHasKey('expires', $query); self::assertNotSame('old-nonce', $user->getPasswordResetNonce()); self::assertMatchesRegularExpression('/^[a-f0-9]{64}$/', (string) $user->getPasswordResetNonce()); } public function testValidateResetRequestAcceptsValidSignedRequest(): void { $user = new User('customer@example.com'); $user->setPasswordResetNonce('current-nonce'); $manager = $this->createManagerForValidation($user); $request = Request::create($this->buildResetUrl('customer@example.com', 'current-nonce', time() + 3600), 'GET'); self::assertSame($user, $manager->validateResetRequest($request)); } public function testValidateResetRequestRejectsTamperedLink(): void { $user = new User('customer@example.com'); $user->setPasswordResetNonce('current-nonce'); $manager = $this->createManagerForValidation($user); $signedUrl = $this->buildResetUrl('customer@example.com', 'current-nonce', time() + 3600); $parts = parse_url($signedUrl); parse_str($parts['query'] ?? '', $query); $query['nonce'] = 'tampered-nonce'; $request = Request::create( 'https://example.test/reset-password/confirm?'.http_build_query($query), 'GET', ); $this->expectException(NotFoundHttpException::class); $manager->validateResetRequest($request); } public function testValidateResetRequestRejectsExpiredLink(): void { $user = new User('customer@example.com'); $user->setPasswordResetNonce('current-nonce'); $manager = $this->createManagerForValidation($user); $request = Request::create($this->buildResetUrl('customer@example.com', 'current-nonce', time() - 60), 'GET'); $this->expectException(NotFoundHttpException::class); $manager->validateResetRequest($request); } public function testValidateResetRequestRejectsNonceMismatch(): void { $user = new User('customer@example.com'); $user->setPasswordResetNonce('different-nonce'); $manager = $this->createManagerForValidation($user); $request = Request::create($this->buildResetUrl('customer@example.com', 'current-nonce', time() + 3600), 'GET'); $this->expectException(NotFoundHttpException::class); $manager->validateResetRequest($request); } public function testResetPasswordHashesPasswordAndRotatesNonce(): void { $user = new User('customer@example.com'); $user->setPasswordResetNonce('current-nonce'); $user->setPreviousPassword('legacy-backup'); $userRepository = $this->createMock(UserRepository::class); $entityManager = $this->createMock(EntityManagerInterface::class); $passwordHasher = $this->createMock(UserPasswordHasherInterface::class); $urlGenerator = $this->createMock(UrlGeneratorInterface::class); $mailer = $this->createMock(Mailer::class); $passwordHasher ->expects(self::once()) ->method('hashPassword') ->with($user, 'new-password') ->willReturn('hashed-password'); $entityManager->expects(self::once())->method('flush'); $userRepository->expects(self::never())->method('findOneByNormalizedEmail'); $urlGenerator->expects(self::never())->method('generate'); $mailer->expects(self::never())->method('createAndSendEmail'); $manager = $this->createManager($userRepository, $entityManager, $passwordHasher, $urlGenerator, $mailer); $manager->resetPassword($user, 'new-password'); self::assertSame('hashed-password', $user->getPassword()); self::assertSame('legacy-backup', $user->getPreviousPassword()); self::assertNotSame('current-nonce', $user->getPasswordResetNonce()); self::assertMatchesRegularExpression('/^[a-f0-9]{64}$/', (string) $user->getPasswordResetNonce()); } public function testNewPasswordResetRequestInvalidatesPreviousLink(): void { $user = new User('customer@example.com'); $user->setPasswordResetNonce('old-nonce'); $userRepository = $this->createMock(UserRepository::class); $entityManager = $this->createMock(EntityManagerInterface::class); $passwordHasher = $this->createMock(UserPasswordHasherInterface::class); $urlGenerator = $this->createMock(UrlGeneratorInterface::class); $mailer = $this->createMock(Mailer::class); $uriSigner = new UriSigner('test-secret'); $generatedRoutes = []; $generatedParametersList = []; $userRepository ->expects(self::exactly(3)) ->method('findOneByNormalizedEmail') ->willReturnCallback(static function (string $email) use ($user): ?User { return 'customer@example.com' === $email ? $user : null; }); $entityManager->expects(self::exactly(2))->method('flush'); $passwordHasher->expects(self::never())->method('hashPassword'); $urlGenerator ->expects(self::exactly(2)) ->method('generate') ->willReturnCallback(static function (string $route, array $parameters, int $referenceType) use (&$generatedRoutes, &$generatedParametersList): string { $generatedRoutes[] = $route; $generatedParametersList[] = $parameters; return 'https://example.test/reset-password/confirm?'.http_build_query($parameters); }); $capturedUrls = []; $mailer ->expects(self::exactly(2)) ->method('createAndSendEmail') ->willReturnCallback(static function (array $context, array $options) use (&$capturedUrls): void { $capturedUrls[] = $context['resetUrl']; }); $manager = $this->createManager($userRepository, $entityManager, $passwordHasher, $urlGenerator, $mailer); self::assertTrue($manager->requestPasswordReset('customer@example.com')); self::assertTrue($manager->requestPasswordReset('customer@example.com')); self::assertCount(2, $generatedRoutes); self::assertSame(['app_reset_password_confirm', 'app_reset_password_confirm'], $generatedRoutes); self::assertCount(2, $generatedParametersList); self::assertSame('customer@example.com', $generatedParametersList[0]['email']); self::assertSame('customer@example.com', $generatedParametersList[1]['email']); self::assertNotSame($generatedParametersList[0]['nonce'], $generatedParametersList[1]['nonce']); self::assertCount(2, $capturedUrls); self::assertTrue($uriSigner->check($capturedUrls[0])); self::assertTrue($uriSigner->check($capturedUrls[1])); self::assertNotSame($capturedUrls[0], $capturedUrls[1]); $firstQuery = []; $secondQuery = []; parse_str(parse_url($capturedUrls[0], PHP_URL_QUERY) ?? '', $firstQuery); parse_str(parse_url($capturedUrls[1], PHP_URL_QUERY) ?? '', $secondQuery); self::assertSame('customer@example.com', $firstQuery['email']); self::assertSame('customer@example.com', $secondQuery['email']); self::assertNotSame($firstQuery['nonce'], $secondQuery['nonce']); $this->expectException(NotFoundHttpException::class); $manager->validateResetRequest(Request::create($capturedUrls[0], 'GET')); } private function createManager( UserRepository $userRepository, EntityManagerInterface $entityManager, UserPasswordHasherInterface $passwordHasher, UrlGeneratorInterface $urlGenerator, Mailer $mailer, ): PasswordResetManager { return new PasswordResetManager( $userRepository, $entityManager, $passwordHasher, $urlGenerator, new UriSigner('test-secret'), $mailer, new NullLogger(), ); } private function createManagerForValidation(User $user): PasswordResetManager { $userRepository = $this->createMock(UserRepository::class); $entityManager = $this->createMock(EntityManagerInterface::class); $passwordHasher = $this->createMock(UserPasswordHasherInterface::class); $urlGenerator = $this->createMock(UrlGeneratorInterface::class); $mailer = $this->createMock(Mailer::class); $userRepository ->method('findOneByNormalizedEmail') ->willReturnCallback(static function (string $email) use ($user): ?User { return 'customer@example.com' === $email ? $user : null; }); $entityManager->expects(self::never())->method('flush'); $passwordHasher->expects(self::never())->method('hashPassword'); $urlGenerator->expects(self::never())->method('generate'); $mailer->expects(self::never())->method('createAndSendEmail'); return $this->createManager($userRepository, $entityManager, $passwordHasher, $urlGenerator, $mailer); } private function buildResetUrl(string $email, string $nonce, int $expires): string { $uriSigner = new UriSigner('test-secret'); $url = 'https://example.test/reset-password/confirm?'.http_build_query([ 'email' => $email, 'nonce' => $nonce, 'expires' => $expires, ]); return $uriSigner->sign($url); } }