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

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