wip: guest account registration (self-service and booking create flow)

This commit is contained in:
Björn Fromme committed 2026-06-23 16:27:09 +02:00
1 parent e4430ac5c5
commit d74888f64b
43 files changed
+2736 -144

No files matched your search

+57
View File
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Tests\BpnConnect;
use App\BpnConnect\BpnConnectClient;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
class BpnConnectClientTest extends TestCase
{
public function testLookupBookingMapsResponse(): void
{
$capturedMethod = null;
$capturedUrl = null;
$capturedOptions = null;
$httpClient = new MockHttpClient(static function (string $method, string $url, array $options) use (&$capturedMethod, &$capturedUrl, &$capturedOptions): MockResponse {
$capturedMethod = $method;
$capturedUrl = $url;
$capturedOptions = $options;
return new MockResponse(json_encode([
'bookingNumber' => 111386,
'bookingId' => 98609,
'addressId' => 225672,
'personId' => 412344,
'email' => '[email protected]',
'firstName' => 'Vorname 1',
'lastName' => 'Mustermann 1',
], JSON_THROW_ON_ERROR), [
'response_headers' => ['content-type' => 'application/json'],
]);
}, 'https://bpn-connect.test/');
$booking = (new BpnConnectClient($httpClient, new NullLogger()))->lookupBooking(' [email protected] ', 111386);
self::assertSame('GET', $capturedMethod);
self::assertSame('https://bpn-connect.test/api/bookings/[email protected]&bookingNumber=111386', $capturedUrl);
self::assertSame(111386, $capturedOptions['query']['bookingNumber']);
self::assertSame(98609, $booking?->bookingId);
self::assertSame(225672, $booking?->addressId);
self::assertSame(412344, $booking?->personId);
self::assertSame('[email protected]', $booking?->email);
self::assertSame('Vorname 1', $booking?->firstName);
self::assertSame('Mustermann 1', $booking?->lastName);
}
public function testLookupBookingReturnsNullForNotFound(): void
{
$httpClient = new MockHttpClient(new MockResponse('', ['http_code' => 404]), 'https://bpn-connect.test/');
self::assertNull((new BpnConnectClient($httpClient, new NullLogger()))->lookupBooking('[email protected]', 123));
}
}
@@ -17,6 +17,22 @@ class BookingResponseParserTest extends TestCase
$this->parser = new BookingResponseParser();
}
public function testParseSuccessfulBookingResponse(): void
{
$xmlContent = '<?xml version="1.0" encoding="utf-8"?>
<ergebnis>
<satz typ="BUCHUNG" />
<buchung>erfolgt</buchung>
<vorgang>321530</vorgang>
</ergebnis>';
$crawler = new Crawler($xmlContent);
$response = $this->parser->parse($crawler->filter('ergebnis'));
self::assertTrue($response->isBookingSuccessful());
self::assertSame(321530, $response->bookingNumber);
}
public function testParseBookingResponseWithPurchaseVoucher(): void
{
$xmlContent = '<?xml version="1.0" encoding="utf-8"?>
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Tests\Command;
use App\Command\CleanupGuestInviteAccountsCommand;
use App\Repository\UserRepository;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Tester\CommandTester;
class CleanupGuestInviteAccountsCommandTest extends TestCase
{
public function testCommandReportsWhenNothingWasDeleted(): void
{
$repository = $this->createMock(UserRepository::class);
$repository
->expects(self::once())
->method('deleteExpiredDormantGuestInvites')
->with(self::isInstanceOf(\DateTimeImmutable::class))
->willReturn(0);
$logger = $this->createMock(LoggerInterface::class);
$logger->expects(self::never())->method('info');
$tester = new CommandTester(new CleanupGuestInviteAccountsCommand($repository, $logger));
$exitCode = $tester->execute([]);
self::assertSame(0, $exitCode);
self::assertStringContainsString('No expired dormant guest accounts found.', $tester->getDisplay());
}
public function testCommandLogsAndReportsDeletionCount(): void
{
$repository = $this->createMock(UserRepository::class);
$repository
->expects(self::once())
->method('deleteExpiredDormantGuestInvites')
->with(self::isInstanceOf(\DateTimeImmutable::class))
->willReturn(3);
$logger = $this->createMock(LoggerInterface::class);
$logger
->expects(self::once())
->method('info')
->with(
'Deleted expired dormant guest accounts',
self::callback(static fn (array $context): bool => 3 === $context['count'])
);
$tester = new CommandTester(new CleanupGuestInviteAccountsCommand($repository, $logger));
$exitCode = $tester->execute([]);
self::assertSame(0, $exitCode);
self::assertStringContainsString('Deleted 3 expired dormant guest account(s).', $tester->getDisplay());
}
}
@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller\Account;
use App\Entity\User;
use App\Exception\GuestAccountActivationExpiredException;
use App\Service\BookingGuestAccountManager;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class GuestAccountActivationControllerTest extends WebTestCase
{
public function testExpiredActivationLinkShowsResendPageInsteadOf404(): void
{
$client = self::createClient([], ['HTTPS' => 'on']);
$client->disableReboot();
$guestAccountManager = $this->createMock(BookingGuestAccountManager::class);
$guestAccountManager
->expects(self::once())
->method('validateActivationRequest')
->with(self::isInstanceOf(Request::class))
->willThrowException(new GuestAccountActivationExpiredException());
$guestAccountManager->expects(self::never())->method('consumeActivation');
$guestAccountManager->expects(self::never())->method('validateActivationUrlForResend');
$guestAccountManager->expects(self::never())->method('resendWelcomeEmail');
$security = $this->createMock(Security::class);
$security->expects(self::never())->method('login');
self::getContainer()->set(BookingGuestAccountManager::class, $guestAccountManager);
self::getContainer()->set(Security::class, $security);
$client->request('GET', '/welcome/[email protected]&nonce=current-nonce&expires=9999999999&_hash=signature');
self::assertResponseIsSuccessful();
self::assertStringContainsString('Link', $client->getResponse()->getContent());
self::assertStringContainsString('Willkommens-E-Mail erneut senden', $client->getResponse()->getContent());
}
public function testResendActionResendsWelcomeMailAndRedirectsToLogin(): void
{
$client = self::createClient([], ['HTTPS' => 'on']);
$client->disableReboot();
$user = new User('[email protected]');
$user->setWelcomeActivationNonce('current-nonce');
$guestAccountManager = $this->createMock(BookingGuestAccountManager::class);
$guestAccountManager
->expects(self::once())
->method('validateActivationRequest')
->with(self::isInstanceOf(Request::class))
->willThrowException(new GuestAccountActivationExpiredException());
$guestAccountManager
->expects(self::once())
->method('validateActivationUrlForResend')
->with(self::callback(static function (string $activationUrl): bool {
return str_contains($activationUrl, 'email=customer%40example.com')
|| str_contains($activationUrl, '[email protected]');
}))
->willReturn($user);
$guestAccountManager
->expects(self::once())
->method('resendWelcomeEmail')
->with($user);
$security = $this->createMock(Security::class);
$security->expects(self::never())->method('login');
self::getContainer()->set(BookingGuestAccountManager::class, $guestAccountManager);
self::getContainer()->set(Security::class, $security);
$crawler = $client->request('GET', '/welcome/[email protected]&nonce=current-nonce&expires=9999999999&_hash=signature');
self::assertResponseIsSuccessful();
$form = $crawler->selectButton('Willkommens-E-Mail erneut senden')->form();
$client->submit($form);
self::assertResponseRedirects('/');
$client->followRedirect();
self::assertStringContainsString('Wir haben dir die Willkommens-E-Mail erneut gesendet.', $client->getResponse()->getContent());
}
public function testTamperedActivationLinkStillReturns404(): void
{
$client = self::createClient([], ['HTTPS' => 'on']);
$client->disableReboot();
$guestAccountManager = $this->createMock(BookingGuestAccountManager::class);
$guestAccountManager
->expects(self::once())
->method('validateActivationRequest')
->with(self::isInstanceOf(Request::class))
->willThrowException(new NotFoundHttpException());
$guestAccountManager->expects(self::never())->method('consumeActivation');
$security = $this->createMock(Security::class);
$security->expects(self::never())->method('login');
self::getContainer()->set(BookingGuestAccountManager::class, $guestAccountManager);
self::getContainer()->set(Security::class, $security);
$client->request('GET', '/welcome/[email protected]&nonce=current-nonce&expires=9999999999&_hash=invalid');
self::assertResponseStatusCodeSame(404);
}
}
@@ -256,6 +256,59 @@ class PersonalDataControllerTest extends TestCase
], $controller->flashes);
}
public function testIndexConsumesWelcomeActivationAfterForcedPasswordChangeSucceeds(): void
{
$personalData = $this->createCompletePersonalData();
$controller = $this->createController(true, true, $this->validator(), 'NewPassw0rd!');
$controller->user->markWelcomeActivationPending('current-nonce', new \DateTimeImmutable('+30 minutes'));
$controller->apiClient
->expects(self::once())
->method('getPersonalData')
->with('[email protected]', null, null)
->willReturn($personalData);
$controller->apiClient
->expects(self::once())
->method('updatePersonalData')
->with('[email protected]', $personalData)
->willReturn($personalData);
$controller->newsletterManager
->expects(self::once())
->method('hasConfirmedOptIn')
->with('[email protected]')
->willReturn(false);
$controller->newsletterManager
->expects(self::once())
->method('hasPendingConfirmation')
->with('[email protected]')
->willReturn(false);
$controller->dataLoader
->expects(self::once())
->method('invalidateUserBookingCaches')
->with($controller->user);
$controller->entityManager
->expects(self::once())
->method('flush');
$controller->passwordHasher
->expects(self::once())
->method('hashPassword')
->with($controller->user, 'NewPassw0rd!')
->willReturn('new-local-hash');
$request = Request::create('/personal-data', 'POST');
$request->setSession(new Session(new MockArraySessionStorage()));
$request->getSession()->set(PersonalDataController::SESSION_FORCE_PASSWORD_CHANGE_KEY, true);
$response = $controller->index($request);
self::assertSame(302, $response->getStatusCode());
self::assertSame('new-local-hash', $controller->user->getPassword());
self::assertNull($controller->user->getWelcomeActivationNonce());
self::assertNull($controller->user->getWelcomeActivationExpiresAt());
self::assertNotNull($controller->user->getWelcomeActivatedAt());
self::assertFalse($request->getSession()->has(PersonalDataController::SESSION_FORCE_PASSWORD_CHANGE_KEY));
}
public function testIndexDoesNotPersistInvalidFormSubmission(): void
{
$controller = $this->createController(true, false, $this->validator(), 'NewPassw0rd!');
@@ -0,0 +1,151 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller\Account;
use App\Service\BookingGuestAccountManager;
use App\Service\PublicFormRateLimiter;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Request;
class PortalAccessRequestControllerTest extends WebTestCase
{
public function testPortalAccessRequestRedirectsToLoginAfterSendingActivationMail(): void
{
$client = self::createClient([], ['HTTPS' => 'on']);
$client->disableReboot();
$rateLimiter = $this->createMock(PublicFormRateLimiter::class);
$rateLimiter
->expects(self::once())
->method('isPortalAccessAllowed')
->with('[email protected]', self::isInstanceOf(Request::class))
->willReturn(true);
$guestAccountManager = $this->createMock(BookingGuestAccountManager::class);
$guestAccountManager
->expects(self::once())
->method('requestPortalAccess')
->with('[email protected]', 123)
->willReturn(BookingGuestAccountManager::PORTAL_ACCESS_SENT);
self::getContainer()->set(PublicFormRateLimiter::class, $rateLimiter);
self::getContainer()->set(BookingGuestAccountManager::class, $guestAccountManager);
$crawler = $client->request('GET', '/');
$token = $crawler->filter('input[name="_token"]')->attr('value');
$client->request('POST', '/portal-access/request', [
'email' => '[email protected]',
'bookingNumber' => '123',
'_token' => $token,
]);
self::assertResponseRedirects('/');
$client->followRedirect();
self::assertStringContainsString('Wenn zu deiner Buchung ein Portalzugang möglich ist, haben wir dir eine E-Mail gesendet.', $client->getResponse()->getContent());
}
public function testPortalAccessRequestRejectsMismatchedBookingGenerically(): void
{
$client = self::createClient([], ['HTTPS' => 'on']);
$client->disableReboot();
$rateLimiter = $this->createMock(PublicFormRateLimiter::class);
$rateLimiter
->expects(self::once())
->method('isPortalAccessAllowed')
->with('[email protected]', self::isInstanceOf(Request::class))
->willReturn(true);
$guestAccountManager = $this->createMock(BookingGuestAccountManager::class);
$guestAccountManager
->expects(self::once())
->method('requestPortalAccess')
->with('[email protected]', 123)
->willReturn(BookingGuestAccountManager::PORTAL_ACCESS_NOT_SENT);
self::getContainer()->set(PublicFormRateLimiter::class, $rateLimiter);
self::getContainer()->set(BookingGuestAccountManager::class, $guestAccountManager);
$crawler = $client->request('GET', '/');
$token = $crawler->filter('input[name="_token"]')->attr('value');
$client->request('POST', '/portal-access/request', [
'email' => '[email protected]',
'bookingNumber' => '123',
'_token' => $token,
]);
self::assertResponseRedirects('/');
$client->followRedirect();
self::assertStringContainsString('Wenn zu deiner Buchung ein Portalzugang möglich ist, haben wir dir eine E-Mail gesendet.', $client->getResponse()->getContent());
}
public function testPortalAccessRequestShowsLoginHintForAlreadyActiveAccount(): void
{
$client = self::createClient([], ['HTTPS' => 'on']);
$client->disableReboot();
$rateLimiter = $this->createMock(PublicFormRateLimiter::class);
$rateLimiter
->expects(self::once())
->method('isPortalAccessAllowed')
->with('[email protected]', self::isInstanceOf(Request::class))
->willReturn(true);
$guestAccountManager = $this->createMock(BookingGuestAccountManager::class);
$guestAccountManager
->expects(self::once())
->method('requestPortalAccess')
->with('[email protected]', 123)
->willReturn(BookingGuestAccountManager::PORTAL_ACCESS_ALREADY_ACTIVE);
self::getContainer()->set(PublicFormRateLimiter::class, $rateLimiter);
self::getContainer()->set(BookingGuestAccountManager::class, $guestAccountManager);
$crawler = $client->request('GET', '/');
$token = $crawler->filter('input[name="_token"]')->attr('value');
$client->request('POST', '/portal-access/request', [
'email' => '[email protected]',
'bookingNumber' => '123',
'_token' => $token,
]);
self::assertResponseRedirects('/');
$client->followRedirect();
self::assertStringContainsString('Für diese E-Mail-Adresse besteht bereits ein aktiver Portalzugang.', $client->getResponse()->getContent());
}
public function testPortalAccessRequestReturns429WhenThrottled(): void
{
$client = self::createClient([], ['HTTPS' => 'on']);
$client->disableReboot();
$rateLimiter = $this->createMock(PublicFormRateLimiter::class);
$rateLimiter
->expects(self::once())
->method('isPortalAccessAllowed')
->with('[email protected]', self::isInstanceOf(Request::class))
->willReturn(false);
$guestAccountManager = $this->createMock(BookingGuestAccountManager::class);
$guestAccountManager->expects(self::never())->method('requestPortalAccess');
self::getContainer()->set(PublicFormRateLimiter::class, $rateLimiter);
self::getContainer()->set(BookingGuestAccountManager::class, $guestAccountManager);
$crawler = $client->request('GET', '/');
$token = $crawler->filter('input[name="_token"]')->attr('value');
$client->request('POST', '/portal-access/request', [
'email' => '[email protected]',
'bookingNumber' => '123',
'_token' => $token,
]);
self::assertResponseStatusCodeSame(429);
self::assertStringContainsString(PublicFormRateLimiter::THROTTLED_MESSAGE, $client->getResponse()->getContent());
}
}
@@ -0,0 +1,293 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller\Booking\Create;
use App\BusProNet\ApiClient;
use App\BusProNet\Constants;
use App\BusProNet\Model\BookingResponse;
use App\BusProNet\Model\Hotel;
use App\BusProNet\Model\Travel;
use App\Entity\User;
use App\Form\Model\BookingCreateContext;
use App\Form\Model\BookingDto;
use App\Form\Model\BookingSummaryDto;
use App\Form\Model\BookingSummaryPricingDto;
use App\Form\Model\BookingSummaryVoucherDto;
use App\Form\Model\ParticipantDto;
use App\Form\Model\RoomGroupsDto;
use App\Repository\UserRepository;
use App\Service\BookingConfigurator;
use App\Service\BookingCreateContextFactory;
use App\Service\BookingGuestAccountManager;
use App\Service\BookingSessionManager;
use App\Service\NewsletterManager;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Bundle\SecurityBundle\Security;
class Step4ControllerTest extends WebTestCase
{
protected function tearDown(): void
{
self::ensureKernelShutdown();
parent::tearDown();
}
public function testStep4RendersCheckboxAndProvisionsGuestAccountAfterSuccessfulBooking(): void
{
$client = self::createClient([], ['HTTPS' => 'on']);
$client->disableReboot();
$bookingDto = $this->createBookingDto();
$context = $this->createBookingContext($bookingDto);
$bookingSessionManager = $this->createMock(BookingSessionManager::class);
$bookingSessionManager
->expects(self::exactly(2))
->method('getOrCreateBookingCreateDto')
->willReturn($bookingDto);
$contextFactory = $this->createMock(BookingCreateContextFactory::class);
$contextFactory
->expects(self::exactly(2))
->method('createWithParticipantPrices')
->with($bookingDto)
->willReturn($context);
$userRepository = $this->createMock(UserRepository::class);
$userRepository
->expects(self::exactly(2))
->method('findOneByNormalizedEmail')
->with('[email protected]')
->willReturn(null);
$guestAccountManager = $this->createMock(BookingGuestAccountManager::class);
$guestAccountManager
->expects(self::once())
->method('provisionGuestAccountForBooking')
->with('[email protected]', 123, 'Alice', 'Example')
->willReturn(true);
$bookingConfigurator = $this->createMock(BookingConfigurator::class);
$bookingConfigurator
->expects(self::once())
->method('applyCreateBookingStatusRules');
$apiClient = $this->createMock(ApiClient::class);
$apiClient
->expects(self::once())
->method('createBooking')
->with($bookingDto)
->willReturn(new BookingResponse(BookingResponse::BOOKING_PROCESSED, 123));
$newsletterManager = $this->createMock(NewsletterManager::class);
$newsletterManager->expects(self::never())->method('requestConfirmation');
self::getContainer()->set(BookingSessionManager::class, $bookingSessionManager);
self::getContainer()->set(BookingCreateContextFactory::class, $contextFactory);
self::getContainer()->set(UserRepository::class, $userRepository);
self::getContainer()->set(BookingGuestAccountManager::class, $guestAccountManager);
self::getContainer()->set(BookingConfigurator::class, $bookingConfigurator);
self::getContainer()->set(ApiClient::class, $apiClient);
self::getContainer()->set(NewsletterManager::class, $newsletterManager);
$crawler = $client->request('GET', '/bookings/create/confirmation');
self::assertResponseIsSuccessful();
self::assertStringContainsString('Benutzeraccount in MyE&P anlegen', $client->getResponse()->getContent());
$form = $crawler->selectButton('Verbindlich buchen')->form([
'booking_create_step4[confirmationAccepted]' => '1',
'booking_create_step4[termsAccepted]' => '1',
'booking_create_step4[guestAccountOptIn]' => '1',
]);
$client->submit($form);
self::assertResponseRedirects('/bookings/create/success');
}
public function testStep4HidesGuestAccountCheckboxForAuthenticatedUsers(): void
{
$bookingDto = $this->createBookingDto();
$userRepository = $this->createMock(UserRepository::class);
$userRepository->expects(self::never())->method('findOneByNormalizedEmail');
$controller = $this->createAuthenticatedController(
user: new User('[email protected]'),
userRepository: $userRepository,
);
self::assertNull($controller->exposeGuestAccountTargetEmail($bookingDto));
}
public function testStep4HidesGuestAccountCheckboxWhenLocalUserAlreadyExists(): void
{
$client = self::createClient([], ['HTTPS' => 'on']);
$client->disableReboot();
$bookingDto = $this->createBookingDto();
$context = $this->createBookingContext($bookingDto);
$bookingSessionManager = $this->createMock(BookingSessionManager::class);
$bookingSessionManager
->expects(self::once())
->method('getOrCreateBookingCreateDto')
->willReturn($bookingDto);
$contextFactory = $this->createMock(BookingCreateContextFactory::class);
$contextFactory
->expects(self::once())
->method('createWithParticipantPrices')
->with($bookingDto)
->willReturn($context);
$userRepository = $this->createMock(UserRepository::class);
$userRepository
->expects(self::once())
->method('findOneByNormalizedEmail')
->with('[email protected]')
->willReturn(new User('[email protected]'));
$guestAccountManager = $this->createMock(BookingGuestAccountManager::class);
$guestAccountManager->expects(self::never())->method('provisionGuestAccount');
self::getContainer()->set(BookingSessionManager::class, $bookingSessionManager);
self::getContainer()->set(BookingCreateContextFactory::class, $contextFactory);
self::getContainer()->set(UserRepository::class, $userRepository);
self::getContainer()->set(BookingGuestAccountManager::class, $guestAccountManager);
$client->request('GET', '/bookings/create/confirmation');
self::assertResponseIsSuccessful();
self::assertStringNotContainsString('Benutzeraccount in MyE&P anlegen', $client->getResponse()->getContent());
}
public function testGuestAccountActivationRedirectsToPersonalDataAndForcesPasswordChange(): void
{
$client = self::createClient([], ['HTTPS' => 'on']);
$client->disableReboot();
$guestAccountManager = $this->createMock(BookingGuestAccountManager::class);
$guestAccountManager
->expects(self::once())
->method('validateActivationRequest')
->with(self::isInstanceOf(\Symfony\Component\HttpFoundation\Request::class))
->willReturn(new User('[email protected]'));
$guestAccountManager
->expects(self::never())
->method('consumeActivation');
$security = $this->createMock(Security::class);
$security
->expects(self::once())
->method('login')
->with(self::isInstanceOf(User::class), null, 'main')
->willReturn(null);
self::getContainer()->set(BookingGuestAccountManager::class, $guestAccountManager);
self::getContainer()->set(Security::class, $security);
$client->request('GET', '/welcome/[email protected]&nonce=current-nonce&expires=9999999999&_hash=signature');
self::assertResponseRedirects('/personal-data');
self::assertTrue($client->getRequest()->getSession()->get(\App\Controller\Account\PersonalDataController::SESSION_FORCE_PASSWORD_CHANGE_KEY));
}
private function createBookingDto(): BookingDto
{
$travel = new Travel();
$travel->id = 123;
$travel->label = 'Winterreise';
$travel->productCode = 'PROD-1';
$travel->dateFrom = new \DateTimeImmutable('2026-01-01');
$travel->dateTo = new \DateTimeImmutable('2026-01-08');
$hotel = new Hotel();
$hotel->name = 'Hotel Test';
$travel->hotel = $hotel;
$bookingDto = new BookingDto($travel, 77);
$bookingDto->currentStep = 4;
$bookingDto->bookingStatus = 'F';
$bookingDto->paymentMethod = Constants::PAYMENT_METHOD_TRANSFER;
$participant = new ParticipantDto();
$participant->index = 0;
$participant->firstName = 'Alice';
$participant->lastName = 'Example';
$participant->email = '[email protected]';
$participant->gender = 'M';
$participant->nationality = 'DE';
$participant->dateOfBirth = new \DateTimeImmutable('1990-01-01');
$bookingDto->participants = [$participant];
return $bookingDto;
}
private function createBookingContext(BookingDto $bookingDto): BookingCreateContext
{
$pricing = new BookingSummaryPricingDto([], [], null, [], 0.0);
$vouchers = new BookingSummaryVoucherDto(null, 0.0);
$summary = new BookingSummaryDto([], 1, $pricing, $vouchers, null);
return new BookingCreateContext(
bookingDto: $bookingDto,
summaryData: $summary,
groupedRooms: new RoomGroupsDto([], []),
participantPrices: [0.0],
);
}
private function createAuthenticatedController(User $user, UserRepository $userRepository): TestableStep4Controller
{
return new TestableStep4Controller(
$this->createMock(BookingConfigurator::class),
$this->createMock(BookingSessionManager::class),
$this->createMock(BookingCreateContextFactory::class),
$this->createMock(BookingGuestAccountManager::class),
$this->createMock(ApiClient::class),
$this->createMock(\Symfony\Contracts\Cache\CacheInterface::class),
$this->createMock(NewsletterManager::class),
$userRepository,
$this->createMock(\Psr\Log\LoggerInterface::class),
$user,
);
}
}
final class TestableStep4Controller extends \App\Controller\Booking\Create\Step4Controller
{
public function __construct(
BookingConfigurator $bookingService,
BookingSessionManager $bookingSessionService,
BookingCreateContextFactory $createContextFactory,
BookingGuestAccountManager $guestAccountManager,
ApiClient $apiClient,
\Symfony\Contracts\Cache\CacheInterface $cache,
NewsletterManager $doubleOptInService,
UserRepository $userRepository,
\Psr\Log\LoggerInterface $logger,
private readonly User $user,
) {
parent::__construct(
$bookingService,
$bookingSessionService,
$createContextFactory,
$guestAccountManager,
$apiClient,
$cache,
$doubleOptInService,
$userRepository,
$logger,
);
}
public function exposeGuestAccountTargetEmail(BookingDto $bookingDto): ?string
{
return $this->resolveGuestAccountTargetEmail($bookingDto);
}
protected function getUser(): \Symfony\Component\Security\Core\User\UserInterface|null
{
return $this->user;
}
}
@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace App\Tests\EventListener;
use App\Controller\Account\PersonalDataController;
use App\Entity\User;
use App\EventListener\ForcePasswordChangeRedirectListener;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\RedirectResponse;
use App\Htmx\HxRedirectResponse;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
class ForcePasswordChangeRedirectListenerTest extends TestCase
{
private HttpKernelInterface $kernel;
protected function setUp(): void
{
$this->kernel = $this->createMock(HttpKernelInterface::class);
}
public function testRedirectsAwayFromNonPersonalDataRoutesWhenPasswordChangeIsForced(): void
{
$listener = $this->createListener();
$request = $this->createRequest('/account', 'app_account');
$event = new RequestEvent($this->kernel, $request, HttpKernelInterface::MAIN_REQUEST);
$listener->onKernelRequest($event);
self::assertInstanceOf(RedirectResponse::class, $event->getResponse());
self::assertSame('/personal-data', $event->getResponse()->headers->get('Location'));
}
public function testAllowsPersonalDataRouteWhilePasswordChangeIsForced(): void
{
$listener = $this->createListener();
$request = $this->createRequest('/personal-data', 'app_personal_data');
$event = new RequestEvent($this->kernel, $request, HttpKernelInterface::MAIN_REQUEST);
$listener->onKernelRequest($event);
self::assertNull($event->getResponse());
}
public function testNewsletterSubRouteIsNoLongerExempt(): void
{
$listener = $this->createListener();
$request = $this->createRequest('/personal-data/newsletter', 'app_personal_data_newsletter', ['HX-Request' => 'true']);
$event = new RequestEvent($this->kernel, $request, HttpKernelInterface::MAIN_REQUEST);
$listener->onKernelRequest($event);
self::assertInstanceOf(HxRedirectResponse::class, $event->getResponse());
self::assertSame('/personal-data', $event->getResponse()->headers->get('HX-Redirect'));
}
public function testLogoutRouteRemainsReachable(): void
{
$listener = $this->createListener();
$request = $this->createRequest('/logout', 'app_logout');
$event = new RequestEvent($this->kernel, $request, HttpKernelInterface::MAIN_REQUEST);
$listener->onKernelRequest($event);
self::assertNull($event->getResponse());
}
/**
* @param array<string, string> $headers
*/
private function createRequest(string $uri, string $route, array $headers = []): Request
{
$server = [];
foreach ($headers as $header => $value) {
$server['HTTP_'.strtoupper(str_replace('-', '_', $header))] = $value;
}
$request = Request::create($uri, 'GET', [], [], [], $server);
$request->setSession(new Session(new MockArraySessionStorage()));
$request->getSession()->set(PersonalDataController::SESSION_FORCE_PASSWORD_CHANGE_KEY, true);
$request->attributes->set('_route', $route);
return $request;
}
private function createListener(): ForcePasswordChangeRedirectListener
{
$security = $this->createMock(Security::class);
$security
->method('getUser')
->willReturn(new User('[email protected]'));
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$urlGenerator
->method('generate')
->with('app_personal_data')
->willReturn('/personal-data');
return new ForcePasswordChangeRedirectListener($security, $urlGenerator);
}
}
+71
View File
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Tests\Repository;
use App\Repository\UserRepository;
use Doctrine\ORM\Query;
use Doctrine\ORM\QueryBuilder;
use PHPUnit\Framework\TestCase;
class UserRepositoryTest extends TestCase
{
public function testDeleteExpiredDormantGuestInvitesBuildsExpectedQuery(): void
{
$query = $this->createMock(Query::class);
$query
->expects(self::once())
->method('execute')
->willReturn(2);
$threshold = new \DateTimeImmutable('2026-05-29 01:00:00');
$queryBuilder = $this->createMock(QueryBuilder::class);
$andWhereCalls = 0;
$queryBuilder
->expects(self::once())
->method('delete')
->willReturnSelf();
$queryBuilder
->expects(self::once())
->method('where')
->with('u.welcomeActivatedAt IS NULL')
->willReturnSelf();
$queryBuilder
->expects(self::exactly(2))
->method('andWhere')
->willReturnCallback(function (string $condition) use (&$andWhereCalls, $queryBuilder): QueryBuilder {
$expectedConditions = [
'u.welcomeActivationExpiresAt IS NOT NULL',
'u.welcomeActivationExpiresAt <= :threshold',
];
self::assertSame($expectedConditions[$andWhereCalls], $condition);
++$andWhereCalls;
return $queryBuilder;
});
$queryBuilder
->expects(self::once())
->method('setParameter')
->with('threshold', $threshold)
->willReturnSelf();
$queryBuilder
->expects(self::once())
->method('getQuery')
->willReturn($query);
$repository = new class($queryBuilder) extends UserRepository {
public function __construct(private readonly QueryBuilder $queryBuilder)
{
}
public function createQueryBuilder($alias, $indexBy = null): QueryBuilder
{
return $this->queryBuilder;
}
};
self::assertSame(2, $repository->deleteExpiredDormantGuestInvites($threshold));
}
}
@@ -0,0 +1,623 @@
<?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,
);
}
}
@@ -173,6 +173,35 @@ class PasswordResetManagerTest extends TestCase
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]');
@@ -28,6 +28,11 @@ class TestPublicFormRateLimiter extends PublicFormRateLimiter
return $this->consume($email, $request);
}
public function isPortalAccessAllowed(string $email, Request $request): bool
{
return $this->consume($email, $request);
}
public function createBucketKey(string $email, ?string $clientIp): string
{
return hash('sha256', mb_strtolower(trim($email)).'|'.($clientIp ?? 'unknown'));