Files
myep/tests/Service/BookingGuestAccountManagerTest.php
T

624 lines
27 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\BpnConnect\BpnConnectClient;
use App\BpnConnect\Model\BookingLookupResult;
use App\Email\Mailer;
use App\Entity\User;
use App\Exception\GuestAccountActivationExpiredException;
use App\Repository\UserRepository;
use App\Service\BookingGuestAccountManager;
use App\Service\UserBootstrapper;
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\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Mailer\Exception\TransportException;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
class BookingGuestAccountManagerTest extends TestCase
{
public function testProvisionGuestAccountCreatesCustomerAndSendsActivationMail(): void
{
$persistedUser = null;
$plainPassword = null;
$generatedRoute = null;
$generatedParameters = null;
$generatedReferenceType = null;
$capturedContext = null;
$capturedOptions = null;
$userRepository = $this->createMock(UserRepository::class);
$userRepository
->expects(self::once())
->method('findOneByNormalizedEmail')
->with('[email protected]')
->willReturn(null);
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager
->expects(self::once())
->method('persist')
->with(self::callback(static function (User $user) use (&$persistedUser): bool {
$persistedUser = $user;
return true;
}));
$entityManager
->expects(self::once())
->method('flush');
$passwordHasher = $this->createMock(UserPasswordHasherInterface::class);
$passwordHasher
->expects(self::once())
->method('hashPassword')
->with(self::isInstanceOf(User::class), self::callback(static function (string $password) use (&$plainPassword): bool {
$plainPassword = $password;
return '' !== $password;
}))
->willReturn('hashed-password');
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$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/welcome/activate?'.http_build_query($parameters);
});
$mailer = $this->createMock(Mailer::class);
$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);
$uriSigner = new UriSigner('test-secret');
self::assertTrue($manager->provisionGuestAccount(' [email protected] ', 'Alice', 'Example', 12345, 67890));
self::assertSame('app_guest_account_activate', $generatedRoute);
self::assertSame(UrlGeneratorInterface::ABSOLUTE_URL, $generatedReferenceType);
self::assertSame('[email protected]', $generatedParameters['email']);
self::assertSame($persistedUser?->getWelcomeActivationNonce(), $generatedParameters['nonce']);
self::assertTrue($uriSigner->check((string) $capturedContext['activationUrl']));
self::assertSame('[email protected]', $capturedOptions['to']);
self::assertSame('Dein MyE&P Konto ist bereit', $capturedOptions['subject']);
self::assertSame('email/booking_guest_account_welcome.html.twig', $capturedOptions['template']);
self::assertSame('Alice', $capturedContext['firstName']);
self::assertSame('Example', $capturedContext['lastName']);
self::assertMatchesRegularExpression('/^[a-f0-9]{48}$/', (string) $plainPassword);
self::assertInstanceOf(User::class, $persistedUser);
self::assertSame('[email protected]', $persistedUser->getEmail());
self::assertSame(12345, $persistedUser->getAddressId());
self::assertSame(67890, $persistedUser->getPersonId());
self::assertSame(['ROLE_USER', 'ROLE_CUSTOMER'], $persistedUser->getRoles());
self::assertSame([], $persistedUser->getHotelCodes());
self::assertTrue($persistedUser->isProfileComplete());
self::assertMatchesRegularExpression('/^[a-f0-9]{64}$/', (string) $persistedUser->getWelcomeActivationNonce());
self::assertInstanceOf(\DateTimeImmutable::class, $persistedUser->getWelcomeActivationExpiresAt());
self::assertNull($persistedUser->getWelcomeActivatedAt());
}
public function testProvisionGuestAccountReturnsFalseWhenLocalUserAlreadyExists(): void
{
$userRepository = $this->createMock(UserRepository::class);
$userRepository
->expects(self::once())
->method('findOneByNormalizedEmail')
->with('[email protected]')
->willReturn(new User('[email protected]'));
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects(self::never())->method('persist');
$entityManager->expects(self::never())->method('flush');
$passwordHasher = $this->createMock(UserPasswordHasherInterface::class);
$passwordHasher->expects(self::never())->method('hashPassword');
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$urlGenerator->expects(self::never())->method('generate');
$mailer = $this->createMock(Mailer::class);
$mailer->expects(self::never())->method('createAndSendEmail');
$manager = $this->createManager($userRepository, $entityManager, $passwordHasher, $urlGenerator, $mailer);
self::assertFalse($manager->provisionGuestAccount('[email protected]', 'Alice', 'Example'));
}
public function testRequestPortalAccessProvisionsGuestAccountWhenBookingMatches(): void
{
$persistedUser = null;
$capturedContext = null;
$capturedOptions = null;
$booking = $this->createLookupResult('[email protected]', 123, 'Alice', 'Example');
$bpnConnectClient = $this->createMock(BpnConnectClient::class);
$bpnConnectClient
->expects(self::once())
->method('lookupBooking')
->with('[email protected]', 123)
->willReturn($booking);
$userRepository = $this->createMock(UserRepository::class);
$userRepository
->expects(self::exactly(2))
->method('findOneByNormalizedEmail')
->with('[email protected]')
->willReturnOnConsecutiveCalls(null, null);
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager
->expects(self::once())
->method('persist')
->with(self::callback(static function (User $user) use (&$persistedUser): bool {
$persistedUser = $user;
return true;
}));
$entityManager->expects(self::once())->method('flush');
$passwordHasher = $this->createMock(UserPasswordHasherInterface::class);
$passwordHasher
->expects(self::once())
->method('hashPassword')
->willReturn('hashed-password');
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$urlGenerator
->expects(self::once())
->method('generate')
->willReturn('https://example.test/welcome/[email protected]&nonce=current&expires=9999999999');
$mailer = $this->createMock(Mailer::class);
$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, $bpnConnectClient);
self::assertSame(BookingGuestAccountManager::PORTAL_ACCESS_SENT, $manager->requestPortalAccess('[email protected]', 123));
self::assertInstanceOf(User::class, $persistedUser);
self::assertSame('[email protected]', $persistedUser->getEmail());
self::assertSame(12345, $persistedUser->getAddressId());
self::assertSame(67890, $persistedUser->getPersonId());
self::assertSame('[email protected]', $capturedOptions['to']);
self::assertSame('Alice', $capturedContext['firstName']);
self::assertSame('Example', $capturedContext['lastName']);
self::assertMatchesRegularExpression('/^[a-f0-9]{64}$/', (string) $persistedUser->getWelcomeActivationNonce());
self::assertInstanceOf(\DateTimeImmutable::class, $persistedUser->getWelcomeActivationExpiresAt());
self::assertNull($persistedUser->getWelcomeActivatedAt());
}
public function testRequestPortalAccessResendsPendingInviteWhenBookingMatches(): void
{
$user = new User('[email protected]');
$user->setAddressId(12345);
$user->setPersonId(67890);
$user->setWelcomeActivationNonce('current-nonce');
$user->setWelcomeActivationExpiresAt(new \DateTimeImmutable('+30 minutes'));
$booking = $this->createLookupResult('[email protected]', 123, 'Alice', 'Example');
$bpnConnectClient = $this->createMock(BpnConnectClient::class);
$bpnConnectClient
->expects(self::once())
->method('lookupBooking')
->with('[email protected]', 123)
->willReturn($booking);
$userRepository = $this->createMock(UserRepository::class);
$userRepository
->expects(self::once())
->method('findOneByNormalizedEmail')
->with('[email protected]')
->willReturn($user);
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects(self::once())->method('flush');
$passwordHasher = $this->createMock(UserPasswordHasherInterface::class);
$passwordHasher->expects(self::never())->method('hashPassword');
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$urlGenerator
->expects(self::once())
->method('generate')
->willReturn('https://example.test/welcome/[email protected]&nonce=new-nonce&expires=9999999999');
$mailer = $this->createMock(Mailer::class);
$mailer
->expects(self::once())
->method('createAndSendEmail');
$manager = $this->createManager($userRepository, $entityManager, $passwordHasher, $urlGenerator, $mailer, $bpnConnectClient);
self::assertSame(BookingGuestAccountManager::PORTAL_ACCESS_SENT, $manager->requestPortalAccess('[email protected]', 123));
self::assertNotSame('current-nonce', $user->getWelcomeActivationNonce());
self::assertNull($user->getWelcomeActivatedAt());
self::assertInstanceOf(\DateTimeImmutable::class, $user->getWelcomeActivationExpiresAt());
}
public function testRequestPortalAccessUsesExistingUserIdsAndDoesNotMailActivatedAccount(): void
{
$user = new User('[email protected]');
$user->setAddressId(12345);
$user->setPersonId(67890);
$user->markWelcomeActivated();
$booking = $this->createLookupResult('[email protected]', 123, 'Alice', 'Example');
$bpnConnectClient = $this->createMock(BpnConnectClient::class);
$bpnConnectClient
->expects(self::once())
->method('lookupBooking')
->with('[email protected]', 123)
->willReturn($booking);
$userRepository = $this->createMock(UserRepository::class);
$userRepository
->expects(self::once())
->method('findOneByNormalizedEmail')
->with('[email protected]')
->willReturn($user);
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects(self::never())->method('persist');
$entityManager->expects(self::never())->method('flush');
$passwordHasher = $this->createMock(UserPasswordHasherInterface::class);
$passwordHasher->expects(self::never())->method('hashPassword');
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$urlGenerator->expects(self::never())->method('generate');
$mailer = $this->createMock(Mailer::class);
$mailer->expects(self::never())->method('createAndSendEmail');
$manager = $this->createManager($userRepository, $entityManager, $passwordHasher, $urlGenerator, $mailer, $bpnConnectClient);
self::assertSame(BookingGuestAccountManager::PORTAL_ACCESS_ALREADY_ACTIVE, $manager->requestPortalAccess('[email protected]', 123));
}
public function testProvisionGuestAccountForBookingFetchesBookingIdentifiers(): void
{
$persistedUser = null;
$booking = $this->createLookupResult('[email protected]', 123, 'Alice', 'Example');
$bpnConnectClient = $this->createMock(BpnConnectClient::class);
$bpnConnectClient
->expects(self::once())
->method('lookupBooking')
->with('[email protected]', 123)
->willReturn($booking);
$userRepository = $this->createMock(UserRepository::class);
$userRepository
->expects(self::once())
->method('findOneByNormalizedEmail')
->with('[email protected]')
->willReturn(null);
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager
->expects(self::once())
->method('persist')
->with(self::callback(static function (User $user) use (&$persistedUser): bool {
$persistedUser = $user;
return true;
}));
$entityManager->expects(self::once())->method('flush');
$passwordHasher = $this->createMock(UserPasswordHasherInterface::class);
$passwordHasher
->expects(self::once())
->method('hashPassword')
->willReturn('hashed-password');
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$urlGenerator
->expects(self::once())
->method('generate')
->willReturn('https://example.test/welcome/[email protected]&nonce=current&expires=9999999999');
$mailer = $this->createMock(Mailer::class);
$mailer
->expects(self::once())
->method('createAndSendEmail');
$manager = $this->createManager($userRepository, $entityManager, $passwordHasher, $urlGenerator, $mailer, $bpnConnectClient);
self::assertTrue($manager->provisionGuestAccountForBooking('[email protected]', 123));
self::assertInstanceOf(User::class, $persistedUser);
self::assertSame('[email protected]', $persistedUser->getEmail());
self::assertSame(12345, $persistedUser->getAddressId());
self::assertSame(67890, $persistedUser->getPersonId());
}
public function testRequestPortalAccessRejectsMismatchedBookingGenerically(): void
{
$booking = $this->createLookupResult('[email protected]', 123, 'Alice', 'Example');
$bpnConnectClient = $this->createMock(BpnConnectClient::class);
$bpnConnectClient
->expects(self::once())
->method('lookupBooking')
->with('[email protected]', 123)
->willReturn($booking);
$userRepository = $this->createMock(UserRepository::class);
$userRepository
->expects(self::once())
->method('findOneByNormalizedEmail')
->with('[email protected]')
->willReturn(null);
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects(self::never())->method('persist');
$entityManager->expects(self::never())->method('flush');
$passwordHasher = $this->createMock(UserPasswordHasherInterface::class);
$passwordHasher->expects(self::never())->method('hashPassword');
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$urlGenerator->expects(self::never())->method('generate');
$mailer = $this->createMock(Mailer::class);
$mailer->expects(self::never())->method('createAndSendEmail');
$manager = $this->createManager($userRepository, $entityManager, $passwordHasher, $urlGenerator, $mailer, $bpnConnectClient);
self::assertSame(BookingGuestAccountManager::PORTAL_ACCESS_NOT_SENT, $manager->requestPortalAccess('[email protected]', 123));
}
public function testConsumeActivationMarksInviteAsActivated(): void
{
$user = new User('[email protected]');
$user->setWelcomeActivationNonce('current-nonce');
$user->setWelcomeActivationExpiresAt(new \DateTimeImmutable('+30 minutes'));
$manager = $this->createValidationManager($user);
$manager->consumeActivation($user);
self::assertNull($user->getWelcomeActivationNonce());
self::assertNull($user->getWelcomeActivationExpiresAt());
self::assertInstanceOf(\DateTimeImmutable::class, $user->getWelcomeActivatedAt());
}
public function testValidateActivationRequestAcceptsValidSignedRequestAndRejectsReplay(): void
{
$user = new User('[email protected]');
$user->setWelcomeActivationNonce('current-nonce');
$manager = $this->createValidationManager($user);
$request = Request::create(
$this->buildActivationUrl('[email protected]', 'current-nonce', time() + 3600),
'GET'
);
self::assertSame($user, $manager->validateActivationRequest($request));
$manager->consumeActivation($user);
$this->expectException(NotFoundHttpException::class);
$manager->validateActivationRequest($request);
}
public function testValidateActivationRequestRejectsExpiredLink(): void
{
$user = new User('[email protected]');
$user->setWelcomeActivationNonce('current-nonce');
$manager = $this->createValidationManager($user);
$request = Request::create(
$this->buildActivationUrl('[email protected]', 'current-nonce', time() - 60),
'GET'
);
$this->expectException(GuestAccountActivationExpiredException::class);
$manager->validateActivationRequest($request);
}
public function testValidateActivationUrlForResendAcceptsExpiredLink(): void
{
$user = new User('[email protected]');
$user->setWelcomeActivationNonce('current-nonce');
$manager = $this->createValidationManager($user);
$activationUrl = $this->buildActivationUrl('[email protected]', 'current-nonce', time() - 60);
self::assertSame($user, $manager->validateActivationUrlForResend($activationUrl));
}
public function testResendWelcomeEmailRotatesNonceAndSendsActivationMail(): void
{
$user = new User('[email protected]');
$user->setWelcomeActivationNonce('old-nonce');
$generatedRoute = null;
$generatedParameters = null;
$generatedReferenceType = null;
$capturedContext = null;
$capturedOptions = null;
$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);
$entityManager->expects(self::once())->method('flush');
$passwordHasher->expects(self::never())->method('hashPassword');
$userRepository->expects(self::never())->method('findOneByNormalizedEmail');
$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/welcome/activate?'.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);
$manager->resendWelcomeEmail($user);
self::assertSame('app_guest_account_activate', $generatedRoute);
self::assertSame(UrlGeneratorInterface::ABSOLUTE_URL, $generatedReferenceType);
self::assertSame('[email protected]', $generatedParameters['email']);
self::assertSame($user->getWelcomeActivationNonce(), $generatedParameters['nonce']);
self::assertSame('[email protected]', $capturedOptions['to']);
self::assertSame('Dein MyE&P Konto ist bereit', $capturedOptions['subject']);
self::assertSame('email/booking_guest_account_welcome.html.twig', $capturedOptions['template']);
self::assertTrue((new UriSigner('test-secret'))->check((string) $capturedContext['activationUrl']));
self::assertNotSame('old-nonce', $user->getWelcomeActivationNonce());
self::assertMatchesRegularExpression('/^[a-f0-9]{64}$/', (string) $user->getWelcomeActivationNonce());
}
public function testProvisionGuestAccountKeepsPersistedUserWhenMailSendFails(): void
{
$persistedUser = null;
$userRepository = $this->createMock(UserRepository::class);
$userRepository
->expects(self::once())
->method('findOneByNormalizedEmail')
->with('[email protected]')
->willReturn(null);
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager
->expects(self::once())
->method('persist')
->with(self::callback(static function (User $user) use (&$persistedUser): bool {
$persistedUser = $user;
return true;
}));
$entityManager->expects(self::once())->method('flush');
$passwordHasher = $this->createMock(UserPasswordHasherInterface::class);
$passwordHasher
->expects(self::once())
->method('hashPassword')
->willReturn('hashed-password');
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$urlGenerator
->expects(self::once())
->method('generate')
->willReturn('https://example.test/welcome/[email protected]&nonce=current&expires=9999999999');
$mailer = $this->createMock(Mailer::class);
$mailer
->expects(self::once())
->method('createAndSendEmail')
->willThrowException(new TransportException('smtp down'));
$manager = $this->createManager($userRepository, $entityManager, $passwordHasher, $urlGenerator, $mailer);
$this->expectException(TransportException::class);
try {
$manager->provisionGuestAccount('[email protected]', 'Alice', 'Example');
} finally {
self::assertInstanceOf(User::class, $persistedUser);
self::assertSame('[email protected]', $persistedUser->getEmail());
self::assertSame(['ROLE_USER', 'ROLE_CUSTOMER'], $persistedUser->getRoles());
self::assertTrue($persistedUser->isProfileComplete());
self::assertNotNull($persistedUser->getWelcomeActivationNonce());
}
}
private function createManager(
UserRepository $userRepository,
EntityManagerInterface $entityManager,
UserPasswordHasherInterface $passwordHasher,
UrlGeneratorInterface $urlGenerator,
Mailer $mailer,
?BpnConnectClient $bpnConnectClient = null,
): BookingGuestAccountManager {
$bpnConnectClient ??= $this->createMock(BpnConnectClient::class);
return new BookingGuestAccountManager(
$bpnConnectClient,
$userRepository,
$entityManager,
new UserBootstrapper(),
$passwordHasher,
$urlGenerator,
new UriSigner('test-secret'),
$mailer,
new NullLogger(),
);
}
private function createValidationManager(User $user): BookingGuestAccountManager
{
$userRepository = $this->createMock(UserRepository::class);
$userRepository
->method('findOneByNormalizedEmail')
->willReturnCallback(static function (string $email) use ($user): ?User {
return '[email protected]' === $email ? $user : null;
});
$entityManager = $this->createMock(EntityManagerInterface::class);
$passwordHasher = $this->createMock(UserPasswordHasherInterface::class);
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$mailer = $this->createMock(Mailer::class);
$bpnConnectClient = $this->createMock(BpnConnectClient::class);
return $this->createManager($userRepository, $entityManager, $passwordHasher, $urlGenerator, $mailer, $bpnConnectClient);
}
private function buildActivationUrl(string $email, string $nonce, int $expires): string
{
$baseUrl = 'https://example.test/welcome/activate?'.http_build_query([
'email' => $email,
'nonce' => $nonce,
'expires' => $expires,
]);
return (new UriSigner('test-secret'))->sign($baseUrl);
}
private function createLookupResult(string $email, int $bookingNumber, string $firstName, string $lastName): BookingLookupResult
{
return new BookingLookupResult(
bookingNumber: $bookingNumber,
bookingId: 98765,
addressId: 12345,
personId: 67890,
email: $email,
firstName: $firstName,
lastName: $lastName,
);
}
}