Files
myep/tests/Service/PasswordResetManagerTest.php
T

323 lines
14 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\Email\Mailer;
use App\Entity\User;
use App\Repository\UserRepository;
use App\Service\PasswordResetManager;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\UriSigner;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class PasswordResetManagerTest extends TestCase
{
public function testRequestPasswordResetStoresNonceAndSendsSignedLink(): void
{
$user = new User('[email protected]');
$user->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('[email protected]')
->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(' [email protected] '));
self::assertSame('app_reset_password_confirm', $generatedRoute);
self::assertSame(UrlGeneratorInterface::ABSOLUTE_URL, $generatedReferenceType);
self::assertSame('[email protected]', $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('[email protected]', $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('[email protected]', $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('[email protected]');
$user->setPasswordResetNonce('current-nonce');
$manager = $this->createManagerForValidation($user);
$request = Request::create($this->buildResetUrl('[email protected]', 'current-nonce', time() + 3600), 'GET');
self::assertSame($user, $manager->validateResetRequest($request));
}
public function testValidateResetRequestRejectsTamperedLink(): void
{
$user = new User('[email protected]');
$user->setPasswordResetNonce('current-nonce');
$manager = $this->createManagerForValidation($user);
$signedUrl = $this->buildResetUrl('[email protected]', '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('[email protected]');
$user->setPasswordResetNonce('current-nonce');
$manager = $this->createManagerForValidation($user);
$request = Request::create($this->buildResetUrl('[email protected]', 'current-nonce', time() - 60), 'GET');
$this->expectException(NotFoundHttpException::class);
$manager->validateResetRequest($request);
}
public function testValidateResetRequestRejectsNonceMismatch(): void
{
$user = new User('[email protected]');
$user->setPasswordResetNonce('different-nonce');
$manager = $this->createManagerForValidation($user);
$request = Request::create($this->buildResetUrl('[email protected]', 'current-nonce', time() + 3600), 'GET');
$this->expectException(NotFoundHttpException::class);
$manager->validateResetRequest($request);
}
public function testResetPasswordHashesPasswordAndRotatesNonce(): void
{
$user = new User('[email protected]');
$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 testResetPasswordClearsPendingWelcomeInvite(): void
{
$user = new User('[email protected]');
$user->setPasswordResetNonce('current-nonce');
$user->markWelcomeActivationPending('welcome-nonce', new \DateTimeImmutable('-30 minutes'));
$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');
$manager = $this->createManager($userRepository, $entityManager, $passwordHasher, $urlGenerator, $mailer);
$manager->resetPassword($user, 'new-password');
self::assertSame('hashed-password', $user->getPassword());
self::assertNull($user->getWelcomeActivationNonce());
self::assertNull($user->getWelcomeActivationExpiresAt());
self::assertNotNull($user->getWelcomeActivatedAt());
}
public function testNewPasswordResetRequestInvalidatesPreviousLink(): void
{
$user = new User('[email protected]');
$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 '[email protected]' === $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('[email protected]'));
self::assertTrue($manager->requestPasswordReset('[email protected]'));
self::assertCount(2, $generatedRoutes);
self::assertSame(['app_reset_password_confirm', 'app_reset_password_confirm'], $generatedRoutes);
self::assertCount(2, $generatedParametersList);
self::assertSame('[email protected]', $generatedParametersList[0]['email']);
self::assertSame('[email protected]', $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('[email protected]', $firstQuery['email']);
self::assertSame('[email protected]', $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 '[email protected]' === $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);
}
}