diff --git a/.env b/.env index 4f2ec8f2..7c9997b5 100644 --- a/.env +++ b/.env @@ -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/" diff --git a/.env.test b/.env.test index 9d631027..85d14697 100644 --- a/.env.test +++ b/.env.test @@ -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 diff --git a/config/packages/framework.yaml b/config/packages/framework.yaml index 9dd7f6e0..92ce9df5 100644 --- a/config/packages/framework.yaml +++ b/config/packages/framework.yaml @@ -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: diff --git a/config/packages/zenstruck_schedule.yaml b/config/packages/zenstruck_schedule.yaml index a02d4828..97ba627e 100644 --- a/config/packages/zenstruck_schedule.yaml +++ b/config/packages/zenstruck_schedule.yaml @@ -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" diff --git a/config/services.yaml b/config/services.yaml index 6a841594..25ea3a8c 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -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: diff --git a/docs/body-dimensions-transition-plan.md b/docs/body-dimensions-transition-plan.md deleted file mode 100644 index 690e293b..00000000 --- a/docs/body-dimensions-transition-plan.md +++ /dev/null @@ -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. diff --git a/migrations/Version20260529120000.php b/migrations/Version20260529120000.php new file mode 100644 index 00000000..1b9b78b1 --- /dev/null +++ b/migrations/Version20260529120000.php @@ -0,0 +1,26 @@ +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'); + } +} diff --git a/migrations/Version20260529130000.php b/migrations/Version20260529130000.php new file mode 100644 index 00000000..e24a348f --- /dev/null +++ b/migrations/Version20260529130000.php @@ -0,0 +1,28 @@ +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'); + } +} diff --git a/src/BpnConnect/BpnConnectClient.php b/src/BpnConnect/BpnConnectClient.php new file mode 100644 index 00000000..bf5a94cd --- /dev/null +++ b/src/BpnConnect/BpnConnectClient.php @@ -0,0 +1,83 @@ +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 $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; + } +} diff --git a/src/BpnConnect/Exception/BpnConnectException.php b/src/BpnConnect/Exception/BpnConnectException.php new file mode 100644 index 00000000..e30d225e --- /dev/null +++ b/src/BpnConnect/Exception/BpnConnectException.php @@ -0,0 +1,9 @@ +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(), + ]); } } diff --git a/src/Command/CleanupGuestInviteAccountsCommand.php b/src/Command/CleanupGuestInviteAccountsCommand.php new file mode 100644 index 00000000..9e9556f5 --- /dev/null +++ b/src/Command/CleanupGuestInviteAccountsCommand.php @@ -0,0 +1,50 @@ +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; + } +} diff --git a/src/Controller/Account/GuestAccountActivationController.php b/src/Controller/Account/GuestAccountActivationController.php new file mode 100644 index 00000000..4a196354 --- /dev/null +++ b/src/Controller/Account/GuestAccountActivationController.php @@ -0,0 +1,87 @@ +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(); + } +} diff --git a/src/Controller/Account/PersonalDataController.php b/src/Controller/Account/PersonalDataController.php index 3237854e..38d9e5a6 100644 --- a/src/Controller/Account/PersonalDataController.php +++ b/src/Controller/Account/PersonalDataController.php @@ -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, ]); } diff --git a/src/Controller/Account/PortalAccessRequestController.php b/src/Controller/Account/PortalAccessRequestController.php new file mode 100644 index 00000000..0ab62bc1 --- /dev/null +++ b/src/Controller/Account/PortalAccessRequestController.php @@ -0,0 +1,75 @@ +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'); + } +} diff --git a/src/Controller/Booking/Create/Step4Controller.php b/src/Controller/Booking/Create/Step4Controller.php index a3e0dec3..f0e9d76f 100644 --- a/src/Controller/Booking/Create/Step4Controller.php +++ b/src/Controller/Booking/Create/Step4Controller.php @@ -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. */ diff --git a/src/Entity/User.php b/src/Entity/User.php index 9d1a8039..8cf323e0 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -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 { } diff --git a/src/EventListener/ForcePasswordChangeRedirectListener.php b/src/EventListener/ForcePasswordChangeRedirectListener.php new file mode 100644 index 00000000..2d143a52 --- /dev/null +++ b/src/EventListener/ForcePasswordChangeRedirectListener.php @@ -0,0 +1,69 @@ + ['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)); + } +} diff --git a/src/Exception/GuestAccountActivationExpiredException.php b/src/Exception/GuestAccountActivationExpiredException.php new file mode 100644 index 00000000..660a139e --- /dev/null +++ b/src/Exception/GuestAccountActivationExpiredException.php @@ -0,0 +1,11 @@ + 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(); } } diff --git a/src/Form/BookingCreateStep4Type.php b/src/Form/BookingCreateStep4Type.php index ac03e51e..23840a06 100644 --- a/src/Form/BookingCreateStep4Type.php +++ b/src/Form/BookingCreateStep4Type.php @@ -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'); } } diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php index 2be7c051..eeee5fc4 100644 --- a/src/Repository/UserRepository.php +++ b/src/Repository/UserRepository.php @@ -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(); + } } diff --git a/src/Service/BookingGuestAccountManager.php b/src/Service/BookingGuestAccountManager.php new file mode 100644 index 00000000..52c18bf5 --- /dev/null +++ b/src/Service/BookingGuestAccountManager.php @@ -0,0 +1,351 @@ +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)); + } +} diff --git a/src/Service/PasswordResetManager.php b/src/Service/PasswordResetManager.php index d0a91eea..0fef0ca0 100644 --- a/src/Service/PasswordResetManager.php +++ b/src/Service/PasswordResetManager.php @@ -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(); diff --git a/src/Service/PublicFormRateLimiter.php b/src/Service/PublicFormRateLimiter.php index 39fd8b84..7f2b57a6 100644 --- a/src/Service/PublicFormRateLimiter.php +++ b/src/Service/PublicFormRateLimiter.php @@ -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')); diff --git a/templates/account/guest_account_activation_expired.html.twig b/templates/account/guest_account_activation_expired.html.twig new file mode 100644 index 00000000..8292f656 --- /dev/null +++ b/templates/account/guest_account_activation_expired.html.twig @@ -0,0 +1,30 @@ +{% extends 'layout.html.twig' %} + +{% block content %} +
+
+

