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

+2
View File
@@ -56,6 +56,8 @@ APP_BPN_DEBUG=false
APP_BPN_CONNECTION_TIMEOUT=5
APP_BPN_STREAM_TIMEOUT=30
APP_BPN_TOTAL_TIMEOUT=45
APP_BPN_CONNECT_BASE_URL="http://bpn-connect"
APP_BPN_CONNECT_API_KEY=
APP_TRAVEL_INFO_BASE_URL="https://www.ep-reisen.de/reiseinformationen/"
APP_LEGAL_NOTICE_URL="https://www.ep-reisen.de/unternehmen/impressum-skireiseveranstalter/"
+2
View File
@@ -5,3 +5,5 @@ SYMFONY_DEPRECATIONS_HELPER=999999
PANTHER_APP_ENV=panther
PANTHER_ERROR_SCREENSHOT_DIR=./var/error-screenshots
MAILJET_WEBHOOK_BASIC_PASSWORD=secret
APP_BPN_CONNECT_BASE_URL="http://bpn-connect.test"
APP_BPN_CONNECT_API_KEY=test
+9
View File
@@ -14,6 +14,10 @@ framework:
policy: sliding_window
limit: 5
interval: 15 minutes
portal_access_request:
policy: sliding_window
limit: 5
interval: 15 minutes
# Enables session support. Note that the session will ONLY be started if you read or write from it.
# Remove or comment this section to explicitly disable session support.
@@ -34,6 +38,11 @@ framework:
base_uri: '%env(resolve:APP_CMS_API_BASE_URL)%'
headers:
accept: 'text/json'
bpn_connect.client:
base_uri: '%env(resolve:APP_BPN_CONNECT_BASE_URL)%'
headers:
accept: 'application/json'
X-API-Key: '%env(APP_BPN_CONNECT_API_KEY)%'
when@test:
framework:
+4
View File
@@ -28,6 +28,10 @@ zenstruck_schedule:
frequency: "45 1 * * *"
description: "Removes expired pending newsletter double opt-in requests"
- task: app:cleanup:guest-invite-accounts
frequency: "50 1 * * *"
description: "Removes expired dormant guest accounts"
- task: league:oauth2-server:clear-expired-tokens
frequency: "0 * * * *"
description: "Removes expired oauth2 tokens hourly"
+5
View File
@@ -100,6 +100,10 @@ services:
stream_timeout: '%env(int:APP_BPN_STREAM_TIMEOUT)%'
total_timeout: '%env(int:APP_BPN_TOTAL_TIMEOUT)%'
App\BpnConnect\BpnConnectClient:
arguments:
$httpClient: '@bpn_connect.client'
App\Twig\AppRuntime:
arguments:
$intlExtension: '@twig.extension.intl'
@@ -140,6 +144,7 @@ services:
arguments:
$registrationLimiter: '@limiter.registration'
$passwordResetLimiter: '@limiter.password_reset'
$portalAccessRequestLimiter: '@limiter.portal_access_request'
App\Service\TravelDataProvider:
arguments:
-120
View File
@@ -1,120 +0,0 @@
# Body Dimensions Transition Plan (Stakeholder Draft)
## Goal
Prevent edit-form failures for existing bookings while we transition from legacy body-dimension values to a better long-term field model.
This plan introduces a temporary compatibility phase and keeps sunsetting manual and straightforward.
## Current Problem
- Existing bookings may contain legacy values such as `-148`, `149-157`, or `195+`.
- Integer-only form fields can fail when these legacy strings are loaded.
- Result: participant edit forms can crash before submit.
## Proposed Transition Strategy
Use **text input fields** for body dimensions during transition, with explicit server-side format validation.
### Key Decisions
- No gating by booking create date.
- One unified behavior for all bookings.
- Accept both legacy and numeric formats during transition.
- Keep manual sunsetting later (no automated migration switch).
## Functional Scope
Affected fields:
- `height`
- `weight`
- `shoeSize`
Affected components:
- `src/Form/BodyDimensionsType.php`
- `src/Validator/Constraints/ParticipantValidator.php`
- `src/Form/BookingParticipantType.php` (option passthrough already in place)
- `config/services.yaml` (ranges remain as validation/config source)
## Validation Rules During Transition
### Accepted formats
- Numeric value: `^\d+$` (e.g. `176`)
- Legacy lower/open/range tokens:
- `^-\d+$` (e.g. `-148`)
- `^\d+\+$` (e.g. `195+`)
- `^\d+\s*-\s*\d+$` (e.g. `149-157`)
### Requiredness
- Keep existing rule: body dimensions required when rentals are selected.
### Range checks
- Keep current numeric range checks based on `body_dimension_ranges`.
- Apply range checks only to plain numeric values in transition phase.
## UX/Behavior Expectations
- Existing legacy values render without form initialization errors.
- New numeric entries are accepted and validated.
- Invalid free text (e.g. `abc`, `17x`) shows clear validation errors.
- HTMX refresh behavior remains unchanged.
## Implementation Steps
1. **Form type change**
- In `BodyDimensionsType`, replace integer fields with text fields for all three body dimensions.
- Keep labels/help text; keep placeholders based on configured ranges.
2. **Validator extension**
- Add format validation for all three fields in `ParticipantValidator`.
- Keep existing required-when-rentals logic.
- Keep existing range validation for numeric values.
3. **Message tuning**
- Add one clear message for invalid format.
- Retain existing range message for numeric out-of-range values.
4. **Testing and QA**
- Validate old bookings with legacy values open/edit successfully.
- Validate numeric path (valid and invalid range).
- Validate invalid format handling.
- Validate rental-required behavior.
## Acceptance Criteria
- No 500 error when editing participants with legacy body-dimension values.
- Legacy values can be loaded and submitted in transition phase.
- Numeric values are accepted and range-validated.
- Invalid text is rejected with a user-facing validation message.
- Existing participant edit/refresh flows continue to work.
## Risks and Mitigations
- **Risk:** Free-text field semantics are less strict than integer fields.
- **Mitigation:** strict server-side regex + range checks.
- **Risk:** Inconsistent data representations during transition.
- **Mitigation:** explicit acceptance policy and manual sunset plan.
## Effort Estimate
- Implementation: 0.5 day
- Validation/message tuning: 0.25 day
- QA/manual testing: 0.5 day
- **Total:** ~1 to 1.5 days
## Manual Sunset Plan (Later)
When stakeholders approve end of transition:
1. Replace text fields with final semantic field type(s).
2. Remove legacy format acceptance from validator.
3. Keep only final numeric/range validation behavior.
4. Remove transition-specific tests and copy.
Expected cleanup effort: ~0.25 to 0.5 day.
+26
View File
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260529120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add welcome activation nonce to users';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE user ADD welcome_activation_nonce VARCHAR(64) DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE user DROP welcome_activation_nonce');
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260529130000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add guest invite expiry and activation timestamps to users';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE user ADD welcome_activation_expires_at DATETIME DEFAULT NULL');
$this->addSql('ALTER TABLE user ADD welcome_activated_at DATETIME DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE user DROP welcome_activated_at');
$this->addSql('ALTER TABLE user DROP welcome_activation_expires_at');
}
}
+83
View File
@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace App\BpnConnect;
use App\BpnConnect\Exception\BpnConnectException;
use App\BpnConnect\Model\BookingLookupResult;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class BpnConnectClient
{
public function __construct(
private readonly HttpClientInterface $httpClient,
private readonly LoggerInterface $logger,
) {
}
public function lookupBooking(string $email, int $bookingNumber): ?BookingLookupResult
{
$normalizedEmail = mb_strtolower(trim($email));
try {
$response = $this->httpClient->request('GET', 'api/bookings/lookup', [
'query' => [
'email' => $normalizedEmail,
'bookingNumber' => $bookingNumber,
],
]);
if (404 === $response->getStatusCode()) {
return null;
}
$data = $response->toArray();
} catch (ExceptionInterface $exception) {
$this->logger->warning('BPN Connect booking lookup failed', [
'email' => $normalizedEmail,
'booking_number' => $bookingNumber,
'error' => $exception->getMessage(),
]);
throw new BpnConnectException('Could not lookup booking via BPN Connect.', previous: $exception);
}
return $this->mapBookingLookupResult($data);
}
/**
* @param array<string, mixed> $data
*/
private function mapBookingLookupResult(array $data): BookingLookupResult
{
foreach (['bookingNumber', 'bookingId', 'addressId', 'personId', 'email'] as $requiredField) {
if (false === array_key_exists($requiredField, $data) || '' === trim((string) $data[$requiredField])) {
throw new BpnConnectException(sprintf('BPN Connect booking lookup response is missing "%s".', $requiredField));
}
}
return new BookingLookupResult(
bookingNumber: (int) $data['bookingNumber'],
bookingId: (int) $data['bookingId'],
addressId: (int) $data['addressId'],
personId: (int) $data['personId'],
email: mb_strtolower(trim((string) $data['email'])),
firstName: $this->optionalString($data['firstName'] ?? null),
lastName: $this->optionalString($data['lastName'] ?? null),
);
}
private function optionalString(mixed $value): ?string
{
if (null === $value) {
return null;
}
$value = trim((string) $value);
return '' === $value ? null : $value;
}
}
@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\BpnConnect\Exception;
class BpnConnectException extends \RuntimeException
{
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\BpnConnect\Model;
class BookingLookupResult
{
public function __construct(
public readonly int $bookingNumber,
public readonly int $bookingId,
public readonly int $addressId,
public readonly int $personId,
public readonly string $email,
public readonly ?string $firstName = null,
public readonly ?string $lastName = null,
) {
}
}
+5
View File
@@ -768,6 +768,11 @@ class ApiClient
try {
$this->xmlDump->write($requestId.'_'.$type.'.xml', $body);
} catch (FilesystemException $e) {
$this->logger->warning('Could not write BPN XML dump', [
'request_id' => $requestId,
'type' => $type,
'error' => $e->getMessage(),
]);
}
}
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Repository\UserRepository;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:cleanup:guest-invite-accounts',
description: 'Removes expired unactivated guest accounts',
)]
class CleanupGuestInviteAccountsCommand extends Command
{
public function __construct(
private readonly UserRepository $userRepository,
private readonly LoggerInterface $logger,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$threshold = new \DateTimeImmutable();
$deletedCount = $this->userRepository->deleteExpiredDormantGuestInvites($threshold);
if (0 === $deletedCount) {
$io->success('No expired dormant guest accounts found.');
return Command::SUCCESS;
}
$this->logger->info('Deleted expired dormant guest accounts', [
'count' => $deletedCount,
'threshold' => $threshold->format(\DateTimeInterface::ATOM),
]);
$io->success(sprintf('Deleted %d expired dormant guest account(s).', $deletedCount));
return Command::SUCCESS;
}
}
@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\Controller\Account;
use App\Entity\User;
use App\Exception\GuestAccountActivationExpiredException;
use App\Service\BookingGuestAccountManager;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class GuestAccountActivationController extends AbstractController
{
public function __construct(
private readonly BookingGuestAccountManager $guestAccountManager,
private readonly Security $security,
) {
}
#[Route('/welcome/activate', name: 'app_guest_account_activate', methods: ['GET'])]
#[IsGranted('PUBLIC_ACCESS')]
public function activate(Request $request): Response
{
try {
$user = $this->guestAccountManager->validateActivationRequest($request);
} catch (GuestAccountActivationExpiredException) {
return $this->render('account/guest_account_activation_expired.html.twig', [
'resendForm' => $this->createResendForm($request->getUri()),
]);
}
$session = $request->getSession();
$session->set('_security.main.target_path', $this->generateUrl('app_personal_data'));
/** @var User $user */
$this->security->login($user, null, 'main');
$session->set(PersonalDataController::SESSION_FORCE_PASSWORD_CHANGE_KEY, true);
return $this->redirectToRoute('app_personal_data');
}
#[Route('/welcome/activate/resend', name: 'app_guest_account_activate_resend', methods: ['POST'])]
#[IsGranted('PUBLIC_ACCESS')]
public function resend(Request $request): Response
{
$form = $this->createResendForm();
$form->handleRequest($request);
if (false === $form->isSubmitted() || false === $form->isValid()) {
return $this->render('account/guest_account_activation_expired.html.twig', [
'resendForm' => $form,
]);
}
$activationUrl = (string) $form->get('activationUrl')->getData();
$user = $this->guestAccountManager->validateActivationUrlForResend($activationUrl);
try {
$this->guestAccountManager->resendWelcomeEmail($user);
} catch (\Throwable) {
$this->addFlash('error', 'Die Willkommens-E-Mail konnte gerade nicht erneut gesendet werden. Bitte versuche es später noch einmal.');
return $this->render('account/guest_account_activation_expired.html.twig', [
'resendForm' => $form,
]);
}
$this->addFlash('success', 'Wir haben dir die Willkommens-E-Mail erneut gesendet.');
return $this->redirectToRoute('app_login');
}
private function createResendForm(string $activationUrl = ''): \Symfony\Component\Form\FormInterface
{
return $this->createFormBuilder(null, [
'action' => $this->generateUrl('app_guest_account_activate_resend'),
])->add('activationUrl', HiddenType::class, [
'data' => $activationUrl,
])->getForm();
}
}
@@ -37,6 +37,7 @@ class PersonalDataController extends AbstractController
use HxTrait;
public const string SESSION_REDIRECT_KEY = '_profile_completion_redirect';
public const string SESSION_FORCE_PASSWORD_CHANGE_KEY = '_force_password_change';
public function __construct(
private readonly ApiClient $apiClient,
@@ -68,6 +69,8 @@ class PersonalDataController extends AbstractController
{
/** @var User $user */
$user = $this->getUser();
$forcePasswordChange = $request->hasSession()
&& true === $request->getSession()->get(self::SESSION_FORCE_PASSWORD_CHANGE_KEY, false);
$email = $user->getEmail();
$personalData = $this->loadPersonalData($user);
$newsletterSubscribed = $this->newsletterManager->hasConfirmedOptIn($email);
@@ -75,6 +78,8 @@ class PersonalDataController extends AbstractController
$personalDataForm = $this->createForm(AccountPersonalDataType::class, $personalData, [
'attr' => ['novalidate' => 'novalidate'],
'require_current_password' => false === $forcePasswordChange,
'force_password_change' => $forcePasswordChange,
]);
$personalDataForm->handleRequest($request);
@@ -86,6 +91,9 @@ class PersonalDataController extends AbstractController
if ('' !== $newPassword) {
$user->setPassword($this->passwordHasher->hashPassword($user, $newPassword));
}
if ('' !== $newPassword && true === $forcePasswordChange) {
$user->markWelcomeActivated();
}
// Invalidate cached bookings to ensure edit mode shows updated applicant data
$this->dataLoader->invalidateUserBookingCaches($user);
@@ -95,17 +103,21 @@ class PersonalDataController extends AbstractController
$user->setProfileComplete($isComplete);
$this->entityManager->flush();
if ('' !== $newPassword && true === $forcePasswordChange && true === $request->hasSession()) {
$request->getSession()->remove(self::SESSION_FORCE_PASSWORD_CHANGE_KEY);
}
$this->addFlash('success', 'Deine persönlichen Daten wurden aktualisiert');
$this->logger->info('Updated personal data', [
'email' => $user->getEmail(),
]);
// Handle profile completion redirect
$session = $request->getSession();
$redirectUrl = $session->get(self::SESSION_REDIRECT_KEY);
$session = true === $request->hasSession() ? $request->getSession() : null;
$redirectUrl = $session?->get(self::SESSION_REDIRECT_KEY);
if (null !== $redirectUrl && true === $isComplete) {
$session->remove(self::SESSION_REDIRECT_KEY);
$session?->remove(self::SESSION_REDIRECT_KEY);
return $this->redirect($redirectUrl);
}
@@ -121,6 +133,7 @@ class PersonalDataController extends AbstractController
'personalDataForm' => $personalDataForm,
'newsletterSubscribed' => $newsletterSubscribed,
'newsletterPendingConfirmation' => $newsletterPendingConfirmation,
'forcePasswordChange' => $forcePasswordChange,
]);
}
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Controller\Account;
use App\Service\BookingGuestAccountManager;
use App\Service\PublicFormRateLimiter;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class PortalAccessRequestController extends AbstractController
{
public function __construct(
private readonly BookingGuestAccountManager $guestAccountManager,
private readonly PublicFormRateLimiter $publicFormRateLimiter,
private readonly LoggerInterface $logger,
) {
}
#[Route('/portal-access/request', name: 'app_portal_access_request', methods: ['POST'])]
#[IsGranted('PUBLIC_ACCESS')]
public function request(Request $request): Response
{
if (false === $this->isCsrfTokenValid('portal_access_request', $request->request->getString('_token'))) {
$this->addFlash('error', 'Deine Anfrage konnte gerade nicht verarbeitet werden. Bitte versuche es erneut.');
return $this->redirectToRoute('app_login');
}
$email = trim($request->request->getString('email'));
$bookingNumber = $request->request->getInt('bookingNumber');
if ('' === $email || false === filter_var($email, FILTER_VALIDATE_EMAIL) || 0 >= $bookingNumber) {
$this->addFlash('error', 'Bitte prüfe deine Eingaben.');
return $this->redirectToRoute('app_login');
}
if (false === $this->publicFormRateLimiter->isPortalAccessAllowed($email, $request)) {
$this->addFlash('error', PublicFormRateLimiter::THROTTLED_MESSAGE);
return $this->render('security/login.html.twig', [
'last_username' => '',
'error' => null,
], new Response('', Response::HTTP_TOO_MANY_REQUESTS));
}
try {
$result = $this->guestAccountManager->requestPortalAccess($email, $bookingNumber);
} catch (\Throwable $exception) {
$this->logger->warning('Portal access request failed unexpectedly', [
'email' => mb_strtolower($email),
'booking_number' => $bookingNumber,
'error' => $exception->getMessage(),
]);
$result = BookingGuestAccountManager::PORTAL_ACCESS_NOT_SENT;
}
if (BookingGuestAccountManager::PORTAL_ACCESS_ALREADY_ACTIVE === $result) {
$this->addFlash('info', 'Für diese E-Mail-Adresse besteht bereits ein aktiver Portalzugang. Bitte logge dich ein oder nutze „Passwort vergessen“.');
return $this->redirectToRoute('app_login');
}
$this->addFlash('info', 'Wenn zu deiner Buchung ein Portalzugang möglich ist, haben wir dir eine E-Mail gesendet.');
return $this->redirectToRoute('app_login');
}
}
@@ -15,9 +15,11 @@ use App\Exception\TravelNotFoundException;
use App\Form\BookingCreateStep4Type;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use App\Repository\UserRepository;
use App\Htmx\HxTrait;
use App\Service\BookingConfigurator;
use App\Service\BookingCreateContextFactory;
use App\Service\BookingGuestAccountManager;
use App\Service\BookingSessionManager;
use App\Service\NewsletterManager;
use Psr\Log\LoggerInterface;
@@ -38,9 +40,11 @@ class Step4Controller extends AbstractBookingCreateController
private readonly BookingConfigurator $bookingService,
private readonly BookingSessionManager $bookingSessionService,
private readonly BookingCreateContextFactory $createContextFactory,
private readonly BookingGuestAccountManager $guestAccountManager,
private readonly ApiClient $apiClient,
private readonly CacheInterface $cache,
private readonly NewsletterManager $doubleOptInService,
private readonly UserRepository $userRepository,
private readonly LoggerInterface $logger,
) {
}
@@ -64,10 +68,13 @@ class Step4Controller extends AbstractBookingCreateController
$newsletterTargetEmail = $this->resolveNewsletterTargetEmail($bookingCreateDto);
$newsletterOptInVisible = null !== $newsletterTargetEmail;
$guestAccountTargetEmail = $this->resolveGuestAccountTargetEmail($bookingCreateDto);
$guestAccountOptInVisible = null !== $guestAccountTargetEmail;
$form = $this->createForm(BookingCreateStep4Type::class, $bookingCreateDto, [
'show_newsletter_opt_in' => $newsletterOptInVisible,
'newsletter_target_email' => $newsletterTargetEmail,
'show_guest_account_opt_in' => $guestAccountOptInVisible,
]);
$form->handleRequest($request);
@@ -125,6 +132,27 @@ class Step4Controller extends AbstractBookingCreateController
}
}
$guestAccountOptInSelected = $guestAccountOptInVisible
&& $form->has('guestAccountOptIn')
&& true === $form->get('guestAccountOptIn')->getData();
if (true === $guestAccountOptInSelected && null !== $guestAccountTargetEmail) {
try {
$targetParticipant = $this->resolveGuestAccountTargetParticipant($bookingCreateDto);
$this->guestAccountManager->provisionGuestAccountForBooking(
$guestAccountTargetEmail,
(int) $bookingResponse->bookingNumber,
$targetParticipant?->firstName,
$targetParticipant?->lastName,
);
} catch (\Throwable $e) {
$this->logger->warning('Guest account creation failed after booking', [
'email' => $guestAccountTargetEmail,
'error' => $e->getMessage(),
]);
}
}
// Success: Store booking data in flash for conversion tracking
$bookingCreateContext = $this->createContextFactory->createWithParticipantPrices($bookingCreateDto);
$this->addFlash('booking_number', $bookingResponse->bookingNumber);
@@ -224,6 +252,36 @@ class Step4Controller extends AbstractBookingCreateController
return $participant;
}
protected function resolveGuestAccountTargetEmail(BookingDto $bookingDto): ?string
{
if (null !== $this->getUser()) {
return null;
}
$participant = $bookingDto->participants[0] ?? null;
$email = $participant?->email;
if (null === $email || '' === trim($email)) {
return null;
}
$normalizedEmail = mb_strtolower(trim($email));
if (false === filter_var($normalizedEmail, FILTER_VALIDATE_EMAIL)) {
return null;
}
if (null !== $this->userRepository->findOneByNormalizedEmail($normalizedEmail)) {
return null;
}
return $normalizedEmail;
}
private function resolveGuestAccountTargetParticipant(BookingDto $bookingDto): ?ParticipantDto
{
return $this->resolveNewsletterTargetParticipant($bookingDto);
}
/**
* Clears travel data and availability cache after successful booking.
*/
+81
View File
@@ -51,6 +51,15 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[ORM\Column(type: 'string', length: 64, nullable: true)]
private ?string $passwordResetNonce = null;
#[ORM\Column(type: 'string', length: 64, nullable: true)]
private ?string $welcomeActivationNonce = null;
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
private ?\DateTimeImmutable $welcomeActivationExpiresAt = null;
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
private ?\DateTimeImmutable $welcomeActivatedAt = null;
public function __construct(string $email)
{
$this->email = self::normalizeEmail($email);
@@ -202,6 +211,78 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return $this;
}
public function getWelcomeActivationNonce(): ?string
{
return $this->welcomeActivationNonce;
}
public function setWelcomeActivationNonce(?string $welcomeActivationNonce): static
{
$this->welcomeActivationNonce = $welcomeActivationNonce;
return $this;
}
public function getWelcomeActivationExpiresAt(): ?\DateTimeImmutable
{
return $this->welcomeActivationExpiresAt;
}
public function setWelcomeActivationExpiresAt(?\DateTimeImmutable $welcomeActivationExpiresAt): static
{
$this->welcomeActivationExpiresAt = $welcomeActivationExpiresAt;
return $this;
}
public function getWelcomeActivatedAt(): ?\DateTimeImmutable
{
return $this->welcomeActivatedAt;
}
public function setWelcomeActivatedAt(?\DateTimeImmutable $welcomeActivatedAt): static
{
$this->welcomeActivatedAt = $welcomeActivatedAt;
return $this;
}
public function isWelcomeActivationPending(): bool
{
if (null !== $this->welcomeActivatedAt) {
return false;
}
if (null === $this->welcomeActivationNonce || null === $this->welcomeActivationExpiresAt) {
return false;
}
return $this->welcomeActivationExpiresAt > new \DateTimeImmutable();
}
public function hasPendingWelcomeInvite(): bool
{
return null === $this->welcomeActivatedAt && null !== $this->welcomeActivationNonce;
}
public function markWelcomeActivationPending(string $nonce, \DateTimeImmutable $expiresAt): static
{
$this->welcomeActivationNonce = $nonce;
$this->welcomeActivationExpiresAt = $expiresAt;
$this->welcomeActivatedAt = null;
return $this;
}
public function markWelcomeActivated(?\DateTimeImmutable $now = null): static
{
$this->welcomeActivatedAt = $now ?? new \DateTimeImmutable();
$this->welcomeActivationNonce = null;
$this->welcomeActivationExpiresAt = null;
return $this;
}
public function eraseCredentials(): void
{
}
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\EventListener;
use App\Controller\Account\PersonalDataController;
use App\Entity\User;
use App\Htmx\HxRedirectResponse;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
class ForcePasswordChangeRedirectListener implements EventSubscriberInterface
{
public function __construct(
private readonly Security $security,
private readonly UrlGeneratorInterface $urlGenerator,
) {
}
public static function getSubscribedEvents(): array
{
return [
KernelEvents::REQUEST => ['onKernelRequest', 0],
];
}
public function onKernelRequest(RequestEvent $event): void
{
if (false === $event->isMainRequest()) {
return;
}
$request = $event->getRequest();
if (false === $request->hasSession()) {
return;
}
$session = $request->getSession();
if (false === $session->get(PersonalDataController::SESSION_FORCE_PASSWORD_CHANGE_KEY, false)) {
return;
}
/** @var User|null $user */
$user = $this->security->getUser();
if (false === $user instanceof User) {
return;
}
$route = (string) $request->attributes->get('_route', '');
if (in_array($route, ['app_personal_data', 'app_logout'], true)) {
return;
}
$targetUrl = $this->urlGenerator->generate('app_personal_data');
if (true === $request->headers->has('HX-Request')) {
$event->setResponse(new HxRedirectResponse($targetUrl));
return;
}
$event->setResponse(new RedirectResponse($targetUrl, Response::HTTP_FOUND));
}
}
@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Exception;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class GuestAccountActivationExpiredException extends NotFoundHttpException
{
}
+28 -20
View File
@@ -26,24 +26,6 @@ class AccountPersonalDataType extends AbstractType
'label' => false,
'inherit_data' => true,
])
->add('currentPassword', PasswordType::class, [
'label' => 'Aktuelles Passwort',
'mapped' => false,
'required' => false,
'attr' => [
'autocomplete' => 'current-password',
],
'constraints' => [
new Sequentially([
new NotBlank([
'message' => 'Bitte angeben',
]),
new UserPassword([
'message' => 'Das aktuelle Passwort ist nicht korrekt.',
]),
], groups: ['password_change']),
],
])
->add('newPassword', RepeatedType::class, [
'type' => PasswordType::class,
'mapped' => false,
@@ -82,27 +64,53 @@ class AccountPersonalDataType extends AbstractType
],
])
;
if (true === $options['require_current_password']) {
$builder->add('currentPassword', PasswordType::class, [
'label' => 'Aktuelles Passwort',
'mapped' => false,
'required' => false,
'attr' => [
'autocomplete' => 'current-password',
],
'constraints' => [
new Sequentially([
new NotBlank([
'message' => 'Bitte angeben',
]),
new UserPassword([
'message' => 'Das aktuelle Passwort ist nicht korrekt.',
]),
], groups: ['password_change']),
],
]);
}
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => PersonalData::class,
'require_current_password' => true,
'force_password_change' => false,
'validation_groups' => function (FormInterface $form): array {
$groups = ['Default', 'personal_data'];
if ($this->isPasswordSectionUsed($form)) {
if (true === $form->getConfig()->getOption('force_password_change') || $this->isPasswordSectionUsed($form)) {
$groups[] = 'password_change';
}
return $groups;
},
]);
$resolver->setAllowedTypes('require_current_password', 'bool');
$resolver->setAllowedTypes('force_password_change', 'bool');
}
private function isPasswordSectionUsed(FormInterface $form): bool
{
return '' !== (string) $form->get('currentPassword')->getData()
return (true === $form->has('currentPassword') && '' !== (string) $form->get('currentPassword')->getData())
|| '' !== (string) $form->get('newPassword')->getData();
}
}
+10
View File
@@ -44,6 +44,14 @@ class BookingCreateStep4Type extends AbstractType
]);
}
if (true === $options['show_guest_account_opt_in']) {
$builder->add('guestAccountOptIn', CheckboxType::class, [
'label' => 'Benutzeraccount in MyE&P anlegen',
'mapped' => false,
'required' => false,
]);
}
$builder
->add('confirmationAccepted', CheckboxType::class, [
'label' => 'Ich bestätige, dass alle Angaben korrekt sind und möchte verbindlich buchen.',
@@ -73,9 +81,11 @@ class BookingCreateStep4Type extends AbstractType
'data_class' => BookingDto::class,
'show_newsletter_opt_in' => false,
'newsletter_target_email' => null,
'show_guest_account_opt_in' => false,
]);
$resolver->setAllowedTypes('show_newsletter_opt_in', 'bool');
$resolver->setAllowedTypes('newsletter_target_email', ['null', 'string']);
$resolver->setAllowedTypes('show_guest_account_opt_in', 'bool');
}
}
+12
View File
@@ -26,4 +26,16 @@ class UserRepository extends ServiceEntityRepository
->getQuery()
->getOneOrNullResult();
}
public function deleteExpiredDormantGuestInvites(\DateTimeImmutable $threshold): int
{
return (int) $this->createQueryBuilder('u')
->delete()
->where('u.welcomeActivatedAt IS NULL')
->andWhere('u.welcomeActivationExpiresAt IS NOT NULL')
->andWhere('u.welcomeActivationExpiresAt <= :threshold')
->setParameter('threshold', $threshold)
->getQuery()
->execute();
}
}
+351
View File
@@ -0,0 +1,351 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\BpnConnect\BpnConnectClient;
use App\BpnConnect\Exception\BpnConnectException;
use App\BpnConnect\Model\BookingLookupResult;
use App\Email\Mailer;
use App\Entity\User;
use App\Exception\GuestAccountActivationExpiredException;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\UriSigner;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use function Symfony\Component\String\u;
class BookingGuestAccountManager
{
public const string PORTAL_ACCESS_SENT = 'sent';
public const string PORTAL_ACCESS_ALREADY_ACTIVE = 'already_active';
public const string PORTAL_ACCESS_NOT_SENT = 'not_sent';
private const int ACTIVATION_TTL_SECONDS = 3600;
private const string WELCOME_EMAIL_SUBJECT = 'Dein MyE&P Konto ist bereit';
private const string WELCOME_EMAIL_TEMPLATE = 'email/booking_guest_account_welcome.html.twig';
public function __construct(
private readonly BpnConnectClient $bpnConnectClient,
private readonly UserRepository $userRepository,
private readonly EntityManagerInterface $entityManager,
private readonly UserBootstrapper $userBootstrapper,
private readonly UserPasswordHasherInterface $passwordHasher,
private readonly UrlGeneratorInterface $urlGenerator,
private readonly UriSigner $uriSigner,
private readonly Mailer $mailer,
private readonly LoggerInterface $logger,
) {
}
public function provisionGuestAccount(
string $email,
?string $firstName = null,
?string $lastName = null,
?int $addressId = null,
?int $personId = null,
): bool
{
$normalizedEmail = $this->normalizeEmail($email);
if ('' === $normalizedEmail || false === filter_var($normalizedEmail, FILTER_VALIDATE_EMAIL)) {
throw new \InvalidArgumentException('Invalid email for guest account provisioning.');
}
if (null !== $this->userRepository->findOneByNormalizedEmail($normalizedEmail)) {
return false;
}
$user = new User($normalizedEmail);
$plainPassword = $this->generateInitialPassword();
$passwordHash = $this->passwordHasher->hashPassword($user, $plainPassword);
$activationNonce = $this->generateNonce();
$activationExpiresAt = $this->createActivationExpiry();
$this->userBootstrapper->bootstrap(
$user,
$passwordHash,
$addressId,
$personId,
['ROLE_CUSTOMER'],
[],
true,
);
$user->markWelcomeActivationPending($activationNonce, $activationExpiresAt);
$this->entityManager->persist($user);
$this->entityManager->flush();
$this->sendWelcomeEmail(
$normalizedEmail,
$this->buildActivationUrl($normalizedEmail, $activationNonce, $activationExpiresAt),
$firstName,
$lastName,
);
$this->logger->info('Created guest account after booking', [
'email' => $normalizedEmail,
]);
return true;
}
public function provisionGuestAccountForBooking(
string $email,
int $bookingNumber,
?string $firstName = null,
?string $lastName = null,
): bool {
$normalizedEmail = $this->normalizeEmail($email);
if ('' === $normalizedEmail || false === filter_var($normalizedEmail, FILTER_VALIDATE_EMAIL) || 0 >= $bookingNumber) {
throw new \InvalidArgumentException('Invalid booking guest account provisioning input.');
}
$booking = $this->bpnConnectClient->lookupBooking($normalizedEmail, $bookingNumber);
if (null === $booking || false === $this->bookingMatches($booking, $normalizedEmail, $bookingNumber)) {
throw new BpnConnectException('Could not resolve matching booking details for guest account provisioning.');
}
return $this->provisionGuestAccount(
$normalizedEmail,
$firstName ?? $booking->firstName,
$lastName ?? $booking->lastName,
$booking->addressId,
$booking->personId,
);
}
public function requestPortalAccess(string $email, int $bookingNumber): string
{
$normalizedEmail = $this->normalizeEmail($email);
if ('' === $normalizedEmail || false === filter_var($normalizedEmail, FILTER_VALIDATE_EMAIL) || 0 >= $bookingNumber) {
$this->logger->warning('Rejected portal access request with invalid input', [
'email' => $normalizedEmail,
'booking_number' => $bookingNumber,
]);
return self::PORTAL_ACCESS_NOT_SENT;
}
$user = $this->userRepository->findOneByNormalizedEmail($normalizedEmail);
try {
$booking = $this->bpnConnectClient->lookupBooking($normalizedEmail, $bookingNumber);
} catch (BpnConnectException $exception) {
$this->logger->warning('Could not verify portal access booking against BPN Connect', [
'email' => $normalizedEmail,
'booking_number' => $bookingNumber,
'error' => $exception->getMessage(),
]);
return self::PORTAL_ACCESS_NOT_SENT;
}
if (null === $booking) {
$this->logger->warning('Portal access booking lookup returned no result', [
'email' => $normalizedEmail,
'booking_number' => $bookingNumber,
]);
return self::PORTAL_ACCESS_NOT_SENT;
}
if (false === $this->bookingMatches($booking, $normalizedEmail, $bookingNumber)) {
$this->logger->warning('Portal access booking verification failed', [
'email' => $normalizedEmail,
'booking_number' => $bookingNumber,
'booking_email' => $booking->email,
'booking_response_number' => $booking->bookingNumber,
]);
return self::PORTAL_ACCESS_NOT_SENT;
}
try {
if (null === $user) {
$this->provisionGuestAccount(
$normalizedEmail,
$booking->firstName,
$booking->lastName,
$booking->addressId,
$booking->personId,
);
} elseif (false === $user->hasPendingWelcomeInvite()) {
$this->logger->info('Portal access requested for already activated account', [
'email' => $normalizedEmail,
'booking_number' => $bookingNumber,
]);
return self::PORTAL_ACCESS_ALREADY_ACTIVE;
} else {
$this->resendWelcomeEmail($user, $booking->firstName, $booking->lastName);
}
} catch (\Throwable $exception) {
$this->logger->error('Portal access request could not send welcome email', [
'email' => $normalizedEmail,
'booking_number' => $bookingNumber,
'error' => $exception->getMessage(),
]);
return self::PORTAL_ACCESS_NOT_SENT;
}
return self::PORTAL_ACCESS_SENT;
}
public function validateActivationRequest(Request $request): User
{
return $this->resolveActivationRequest($request, false);
}
public function validateActivationRequestForResend(Request $request): User
{
return $this->resolveActivationRequest($request, true);
}
public function validateActivationUrlForResend(string $activationUrl): User
{
return $this->resolveActivationRequest(Request::create($activationUrl, 'GET'), true);
}
public function resendWelcomeEmail(User $user, ?string $firstName = null, ?string $lastName = null): void
{
$previousNonce = $user->getWelcomeActivationNonce();
$previousExpiresAt = $user->getWelcomeActivationExpiresAt();
$newNonce = $this->generateNonce();
$normalizedEmail = $this->normalizeEmail($user->getEmail());
$newExpiresAt = $this->createActivationExpiry();
$user->markWelcomeActivationPending($newNonce, $newExpiresAt);
$this->entityManager->flush();
try {
$this->sendWelcomeEmail(
$normalizedEmail,
$this->buildActivationUrl($normalizedEmail, $newNonce, $newExpiresAt),
$firstName,
$lastName,
);
} catch (\Throwable $exception) {
$user->setWelcomeActivationNonce($previousNonce);
$user->setWelcomeActivationExpiresAt($previousExpiresAt);
$this->entityManager->flush();
$this->logger->error('Could not resend guest account welcome email', [
'email' => $normalizedEmail,
'error' => $exception->getMessage(),
]);
throw $exception;
}
$this->logger->info('Resent guest account welcome email', [
'email' => $normalizedEmail,
]);
}
private function resolveActivationRequest(Request $request, bool $allowExpired): User
{
if (false === $this->uriSigner->checkRequest($request)) {
throw new NotFoundHttpException();
}
$expires = $request->query->getInt('expires');
if (0 >= $expires || $expires < time()) {
if (false === $allowExpired) {
throw new GuestAccountActivationExpiredException();
}
}
$email = $this->normalizeEmail($request->query->getString('email'));
$nonce = trim($request->query->getString('nonce'));
if ('' === $email || '' === $nonce) {
throw new NotFoundHttpException();
}
$user = $this->userRepository->findOneByNormalizedEmail($email);
if (null === $user || null === $user->getWelcomeActivationNonce() || false === hash_equals($user->getWelcomeActivationNonce(), $nonce)) {
throw new NotFoundHttpException();
}
return $user;
}
public function consumeActivation(User $user): void
{
$user->markWelcomeActivated();
$this->entityManager->flush();
}
private function buildActivationUrl(string $normalizedEmail, string $nonce, \DateTimeImmutable $expiresAt): string
{
$url = $this->urlGenerator->generate('app_guest_account_activate', [
'email' => $normalizedEmail,
'nonce' => $nonce,
'expires' => $expiresAt->getTimestamp(),
], UrlGeneratorInterface::ABSOLUTE_URL);
return $this->uriSigner->sign($url);
}
private function createActivationExpiry(): \DateTimeImmutable
{
return new \DateTimeImmutable(sprintf('+%d seconds', self::ACTIVATION_TTL_SECONDS));
}
private function bookingMatches(BookingLookupResult $booking, string $normalizedEmail, int $bookingNumber): bool
{
return $bookingNumber === $booking->bookingNumber
&& $normalizedEmail === $this->normalizeEmail($booking->email);
}
private function sendWelcomeEmail(string $normalizedEmail, string $activationUrl, ?string $firstName, ?string $lastName): void
{
$this->mailer->createAndSendEmail(
[
'activationUrl' => $activationUrl,
'firstName' => $this->normalizeName($firstName),
'lastName' => $this->normalizeName($lastName),
],
[
'to' => $normalizedEmail,
'subject' => self::WELCOME_EMAIL_SUBJECT,
'template' => self::WELCOME_EMAIL_TEMPLATE,
],
);
}
private function normalizeEmail(string $email): string
{
return u($email)->trim()->lower()->toString();
}
private function normalizeName(?string $value): ?string
{
if (null === $value) {
return null;
}
$trimmed = trim($value);
return '' === $trimmed ? null : $trimmed;
}
private function generateNonce(): string
{
return bin2hex(random_bytes(32));
}
private function generateInitialPassword(): string
{
return bin2hex(random_bytes(24));
}
}
+3
View File
@@ -107,6 +107,9 @@ class PasswordResetManager
{
$user->setPassword($this->passwordHasher->hashPassword($user, $plainPassword));
$user->setPasswordResetNonce($this->generateNonce());
if (true === $user->hasPendingWelcomeInvite()) {
$user->markWelcomeActivated();
}
$this->entityManager->flush();
+6
View File
@@ -14,6 +14,7 @@ class PublicFormRateLimiter
public function __construct(
private readonly RateLimiterFactory $registrationLimiter,
private readonly RateLimiterFactory $passwordResetLimiter,
private readonly RateLimiterFactory $portalAccessRequestLimiter,
) {
}
@@ -27,6 +28,11 @@ class PublicFormRateLimiter
return $this->consume($this->passwordResetLimiter, $email, $request);
}
public function isPortalAccessAllowed(string $email, Request $request): bool
{
return $this->consume($this->portalAccessRequestLimiter, $email, $request);
}
public function createBucketKey(string $email, ?string $clientIp): string
{
return hash('sha256', $this->normalizeEmail($email).'|'.($clientIp ?? 'unknown'));
@@ -0,0 +1,30 @@
{% extends 'layout.html.twig' %}
{% block content %}
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 px-4 lg:px-8 py-8 lg:py-16">
<div>
<h1 class="text-white uppercase pb-4 mb-8 border-b border-primary-bg/40">
Link<br>
abgelaufen
</h1>
{% include '_partials/_flashes.html.twig' %}
<div class="pb-4">
<p class="mb-4 text-white">
Der Aktivierungslink ist leider abgelaufen. Du kannst dir die Willkommens-E-Mail noch einmal
zusenden lassen.
</p>
{{ form_start(resendForm) }}
<button type="submit" class="button button--primary">
Willkommens-E-Mail erneut senden
</button>
{{ form_rest(resendForm) }}
{{ form_end(resendForm) }}
</div>
<div class="flex space-x-2">
<a href="{{ path('app_login') }}" class="text-white underline">
Zum Login
</a>
</div>
</div>
</div>
{% endblock %}
+11 -1
View File
@@ -15,6 +15,14 @@
} %}
</div>
{% endif %}
{% if forcePasswordChange %}
<div class="mx-4 lg:mx-8">
{% include '_partials/_alert.html.twig' with {
level: 'info',
messages: ['Bitte lege zuerst ein neues Passwort fest, um dein Konto zu aktivieren.']
} %}
</div>
{% endif %}
<div class="p-4 lg:p-8">
<div class="divide-y divide-primary-bg/40">
<div class="grid md:grid-cols-2 gap-x-8 gap-y-4 pb-8">
@@ -72,7 +80,9 @@
<h3 class="text-white">
Passwort ändern
</h3>
{{ form_row(personalDataForm.currentPassword, { 'label_attr': { 'class': 'text-white' } }) }}
{% if not forcePasswordChange %}
{{ form_row(personalDataForm.currentPassword, { 'label_attr': { 'class': 'text-white' } }) }}
{% endif %}
{{ form_row(personalDataForm.newPassword.first, { 'label_attr': { 'class': 'text-white' } }) }}
{{ form_row(personalDataForm.newPassword.second, { 'label_attr': { 'class': 'text-white' } }) }}
</div>
@@ -486,6 +486,11 @@
'label_attr': {'class': 'text-base font-semibold'}
}) }}
{% endif %}
{% if form.guestAccountOptIn is defined %}
{{ form_row(form.guestAccountOptIn, {
'label_attr': {'class': 'text-base font-semibold'}
}) }}
{% endif %}
{{ form_row(form.confirmationAccepted, {
'label_attr': {'class': 'text-base font-semibold'}
}) }}
@@ -0,0 +1,21 @@
{% extends 'email/layout.html.twig' %}
{% block body %}
<h1>
Dein MyE&amp;P Konto
</h1>
<p>
Für deine Buchung wurde ein MyE&amp;P-Konto angelegt.
</p>
<p>
Über den folgenden Link meldest du dich einmalig an und legst direkt ein neues Passwort fest.
</p>
<p>
<a href="{{ activationUrl }}" class="button">
Passwort jetzt festlegen
</a>
</p>
<p>
Wenn du diese Buchung nicht erstellt hast, kannst du diese E-Mail ignorieren.
</p>
{% endblock %}
+42
View File
@@ -53,6 +53,48 @@
Jetzt registrieren
</a>
</div>
<div class="mt-10 pt-8 border-t border-primary-bg/40">
<h2 class="text-white uppercase mb-4">
Portalzugang anfordern
</h2>
<p class="text-white mb-4">
Du hast noch keinen Portalzugang? Gib die E-Mail-Adresse aus deiner Buchung und deine Buchungsnummer ein.
Wenn deine Angaben zur Buchung passen, senden wir dir einen Aktivierungslink an diese E-Mail-Adresse.
</p>
<form action="{{ path('app_portal_access_request') }}" method="post">
<div class="mb-4">
<label for="portal_access_email" class="mb-1 font-semibold text-white">
E-Mail:
</label>
<input type="email"
id="portal_access_email"
name="email"
class="form-field"
required
autocomplete="email">
</div>
<div class="mb-4">
<label for="portal_access_booking_number" class="mb-1 font-semibold text-white">
Buchungsnummer:
</label>
<input type="number"
id="portal_access_booking_number"
name="bookingNumber"
class="form-field"
required
min="1"
inputmode="numeric">
</div>
<button type="submit" class="button button--primary">
Portalzugang anfordern
</button>
<input type="hidden" name="_token" value="{{ csrf_token('portal_access_request') }}">
</form>
</div>
</div>
<div>
<h3 class="mb-2 text-white">
+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'));