+ Link
+ abgelaufen +

+ {% include '_partials/_flashes.html.twig' %} +
+

+ Der Aktivierungslink ist leider abgelaufen. Du kannst dir die Willkommens-E-Mail noch einmal + zusenden lassen. +

+ {{ form_start(resendForm) }} + + {{ form_rest(resendForm) }} + {{ form_end(resendForm) }} +
+ +
+
+{% endblock %} diff --git a/templates/account/personal_data.html.twig b/templates/account/personal_data.html.twig index 3dad7b24..7dab7054 100644 --- a/templates/account/personal_data.html.twig +++ b/templates/account/personal_data.html.twig @@ -15,6 +15,14 @@ } %} {% endif %} + {% if forcePasswordChange %} +
+ {% include '_partials/_alert.html.twig' with { + level: 'info', + messages: ['Bitte lege zuerst ein neues Passwort fest, um dein Konto zu aktivieren.'] + } %} +
+ {% endif %}
@@ -72,7 +80,9 @@

Passwort ändern

- {{ 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' } }) }}
diff --git a/templates/booking/create/step_4.html.twig b/templates/booking/create/step_4.html.twig index 594b6423..a18bbd82 100644 --- a/templates/booking/create/step_4.html.twig +++ b/templates/booking/create/step_4.html.twig @@ -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'} }) }} diff --git a/templates/email/booking_guest_account_welcome.html.twig b/templates/email/booking_guest_account_welcome.html.twig new file mode 100644 index 00000000..a8addce5 --- /dev/null +++ b/templates/email/booking_guest_account_welcome.html.twig @@ -0,0 +1,21 @@ +{% extends 'email/layout.html.twig' %} + +{% block body %} +

+ Dein MyE&P Konto +

+

+ Für deine Buchung wurde ein MyE&P-Konto angelegt. +

+

+ Über den folgenden Link meldest du dich einmalig an und legst direkt ein neues Passwort fest. +

+

+ + Passwort jetzt festlegen + +

+

+ Wenn du diese Buchung nicht erstellt hast, kannst du diese E-Mail ignorieren. +

+{% endblock %} diff --git a/templates/security/login.html.twig b/templates/security/login.html.twig index cafb8446..8bfd8721 100644 --- a/templates/security/login.html.twig +++ b/templates/security/login.html.twig @@ -53,6 +53,48 @@ Jetzt registrieren
+ +
+

+ Portalzugang anfordern +

+

+ 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. +

+ +
+
+ + +
+ +
+ + +
+ + + +
+

diff --git a/tests/BpnConnect/BpnConnectClientTest.php b/tests/BpnConnect/BpnConnectClientTest.php new file mode 100644 index 00000000..35506c8b --- /dev/null +++ b/tests/BpnConnect/BpnConnectClientTest.php @@ -0,0 +1,57 @@ + 111386, + 'bookingId' => 98609, + 'addressId' => 225672, + 'personId' => 412344, + 'email' => 'bar-teilnehmer.in1@example.com', + '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(' BAR-Teilnehmer.IN1@example.com ', 111386); + + self::assertSame('GET', $capturedMethod); + self::assertSame('https://bpn-connect.test/api/bookings/lookup?email=bar-teilnehmer.in1@example.com&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('bar-teilnehmer.in1@example.com', $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('customer@example.com', 123)); + } +} diff --git a/tests/BusProNet/XmlParser/BookingResponseParserTest.php b/tests/BusProNet/XmlParser/BookingResponseParserTest.php index 90b186fe..504f34d1 100644 --- a/tests/BusProNet/XmlParser/BookingResponseParserTest.php +++ b/tests/BusProNet/XmlParser/BookingResponseParserTest.php @@ -17,6 +17,22 @@ class BookingResponseParserTest extends TestCase $this->parser = new BookingResponseParser(); } + public function testParseSuccessfulBookingResponse(): void + { + $xmlContent = ' + + + erfolgt + 321530 +'; + + $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 = ' diff --git a/tests/Command/CleanupGuestInviteAccountsCommandTest.php b/tests/Command/CleanupGuestInviteAccountsCommandTest.php new file mode 100644 index 00000000..07f2ae99 --- /dev/null +++ b/tests/Command/CleanupGuestInviteAccountsCommandTest.php @@ -0,0 +1,58 @@ +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()); + } +} diff --git a/tests/Controller/Account/GuestAccountActivationControllerTest.php b/tests/Controller/Account/GuestAccountActivationControllerTest.php new file mode 100644 index 00000000..478fa21d --- /dev/null +++ b/tests/Controller/Account/GuestAccountActivationControllerTest.php @@ -0,0 +1,113 @@ + '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/activate?email=customer@example.com&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('customer@example.com'); + $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=customer@example.com'); + })) + ->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/activate?email=customer@example.com&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/activate?email=customer@example.com&nonce=current-nonce&expires=9999999999&_hash=invalid'); + + self::assertResponseStatusCodeSame(404); + } +} diff --git a/tests/Controller/Account/PersonalDataControllerTest.php b/tests/Controller/Account/PersonalDataControllerTest.php index eb921ccc..0e11c993 100644 --- a/tests/Controller/Account/PersonalDataControllerTest.php +++ b/tests/Controller/Account/PersonalDataControllerTest.php @@ -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('customer@example.com', null, null) + ->willReturn($personalData); + $controller->apiClient + ->expects(self::once()) + ->method('updatePersonalData') + ->with('customer@example.com', $personalData) + ->willReturn($personalData); + $controller->newsletterManager + ->expects(self::once()) + ->method('hasConfirmedOptIn') + ->with('customer@example.com') + ->willReturn(false); + $controller->newsletterManager + ->expects(self::once()) + ->method('hasPendingConfirmation') + ->with('customer@example.com') + ->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!'); diff --git a/tests/Controller/Account/PortalAccessRequestControllerTest.php b/tests/Controller/Account/PortalAccessRequestControllerTest.php new file mode 100644 index 00000000..e3d4c0cc --- /dev/null +++ b/tests/Controller/Account/PortalAccessRequestControllerTest.php @@ -0,0 +1,151 @@ + 'on']); + $client->disableReboot(); + + $rateLimiter = $this->createMock(PublicFormRateLimiter::class); + $rateLimiter + ->expects(self::once()) + ->method('isPortalAccessAllowed') + ->with('customer@example.com', self::isInstanceOf(Request::class)) + ->willReturn(true); + + $guestAccountManager = $this->createMock(BookingGuestAccountManager::class); + $guestAccountManager + ->expects(self::once()) + ->method('requestPortalAccess') + ->with('customer@example.com', 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' => 'customer@example.com', + '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('customer@example.com', self::isInstanceOf(Request::class)) + ->willReturn(true); + + $guestAccountManager = $this->createMock(BookingGuestAccountManager::class); + $guestAccountManager + ->expects(self::once()) + ->method('requestPortalAccess') + ->with('customer@example.com', 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' => 'customer@example.com', + '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('customer@example.com', self::isInstanceOf(Request::class)) + ->willReturn(true); + + $guestAccountManager = $this->createMock(BookingGuestAccountManager::class); + $guestAccountManager + ->expects(self::once()) + ->method('requestPortalAccess') + ->with('customer@example.com', 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' => 'customer@example.com', + '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('customer@example.com', 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' => 'customer@example.com', + 'bookingNumber' => '123', + '_token' => $token, + ]); + + self::assertResponseStatusCodeSame(429); + self::assertStringContainsString(PublicFormRateLimiter::THROTTLED_MESSAGE, $client->getResponse()->getContent()); + } +} diff --git a/tests/Controller/Booking/Create/Step4ControllerTest.php b/tests/Controller/Booking/Create/Step4ControllerTest.php new file mode 100644 index 00000000..f4f49359 --- /dev/null +++ b/tests/Controller/Booking/Create/Step4ControllerTest.php @@ -0,0 +1,293 @@ + '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('customer@example.com') + ->willReturn(null); + + $guestAccountManager = $this->createMock(BookingGuestAccountManager::class); + $guestAccountManager + ->expects(self::once()) + ->method('provisionGuestAccountForBooking') + ->with('customer@example.com', 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('logged@example.com'), + 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('customer@example.com') + ->willReturn(new User('customer@example.com')); + + $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('customer@example.com')); + $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/activate?email=customer@example.com&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 = 'Customer@Example.COM'; + $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; + } +} diff --git a/tests/EventListener/ForcePasswordChangeRedirectListenerTest.php b/tests/EventListener/ForcePasswordChangeRedirectListenerTest.php new file mode 100644 index 00000000..defcbf8c --- /dev/null +++ b/tests/EventListener/ForcePasswordChangeRedirectListenerTest.php @@ -0,0 +1,109 @@ +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 $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('customer@example.com')); + + $urlGenerator = $this->createMock(UrlGeneratorInterface::class); + $urlGenerator + ->method('generate') + ->with('app_personal_data') + ->willReturn('/personal-data'); + + return new ForcePasswordChangeRedirectListener($security, $urlGenerator); + } +} diff --git a/tests/Repository/UserRepositoryTest.php b/tests/Repository/UserRepositoryTest.php new file mode 100644 index 00000000..36329ae5 --- /dev/null +++ b/tests/Repository/UserRepositoryTest.php @@ -0,0 +1,71 @@ +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)); + } +} diff --git a/tests/Service/BookingGuestAccountManagerTest.php b/tests/Service/BookingGuestAccountManagerTest.php new file mode 100644 index 00000000..364278db --- /dev/null +++ b/tests/Service/BookingGuestAccountManagerTest.php @@ -0,0 +1,623 @@ +createMock(UserRepository::class); + $userRepository + ->expects(self::once()) + ->method('findOneByNormalizedEmail') + ->with('customer@example.com') + ->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(' Customer@Example.COM ', 'Alice', 'Example', 12345, 67890)); + self::assertSame('app_guest_account_activate', $generatedRoute); + self::assertSame(UrlGeneratorInterface::ABSOLUTE_URL, $generatedReferenceType); + self::assertSame('customer@example.com', $generatedParameters['email']); + self::assertSame($persistedUser?->getWelcomeActivationNonce(), $generatedParameters['nonce']); + self::assertTrue($uriSigner->check((string) $capturedContext['activationUrl'])); + self::assertSame('customer@example.com', $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('customer@example.com', $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('customer@example.com') + ->willReturn(new User('customer@example.com')); + + $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('customer@example.com', 'Alice', 'Example')); + } + + public function testRequestPortalAccessProvisionsGuestAccountWhenBookingMatches(): void + { + $persistedUser = null; + $capturedContext = null; + $capturedOptions = null; + $booking = $this->createLookupResult('customer@example.com', 123, 'Alice', 'Example'); + + $bpnConnectClient = $this->createMock(BpnConnectClient::class); + $bpnConnectClient + ->expects(self::once()) + ->method('lookupBooking') + ->with('customer@example.com', 123) + ->willReturn($booking); + + $userRepository = $this->createMock(UserRepository::class); + $userRepository + ->expects(self::exactly(2)) + ->method('findOneByNormalizedEmail') + ->with('customer@example.com') + ->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/activate?email=customer@example.com&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('Customer@Example.COM', 123)); + self::assertInstanceOf(User::class, $persistedUser); + self::assertSame('customer@example.com', $persistedUser->getEmail()); + self::assertSame(12345, $persistedUser->getAddressId()); + self::assertSame(67890, $persistedUser->getPersonId()); + self::assertSame('customer@example.com', $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('customer@example.com'); + $user->setAddressId(12345); + $user->setPersonId(67890); + $user->setWelcomeActivationNonce('current-nonce'); + $user->setWelcomeActivationExpiresAt(new \DateTimeImmutable('+30 minutes')); + $booking = $this->createLookupResult('customer@example.com', 123, 'Alice', 'Example'); + + $bpnConnectClient = $this->createMock(BpnConnectClient::class); + $bpnConnectClient + ->expects(self::once()) + ->method('lookupBooking') + ->with('customer@example.com', 123) + ->willReturn($booking); + + $userRepository = $this->createMock(UserRepository::class); + $userRepository + ->expects(self::once()) + ->method('findOneByNormalizedEmail') + ->with('customer@example.com') + ->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/activate?email=customer@example.com&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('customer@example.com', 123)); + self::assertNotSame('current-nonce', $user->getWelcomeActivationNonce()); + self::assertNull($user->getWelcomeActivatedAt()); + self::assertInstanceOf(\DateTimeImmutable::class, $user->getWelcomeActivationExpiresAt()); + } + + public function testRequestPortalAccessUsesExistingUserIdsAndDoesNotMailActivatedAccount(): void + { + $user = new User('customer@example.com'); + $user->setAddressId(12345); + $user->setPersonId(67890); + $user->markWelcomeActivated(); + $booking = $this->createLookupResult('customer@example.com', 123, 'Alice', 'Example'); + + $bpnConnectClient = $this->createMock(BpnConnectClient::class); + $bpnConnectClient + ->expects(self::once()) + ->method('lookupBooking') + ->with('customer@example.com', 123) + ->willReturn($booking); + + $userRepository = $this->createMock(UserRepository::class); + $userRepository + ->expects(self::once()) + ->method('findOneByNormalizedEmail') + ->with('customer@example.com') + ->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('customer@example.com', 123)); + } + + public function testProvisionGuestAccountForBookingFetchesBookingIdentifiers(): void + { + $persistedUser = null; + $booking = $this->createLookupResult('customer@example.com', 123, 'Alice', 'Example'); + + $bpnConnectClient = $this->createMock(BpnConnectClient::class); + $bpnConnectClient + ->expects(self::once()) + ->method('lookupBooking') + ->with('customer@example.com', 123) + ->willReturn($booking); + + $userRepository = $this->createMock(UserRepository::class); + $userRepository + ->expects(self::once()) + ->method('findOneByNormalizedEmail') + ->with('customer@example.com') + ->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/activate?email=customer@example.com&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('Customer@Example.COM', 123)); + self::assertInstanceOf(User::class, $persistedUser); + self::assertSame('customer@example.com', $persistedUser->getEmail()); + self::assertSame(12345, $persistedUser->getAddressId()); + self::assertSame(67890, $persistedUser->getPersonId()); + } + + public function testRequestPortalAccessRejectsMismatchedBookingGenerically(): void + { + $booking = $this->createLookupResult('other@example.com', 123, 'Alice', 'Example'); + + $bpnConnectClient = $this->createMock(BpnConnectClient::class); + $bpnConnectClient + ->expects(self::once()) + ->method('lookupBooking') + ->with('customer@example.com', 123) + ->willReturn($booking); + + $userRepository = $this->createMock(UserRepository::class); + $userRepository + ->expects(self::once()) + ->method('findOneByNormalizedEmail') + ->with('customer@example.com') + ->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('customer@example.com', 123)); + } + + public function testConsumeActivationMarksInviteAsActivated(): void + { + $user = new User('customer@example.com'); + $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('customer@example.com'); + $user->setWelcomeActivationNonce('current-nonce'); + + $manager = $this->createValidationManager($user); + $request = Request::create( + $this->buildActivationUrl('customer@example.com', '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('customer@example.com'); + $user->setWelcomeActivationNonce('current-nonce'); + + $manager = $this->createValidationManager($user); + $request = Request::create( + $this->buildActivationUrl('customer@example.com', 'current-nonce', time() - 60), + 'GET' + ); + + $this->expectException(GuestAccountActivationExpiredException::class); + $manager->validateActivationRequest($request); + } + + public function testValidateActivationUrlForResendAcceptsExpiredLink(): void + { + $user = new User('customer@example.com'); + $user->setWelcomeActivationNonce('current-nonce'); + + $manager = $this->createValidationManager($user); + $activationUrl = $this->buildActivationUrl('customer@example.com', 'current-nonce', time() - 60); + + self::assertSame($user, $manager->validateActivationUrlForResend($activationUrl)); + } + + public function testResendWelcomeEmailRotatesNonceAndSendsActivationMail(): void + { + $user = new User('customer@example.com'); + $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('customer@example.com', $generatedParameters['email']); + self::assertSame($user->getWelcomeActivationNonce(), $generatedParameters['nonce']); + self::assertSame('customer@example.com', $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('customer@example.com') + ->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/activate?email=customer@example.com&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('customer@example.com', 'Alice', 'Example'); + } finally { + self::assertInstanceOf(User::class, $persistedUser); + self::assertSame('customer@example.com', $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 'customer@example.com' === $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, + ); + } +} diff --git a/tests/Service/PasswordResetManagerTest.php b/tests/Service/PasswordResetManagerTest.php index 60b3215c..a0b38abf 100644 --- a/tests/Service/PasswordResetManagerTest.php +++ b/tests/Service/PasswordResetManagerTest.php @@ -173,6 +173,35 @@ class PasswordResetManagerTest extends TestCase self::assertMatchesRegularExpression('/^[a-f0-9]{64}$/', (string) $user->getPasswordResetNonce()); } + public function testResetPasswordClearsPendingWelcomeInvite(): void + { + $user = new User('customer@example.com'); + $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('customer@example.com'); diff --git a/tests/Support/TestPublicFormRateLimiter.php b/tests/Support/TestPublicFormRateLimiter.php index 5e4863bc..396c0a39 100644 --- a/tests/Support/TestPublicFormRateLimiter.php +++ b/tests/Support/TestPublicFormRateLimiter.php @@ -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'));