wip: guest account registration (self-service and booking create flow)
This commit is contained in:
1 parent
e4430ac5c5
commit
d74888f64b
43 files changed
+2736
-144
No files matched your search
@@ -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/"
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260529120000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add welcome activation nonce to users';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE user ADD welcome_activation_nonce VARCHAR(64) DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE user DROP welcome_activation_nonce');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260529130000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add guest invite expiry and activation timestamps to users';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE user ADD welcome_activation_expires_at DATETIME DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE user ADD welcome_activated_at DATETIME DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE user DROP welcome_activated_at');
|
||||
$this->addSql('ALTER TABLE user DROP welcome_activation_expires_at');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\BpnConnect;
|
||||
|
||||
use App\BpnConnect\Exception\BpnConnectException;
|
||||
use App\BpnConnect\Model\BookingLookupResult;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
class BpnConnectClient
|
||||
{
|
||||
public function __construct(
|
||||
private readonly HttpClientInterface $httpClient,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
public function lookupBooking(string $email, int $bookingNumber): ?BookingLookupResult
|
||||
{
|
||||
$normalizedEmail = mb_strtolower(trim($email));
|
||||
|
||||
try {
|
||||
$response = $this->httpClient->request('GET', 'api/bookings/lookup', [
|
||||
'query' => [
|
||||
'email' => $normalizedEmail,
|
||||
'bookingNumber' => $bookingNumber,
|
||||
],
|
||||
]);
|
||||
|
||||
if (404 === $response->getStatusCode()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = $response->toArray();
|
||||
} catch (ExceptionInterface $exception) {
|
||||
$this->logger->warning('BPN Connect booking lookup failed', [
|
||||
'email' => $normalizedEmail,
|
||||
'booking_number' => $bookingNumber,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
throw new BpnConnectException('Could not lookup booking via BPN Connect.', previous: $exception);
|
||||
}
|
||||
|
||||
return $this->mapBookingLookupResult($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
private function mapBookingLookupResult(array $data): BookingLookupResult
|
||||
{
|
||||
foreach (['bookingNumber', 'bookingId', 'addressId', 'personId', 'email'] as $requiredField) {
|
||||
if (false === array_key_exists($requiredField, $data) || '' === trim((string) $data[$requiredField])) {
|
||||
throw new BpnConnectException(sprintf('BPN Connect booking lookup response is missing "%s".', $requiredField));
|
||||
}
|
||||
}
|
||||
|
||||
return new BookingLookupResult(
|
||||
bookingNumber: (int) $data['bookingNumber'],
|
||||
bookingId: (int) $data['bookingId'],
|
||||
addressId: (int) $data['addressId'],
|
||||
personId: (int) $data['personId'],
|
||||
email: mb_strtolower(trim((string) $data['email'])),
|
||||
firstName: $this->optionalString($data['firstName'] ?? null),
|
||||
lastName: $this->optionalString($data['lastName'] ?? null),
|
||||
);
|
||||
}
|
||||
|
||||
private function optionalString(mixed $value): ?string
|
||||
{
|
||||
if (null === $value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = trim((string) $value);
|
||||
|
||||
return '' === $value ? null : $value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\BpnConnect\Exception;
|
||||
|
||||
class BpnConnectException extends \RuntimeException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\BpnConnect\Model;
|
||||
|
||||
class BookingLookupResult
|
||||
{
|
||||
public function __construct(
|
||||
public readonly int $bookingNumber,
|
||||
public readonly int $bookingId,
|
||||
public readonly int $addressId,
|
||||
public readonly int $personId,
|
||||
public readonly string $email,
|
||||
public readonly ?string $firstName = null,
|
||||
public readonly ?string $lastName = null,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -768,6 +768,11 @@ class ApiClient
|
||||
try {
|
||||
$this->xmlDump->write($requestId.'_'.$type.'.xml', $body);
|
||||
} catch (FilesystemException $e) {
|
||||
$this->logger->warning('Could not write BPN XML dump', [
|
||||
'request_id' => $requestId,
|
||||
'type' => $type,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Repository\UserRepository;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:cleanup:guest-invite-accounts',
|
||||
description: 'Removes expired unactivated guest accounts',
|
||||
)]
|
||||
class CleanupGuestInviteAccountsCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserRepository $userRepository,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$threshold = new \DateTimeImmutable();
|
||||
|
||||
$deletedCount = $this->userRepository->deleteExpiredDormantGuestInvites($threshold);
|
||||
|
||||
if (0 === $deletedCount) {
|
||||
$io->success('No expired dormant guest accounts found.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$this->logger->info('Deleted expired dormant guest accounts', [
|
||||
'count' => $deletedCount,
|
||||
'threshold' => $threshold->format(\DateTimeInterface::ATOM),
|
||||
]);
|
||||
|
||||
$io->success(sprintf('Deleted %d expired dormant guest account(s).', $deletedCount));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Account;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Exception\GuestAccountActivationExpiredException;
|
||||
use App\Service\BookingGuestAccountManager;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
class GuestAccountActivationController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly BookingGuestAccountManager $guestAccountManager,
|
||||
private readonly Security $security,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/welcome/activate', name: 'app_guest_account_activate', methods: ['GET'])]
|
||||
#[IsGranted('PUBLIC_ACCESS')]
|
||||
public function activate(Request $request): Response
|
||||
{
|
||||
try {
|
||||
$user = $this->guestAccountManager->validateActivationRequest($request);
|
||||
} catch (GuestAccountActivationExpiredException) {
|
||||
return $this->render('account/guest_account_activation_expired.html.twig', [
|
||||
'resendForm' => $this->createResendForm($request->getUri()),
|
||||
]);
|
||||
}
|
||||
|
||||
$session = $request->getSession();
|
||||
$session->set('_security.main.target_path', $this->generateUrl('app_personal_data'));
|
||||
|
||||
/** @var User $user */
|
||||
$this->security->login($user, null, 'main');
|
||||
$session->set(PersonalDataController::SESSION_FORCE_PASSWORD_CHANGE_KEY, true);
|
||||
|
||||
return $this->redirectToRoute('app_personal_data');
|
||||
}
|
||||
|
||||
#[Route('/welcome/activate/resend', name: 'app_guest_account_activate_resend', methods: ['POST'])]
|
||||
#[IsGranted('PUBLIC_ACCESS')]
|
||||
public function resend(Request $request): Response
|
||||
{
|
||||
$form = $this->createResendForm();
|
||||
$form->handleRequest($request);
|
||||
|
||||
if (false === $form->isSubmitted() || false === $form->isValid()) {
|
||||
return $this->render('account/guest_account_activation_expired.html.twig', [
|
||||
'resendForm' => $form,
|
||||
]);
|
||||
}
|
||||
|
||||
$activationUrl = (string) $form->get('activationUrl')->getData();
|
||||
$user = $this->guestAccountManager->validateActivationUrlForResend($activationUrl);
|
||||
|
||||
try {
|
||||
$this->guestAccountManager->resendWelcomeEmail($user);
|
||||
} catch (\Throwable) {
|
||||
$this->addFlash('error', 'Die Willkommens-E-Mail konnte gerade nicht erneut gesendet werden. Bitte versuche es später noch einmal.');
|
||||
|
||||
return $this->render('account/guest_account_activation_expired.html.twig', [
|
||||
'resendForm' => $form,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->addFlash('success', 'Wir haben dir die Willkommens-E-Mail erneut gesendet.');
|
||||
|
||||
return $this->redirectToRoute('app_login');
|
||||
}
|
||||
|
||||
private function createResendForm(string $activationUrl = ''): \Symfony\Component\Form\FormInterface
|
||||
{
|
||||
return $this->createFormBuilder(null, [
|
||||
'action' => $this->generateUrl('app_guest_account_activate_resend'),
|
||||
])->add('activationUrl', HiddenType::class, [
|
||||
'data' => $activationUrl,
|
||||
])->getForm();
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,7 @@ class PersonalDataController extends AbstractController
|
||||
use HxTrait;
|
||||
|
||||
public const string SESSION_REDIRECT_KEY = '_profile_completion_redirect';
|
||||
public const string SESSION_FORCE_PASSWORD_CHANGE_KEY = '_force_password_change';
|
||||
|
||||
public function __construct(
|
||||
private readonly ApiClient $apiClient,
|
||||
@@ -68,6 +69,8 @@ class PersonalDataController extends AbstractController
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
$forcePasswordChange = $request->hasSession()
|
||||
&& true === $request->getSession()->get(self::SESSION_FORCE_PASSWORD_CHANGE_KEY, false);
|
||||
$email = $user->getEmail();
|
||||
$personalData = $this->loadPersonalData($user);
|
||||
$newsletterSubscribed = $this->newsletterManager->hasConfirmedOptIn($email);
|
||||
@@ -75,6 +78,8 @@ class PersonalDataController extends AbstractController
|
||||
|
||||
$personalDataForm = $this->createForm(AccountPersonalDataType::class, $personalData, [
|
||||
'attr' => ['novalidate' => 'novalidate'],
|
||||
'require_current_password' => false === $forcePasswordChange,
|
||||
'force_password_change' => $forcePasswordChange,
|
||||
]);
|
||||
$personalDataForm->handleRequest($request);
|
||||
|
||||
@@ -86,6 +91,9 @@ class PersonalDataController extends AbstractController
|
||||
if ('' !== $newPassword) {
|
||||
$user->setPassword($this->passwordHasher->hashPassword($user, $newPassword));
|
||||
}
|
||||
if ('' !== $newPassword && true === $forcePasswordChange) {
|
||||
$user->markWelcomeActivated();
|
||||
}
|
||||
|
||||
// Invalidate cached bookings to ensure edit mode shows updated applicant data
|
||||
$this->dataLoader->invalidateUserBookingCaches($user);
|
||||
@@ -95,17 +103,21 @@ class PersonalDataController extends AbstractController
|
||||
$user->setProfileComplete($isComplete);
|
||||
$this->entityManager->flush();
|
||||
|
||||
if ('' !== $newPassword && true === $forcePasswordChange && true === $request->hasSession()) {
|
||||
$request->getSession()->remove(self::SESSION_FORCE_PASSWORD_CHANGE_KEY);
|
||||
}
|
||||
|
||||
$this->addFlash('success', 'Deine persönlichen Daten wurden aktualisiert');
|
||||
$this->logger->info('Updated personal data', [
|
||||
'email' => $user->getEmail(),
|
||||
]);
|
||||
|
||||
// Handle profile completion redirect
|
||||
$session = $request->getSession();
|
||||
$redirectUrl = $session->get(self::SESSION_REDIRECT_KEY);
|
||||
$session = true === $request->hasSession() ? $request->getSession() : null;
|
||||
$redirectUrl = $session?->get(self::SESSION_REDIRECT_KEY);
|
||||
|
||||
if (null !== $redirectUrl && true === $isComplete) {
|
||||
$session->remove(self::SESSION_REDIRECT_KEY);
|
||||
$session?->remove(self::SESSION_REDIRECT_KEY);
|
||||
|
||||
return $this->redirect($redirectUrl);
|
||||
}
|
||||
@@ -121,6 +133,7 @@ class PersonalDataController extends AbstractController
|
||||
'personalDataForm' => $personalDataForm,
|
||||
'newsletterSubscribed' => $newsletterSubscribed,
|
||||
'newsletterPendingConfirmation' => $newsletterPendingConfirmation,
|
||||
'forcePasswordChange' => $forcePasswordChange,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Account;
|
||||
|
||||
use App\Service\BookingGuestAccountManager;
|
||||
use App\Service\PublicFormRateLimiter;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
class PortalAccessRequestController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly BookingGuestAccountManager $guestAccountManager,
|
||||
private readonly PublicFormRateLimiter $publicFormRateLimiter,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/portal-access/request', name: 'app_portal_access_request', methods: ['POST'])]
|
||||
#[IsGranted('PUBLIC_ACCESS')]
|
||||
public function request(Request $request): Response
|
||||
{
|
||||
if (false === $this->isCsrfTokenValid('portal_access_request', $request->request->getString('_token'))) {
|
||||
$this->addFlash('error', 'Deine Anfrage konnte gerade nicht verarbeitet werden. Bitte versuche es erneut.');
|
||||
|
||||
return $this->redirectToRoute('app_login');
|
||||
}
|
||||
|
||||
$email = trim($request->request->getString('email'));
|
||||
$bookingNumber = $request->request->getInt('bookingNumber');
|
||||
|
||||
if ('' === $email || false === filter_var($email, FILTER_VALIDATE_EMAIL) || 0 >= $bookingNumber) {
|
||||
$this->addFlash('error', 'Bitte prüfe deine Eingaben.');
|
||||
|
||||
return $this->redirectToRoute('app_login');
|
||||
}
|
||||
|
||||
if (false === $this->publicFormRateLimiter->isPortalAccessAllowed($email, $request)) {
|
||||
$this->addFlash('error', PublicFormRateLimiter::THROTTLED_MESSAGE);
|
||||
|
||||
return $this->render('security/login.html.twig', [
|
||||
'last_username' => '',
|
||||
'error' => null,
|
||||
], new Response('', Response::HTTP_TOO_MANY_REQUESTS));
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->guestAccountManager->requestPortalAccess($email, $bookingNumber);
|
||||
} catch (\Throwable $exception) {
|
||||
$this->logger->warning('Portal access request failed unexpectedly', [
|
||||
'email' => mb_strtolower($email),
|
||||
'booking_number' => $bookingNumber,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
$result = BookingGuestAccountManager::PORTAL_ACCESS_NOT_SENT;
|
||||
}
|
||||
|
||||
if (BookingGuestAccountManager::PORTAL_ACCESS_ALREADY_ACTIVE === $result) {
|
||||
$this->addFlash('info', 'Für diese E-Mail-Adresse besteht bereits ein aktiver Portalzugang. Bitte logge dich ein oder nutze „Passwort vergessen“.');
|
||||
|
||||
return $this->redirectToRoute('app_login');
|
||||
}
|
||||
|
||||
$this->addFlash('info', 'Wenn zu deiner Buchung ein Portalzugang möglich ist, haben wir dir eine E-Mail gesendet.');
|
||||
|
||||
return $this->redirectToRoute('app_login');
|
||||
}
|
||||
}
|
||||
@@ -15,9 +15,11 @@ use App\Exception\TravelNotFoundException;
|
||||
use App\Form\BookingCreateStep4Type;
|
||||
use App\Form\Model\BookingDto;
|
||||
use App\Form\Model\ParticipantDto;
|
||||
use App\Repository\UserRepository;
|
||||
use App\Htmx\HxTrait;
|
||||
use App\Service\BookingConfigurator;
|
||||
use App\Service\BookingCreateContextFactory;
|
||||
use App\Service\BookingGuestAccountManager;
|
||||
use App\Service\BookingSessionManager;
|
||||
use App\Service\NewsletterManager;
|
||||
use Psr\Log\LoggerInterface;
|
||||
@@ -38,9 +40,11 @@ class Step4Controller extends AbstractBookingCreateController
|
||||
private readonly BookingConfigurator $bookingService,
|
||||
private readonly BookingSessionManager $bookingSessionService,
|
||||
private readonly BookingCreateContextFactory $createContextFactory,
|
||||
private readonly BookingGuestAccountManager $guestAccountManager,
|
||||
private readonly ApiClient $apiClient,
|
||||
private readonly CacheInterface $cache,
|
||||
private readonly NewsletterManager $doubleOptInService,
|
||||
private readonly UserRepository $userRepository,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
@@ -64,10 +68,13 @@ class Step4Controller extends AbstractBookingCreateController
|
||||
|
||||
$newsletterTargetEmail = $this->resolveNewsletterTargetEmail($bookingCreateDto);
|
||||
$newsletterOptInVisible = null !== $newsletterTargetEmail;
|
||||
$guestAccountTargetEmail = $this->resolveGuestAccountTargetEmail($bookingCreateDto);
|
||||
$guestAccountOptInVisible = null !== $guestAccountTargetEmail;
|
||||
|
||||
$form = $this->createForm(BookingCreateStep4Type::class, $bookingCreateDto, [
|
||||
'show_newsletter_opt_in' => $newsletterOptInVisible,
|
||||
'newsletter_target_email' => $newsletterTargetEmail,
|
||||
'show_guest_account_opt_in' => $guestAccountOptInVisible,
|
||||
]);
|
||||
$form->handleRequest($request);
|
||||
|
||||
@@ -125,6 +132,27 @@ class Step4Controller extends AbstractBookingCreateController
|
||||
}
|
||||
}
|
||||
|
||||
$guestAccountOptInSelected = $guestAccountOptInVisible
|
||||
&& $form->has('guestAccountOptIn')
|
||||
&& true === $form->get('guestAccountOptIn')->getData();
|
||||
|
||||
if (true === $guestAccountOptInSelected && null !== $guestAccountTargetEmail) {
|
||||
try {
|
||||
$targetParticipant = $this->resolveGuestAccountTargetParticipant($bookingCreateDto);
|
||||
$this->guestAccountManager->provisionGuestAccountForBooking(
|
||||
$guestAccountTargetEmail,
|
||||
(int) $bookingResponse->bookingNumber,
|
||||
$targetParticipant?->firstName,
|
||||
$targetParticipant?->lastName,
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->warning('Guest account creation failed after booking', [
|
||||
'email' => $guestAccountTargetEmail,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Success: Store booking data in flash for conversion tracking
|
||||
$bookingCreateContext = $this->createContextFactory->createWithParticipantPrices($bookingCreateDto);
|
||||
$this->addFlash('booking_number', $bookingResponse->bookingNumber);
|
||||
@@ -224,6 +252,36 @@ class Step4Controller extends AbstractBookingCreateController
|
||||
return $participant;
|
||||
}
|
||||
|
||||
protected function resolveGuestAccountTargetEmail(BookingDto $bookingDto): ?string
|
||||
{
|
||||
if (null !== $this->getUser()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$participant = $bookingDto->participants[0] ?? null;
|
||||
$email = $participant?->email;
|
||||
|
||||
if (null === $email || '' === trim($email)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalizedEmail = mb_strtolower(trim($email));
|
||||
if (false === filter_var($normalizedEmail, FILTER_VALIDATE_EMAIL)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (null !== $this->userRepository->findOneByNormalizedEmail($normalizedEmail)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $normalizedEmail;
|
||||
}
|
||||
|
||||
private function resolveGuestAccountTargetParticipant(BookingDto $bookingDto): ?ParticipantDto
|
||||
{
|
||||
return $this->resolveNewsletterTargetParticipant($bookingDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears travel data and availability cache after successful booking.
|
||||
*/
|
||||
|
||||
@@ -51,6 +51,15 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
#[ORM\Column(type: 'string', length: 64, nullable: true)]
|
||||
private ?string $passwordResetNonce = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 64, nullable: true)]
|
||||
private ?string $welcomeActivationNonce = null;
|
||||
|
||||
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
|
||||
private ?\DateTimeImmutable $welcomeActivationExpiresAt = null;
|
||||
|
||||
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
|
||||
private ?\DateTimeImmutable $welcomeActivatedAt = null;
|
||||
|
||||
public function __construct(string $email)
|
||||
{
|
||||
$this->email = self::normalizeEmail($email);
|
||||
@@ -202,6 +211,78 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getWelcomeActivationNonce(): ?string
|
||||
{
|
||||
return $this->welcomeActivationNonce;
|
||||
}
|
||||
|
||||
public function setWelcomeActivationNonce(?string $welcomeActivationNonce): static
|
||||
{
|
||||
$this->welcomeActivationNonce = $welcomeActivationNonce;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getWelcomeActivationExpiresAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->welcomeActivationExpiresAt;
|
||||
}
|
||||
|
||||
public function setWelcomeActivationExpiresAt(?\DateTimeImmutable $welcomeActivationExpiresAt): static
|
||||
{
|
||||
$this->welcomeActivationExpiresAt = $welcomeActivationExpiresAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getWelcomeActivatedAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->welcomeActivatedAt;
|
||||
}
|
||||
|
||||
public function setWelcomeActivatedAt(?\DateTimeImmutable $welcomeActivatedAt): static
|
||||
{
|
||||
$this->welcomeActivatedAt = $welcomeActivatedAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isWelcomeActivationPending(): bool
|
||||
{
|
||||
if (null !== $this->welcomeActivatedAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (null === $this->welcomeActivationNonce || null === $this->welcomeActivationExpiresAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->welcomeActivationExpiresAt > new \DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function hasPendingWelcomeInvite(): bool
|
||||
{
|
||||
return null === $this->welcomeActivatedAt && null !== $this->welcomeActivationNonce;
|
||||
}
|
||||
|
||||
public function markWelcomeActivationPending(string $nonce, \DateTimeImmutable $expiresAt): static
|
||||
{
|
||||
$this->welcomeActivationNonce = $nonce;
|
||||
$this->welcomeActivationExpiresAt = $expiresAt;
|
||||
$this->welcomeActivatedAt = null;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function markWelcomeActivated(?\DateTimeImmutable $now = null): static
|
||||
{
|
||||
$this->welcomeActivatedAt = $now ?? new \DateTimeImmutable();
|
||||
$this->welcomeActivationNonce = null;
|
||||
$this->welcomeActivationExpiresAt = null;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function eraseCredentials(): void
|
||||
{
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\EventListener;
|
||||
|
||||
use App\Controller\Account\PersonalDataController;
|
||||
use App\Entity\User;
|
||||
use App\Htmx\HxRedirectResponse;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
|
||||
class ForcePasswordChangeRedirectListener implements EventSubscriberInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Security $security,
|
||||
private readonly UrlGeneratorInterface $urlGenerator,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
KernelEvents::REQUEST => ['onKernelRequest', 0],
|
||||
];
|
||||
}
|
||||
|
||||
public function onKernelRequest(RequestEvent $event): void
|
||||
{
|
||||
if (false === $event->isMainRequest()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$request = $event->getRequest();
|
||||
if (false === $request->hasSession()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$session = $request->getSession();
|
||||
if (false === $session->get(PersonalDataController::SESSION_FORCE_PASSWORD_CHANGE_KEY, false)) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var User|null $user */
|
||||
$user = $this->security->getUser();
|
||||
if (false === $user instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
$route = (string) $request->attributes->get('_route', '');
|
||||
if (in_array($route, ['app_personal_data', 'app_logout'], true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$targetUrl = $this->urlGenerator->generate('app_personal_data');
|
||||
if (true === $request->headers->has('HX-Request')) {
|
||||
$event->setResponse(new HxRedirectResponse($targetUrl));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$event->setResponse(new RedirectResponse($targetUrl, Response::HTTP_FOUND));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exception;
|
||||
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
class GuestAccountActivationExpiredException extends NotFoundHttpException
|
||||
{
|
||||
}
|
||||
@@ -26,24 +26,6 @@ class AccountPersonalDataType extends AbstractType
|
||||
'label' => false,
|
||||
'inherit_data' => true,
|
||||
])
|
||||
->add('currentPassword', PasswordType::class, [
|
||||
'label' => 'Aktuelles Passwort',
|
||||
'mapped' => false,
|
||||
'required' => false,
|
||||
'attr' => [
|
||||
'autocomplete' => 'current-password',
|
||||
],
|
||||
'constraints' => [
|
||||
new Sequentially([
|
||||
new NotBlank([
|
||||
'message' => 'Bitte angeben',
|
||||
]),
|
||||
new UserPassword([
|
||||
'message' => 'Das aktuelle Passwort ist nicht korrekt.',
|
||||
]),
|
||||
], groups: ['password_change']),
|
||||
],
|
||||
])
|
||||
->add('newPassword', RepeatedType::class, [
|
||||
'type' => PasswordType::class,
|
||||
'mapped' => false,
|
||||
@@ -82,27 +64,53 @@ class AccountPersonalDataType extends AbstractType
|
||||
],
|
||||
])
|
||||
;
|
||||
|
||||
if (true === $options['require_current_password']) {
|
||||
$builder->add('currentPassword', PasswordType::class, [
|
||||
'label' => 'Aktuelles Passwort',
|
||||
'mapped' => false,
|
||||
'required' => false,
|
||||
'attr' => [
|
||||
'autocomplete' => 'current-password',
|
||||
],
|
||||
'constraints' => [
|
||||
new Sequentially([
|
||||
new NotBlank([
|
||||
'message' => 'Bitte angeben',
|
||||
]),
|
||||
new UserPassword([
|
||||
'message' => 'Das aktuelle Passwort ist nicht korrekt.',
|
||||
]),
|
||||
], groups: ['password_change']),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => PersonalData::class,
|
||||
'require_current_password' => true,
|
||||
'force_password_change' => false,
|
||||
'validation_groups' => function (FormInterface $form): array {
|
||||
$groups = ['Default', 'personal_data'];
|
||||
|
||||
if ($this->isPasswordSectionUsed($form)) {
|
||||
if (true === $form->getConfig()->getOption('force_password_change') || $this->isPasswordSectionUsed($form)) {
|
||||
$groups[] = 'password_change';
|
||||
}
|
||||
|
||||
return $groups;
|
||||
},
|
||||
]);
|
||||
|
||||
$resolver->setAllowedTypes('require_current_password', 'bool');
|
||||
$resolver->setAllowedTypes('force_password_change', 'bool');
|
||||
}
|
||||
|
||||
private function isPasswordSectionUsed(FormInterface $form): bool
|
||||
{
|
||||
return '' !== (string) $form->get('currentPassword')->getData()
|
||||
return (true === $form->has('currentPassword') && '' !== (string) $form->get('currentPassword')->getData())
|
||||
|| '' !== (string) $form->get('newPassword')->getData();
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\BpnConnect\BpnConnectClient;
|
||||
use App\BpnConnect\Exception\BpnConnectException;
|
||||
use App\BpnConnect\Model\BookingLookupResult;
|
||||
use App\Email\Mailer;
|
||||
use App\Entity\User;
|
||||
use App\Exception\GuestAccountActivationExpiredException;
|
||||
use App\Repository\UserRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\UriSigner;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
use function Symfony\Component\String\u;
|
||||
|
||||
class BookingGuestAccountManager
|
||||
{
|
||||
public const string PORTAL_ACCESS_SENT = 'sent';
|
||||
public const string PORTAL_ACCESS_ALREADY_ACTIVE = 'already_active';
|
||||
public const string PORTAL_ACCESS_NOT_SENT = 'not_sent';
|
||||
|
||||
private const int ACTIVATION_TTL_SECONDS = 3600;
|
||||
private const string WELCOME_EMAIL_SUBJECT = 'Dein MyE&P Konto ist bereit';
|
||||
private const string WELCOME_EMAIL_TEMPLATE = 'email/booking_guest_account_welcome.html.twig';
|
||||
|
||||
public function __construct(
|
||||
private readonly BpnConnectClient $bpnConnectClient,
|
||||
private readonly UserRepository $userRepository,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly UserBootstrapper $userBootstrapper,
|
||||
private readonly UserPasswordHasherInterface $passwordHasher,
|
||||
private readonly UrlGeneratorInterface $urlGenerator,
|
||||
private readonly UriSigner $uriSigner,
|
||||
private readonly Mailer $mailer,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
public function provisionGuestAccount(
|
||||
string $email,
|
||||
?string $firstName = null,
|
||||
?string $lastName = null,
|
||||
?int $addressId = null,
|
||||
?int $personId = null,
|
||||
): bool
|
||||
{
|
||||
$normalizedEmail = $this->normalizeEmail($email);
|
||||
|
||||
if ('' === $normalizedEmail || false === filter_var($normalizedEmail, FILTER_VALIDATE_EMAIL)) {
|
||||
throw new \InvalidArgumentException('Invalid email for guest account provisioning.');
|
||||
}
|
||||
|
||||
if (null !== $this->userRepository->findOneByNormalizedEmail($normalizedEmail)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$user = new User($normalizedEmail);
|
||||
$plainPassword = $this->generateInitialPassword();
|
||||
$passwordHash = $this->passwordHasher->hashPassword($user, $plainPassword);
|
||||
$activationNonce = $this->generateNonce();
|
||||
$activationExpiresAt = $this->createActivationExpiry();
|
||||
|
||||
$this->userBootstrapper->bootstrap(
|
||||
$user,
|
||||
$passwordHash,
|
||||
$addressId,
|
||||
$personId,
|
||||
['ROLE_CUSTOMER'],
|
||||
[],
|
||||
true,
|
||||
);
|
||||
$user->markWelcomeActivationPending($activationNonce, $activationExpiresAt);
|
||||
|
||||
$this->entityManager->persist($user);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->sendWelcomeEmail(
|
||||
$normalizedEmail,
|
||||
$this->buildActivationUrl($normalizedEmail, $activationNonce, $activationExpiresAt),
|
||||
$firstName,
|
||||
$lastName,
|
||||
);
|
||||
|
||||
$this->logger->info('Created guest account after booking', [
|
||||
'email' => $normalizedEmail,
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function provisionGuestAccountForBooking(
|
||||
string $email,
|
||||
int $bookingNumber,
|
||||
?string $firstName = null,
|
||||
?string $lastName = null,
|
||||
): bool {
|
||||
$normalizedEmail = $this->normalizeEmail($email);
|
||||
|
||||
if ('' === $normalizedEmail || false === filter_var($normalizedEmail, FILTER_VALIDATE_EMAIL) || 0 >= $bookingNumber) {
|
||||
throw new \InvalidArgumentException('Invalid booking guest account provisioning input.');
|
||||
}
|
||||
|
||||
$booking = $this->bpnConnectClient->lookupBooking($normalizedEmail, $bookingNumber);
|
||||
if (null === $booking || false === $this->bookingMatches($booking, $normalizedEmail, $bookingNumber)) {
|
||||
throw new BpnConnectException('Could not resolve matching booking details for guest account provisioning.');
|
||||
}
|
||||
|
||||
return $this->provisionGuestAccount(
|
||||
$normalizedEmail,
|
||||
$firstName ?? $booking->firstName,
|
||||
$lastName ?? $booking->lastName,
|
||||
$booking->addressId,
|
||||
$booking->personId,
|
||||
);
|
||||
}
|
||||
|
||||
public function requestPortalAccess(string $email, int $bookingNumber): string
|
||||
{
|
||||
$normalizedEmail = $this->normalizeEmail($email);
|
||||
|
||||
if ('' === $normalizedEmail || false === filter_var($normalizedEmail, FILTER_VALIDATE_EMAIL) || 0 >= $bookingNumber) {
|
||||
$this->logger->warning('Rejected portal access request with invalid input', [
|
||||
'email' => $normalizedEmail,
|
||||
'booking_number' => $bookingNumber,
|
||||
]);
|
||||
|
||||
return self::PORTAL_ACCESS_NOT_SENT;
|
||||
}
|
||||
|
||||
$user = $this->userRepository->findOneByNormalizedEmail($normalizedEmail);
|
||||
|
||||
try {
|
||||
$booking = $this->bpnConnectClient->lookupBooking($normalizedEmail, $bookingNumber);
|
||||
} catch (BpnConnectException $exception) {
|
||||
$this->logger->warning('Could not verify portal access booking against BPN Connect', [
|
||||
'email' => $normalizedEmail,
|
||||
'booking_number' => $bookingNumber,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
return self::PORTAL_ACCESS_NOT_SENT;
|
||||
}
|
||||
|
||||
if (null === $booking) {
|
||||
$this->logger->warning('Portal access booking lookup returned no result', [
|
||||
'email' => $normalizedEmail,
|
||||
'booking_number' => $bookingNumber,
|
||||
]);
|
||||
|
||||
return self::PORTAL_ACCESS_NOT_SENT;
|
||||
}
|
||||
|
||||
if (false === $this->bookingMatches($booking, $normalizedEmail, $bookingNumber)) {
|
||||
$this->logger->warning('Portal access booking verification failed', [
|
||||
'email' => $normalizedEmail,
|
||||
'booking_number' => $bookingNumber,
|
||||
'booking_email' => $booking->email,
|
||||
'booking_response_number' => $booking->bookingNumber,
|
||||
]);
|
||||
|
||||
return self::PORTAL_ACCESS_NOT_SENT;
|
||||
}
|
||||
|
||||
try {
|
||||
if (null === $user) {
|
||||
$this->provisionGuestAccount(
|
||||
$normalizedEmail,
|
||||
$booking->firstName,
|
||||
$booking->lastName,
|
||||
$booking->addressId,
|
||||
$booking->personId,
|
||||
);
|
||||
} elseif (false === $user->hasPendingWelcomeInvite()) {
|
||||
$this->logger->info('Portal access requested for already activated account', [
|
||||
'email' => $normalizedEmail,
|
||||
'booking_number' => $bookingNumber,
|
||||
]);
|
||||
|
||||
return self::PORTAL_ACCESS_ALREADY_ACTIVE;
|
||||
} else {
|
||||
$this->resendWelcomeEmail($user, $booking->firstName, $booking->lastName);
|
||||
}
|
||||
} catch (\Throwable $exception) {
|
||||
$this->logger->error('Portal access request could not send welcome email', [
|
||||
'email' => $normalizedEmail,
|
||||
'booking_number' => $bookingNumber,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
return self::PORTAL_ACCESS_NOT_SENT;
|
||||
}
|
||||
|
||||
return self::PORTAL_ACCESS_SENT;
|
||||
}
|
||||
|
||||
public function validateActivationRequest(Request $request): User
|
||||
{
|
||||
return $this->resolveActivationRequest($request, false);
|
||||
}
|
||||
|
||||
public function validateActivationRequestForResend(Request $request): User
|
||||
{
|
||||
return $this->resolveActivationRequest($request, true);
|
||||
}
|
||||
|
||||
public function validateActivationUrlForResend(string $activationUrl): User
|
||||
{
|
||||
return $this->resolveActivationRequest(Request::create($activationUrl, 'GET'), true);
|
||||
}
|
||||
|
||||
public function resendWelcomeEmail(User $user, ?string $firstName = null, ?string $lastName = null): void
|
||||
{
|
||||
$previousNonce = $user->getWelcomeActivationNonce();
|
||||
$previousExpiresAt = $user->getWelcomeActivationExpiresAt();
|
||||
$newNonce = $this->generateNonce();
|
||||
$normalizedEmail = $this->normalizeEmail($user->getEmail());
|
||||
$newExpiresAt = $this->createActivationExpiry();
|
||||
|
||||
$user->markWelcomeActivationPending($newNonce, $newExpiresAt);
|
||||
$this->entityManager->flush();
|
||||
|
||||
try {
|
||||
$this->sendWelcomeEmail(
|
||||
$normalizedEmail,
|
||||
$this->buildActivationUrl($normalizedEmail, $newNonce, $newExpiresAt),
|
||||
$firstName,
|
||||
$lastName,
|
||||
);
|
||||
} catch (\Throwable $exception) {
|
||||
$user->setWelcomeActivationNonce($previousNonce);
|
||||
$user->setWelcomeActivationExpiresAt($previousExpiresAt);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->logger->error('Could not resend guest account welcome email', [
|
||||
'email' => $normalizedEmail,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
$this->logger->info('Resent guest account welcome email', [
|
||||
'email' => $normalizedEmail,
|
||||
]);
|
||||
}
|
||||
|
||||
private function resolveActivationRequest(Request $request, bool $allowExpired): User
|
||||
{
|
||||
if (false === $this->uriSigner->checkRequest($request)) {
|
||||
throw new NotFoundHttpException();
|
||||
}
|
||||
|
||||
$expires = $request->query->getInt('expires');
|
||||
if (0 >= $expires || $expires < time()) {
|
||||
if (false === $allowExpired) {
|
||||
throw new GuestAccountActivationExpiredException();
|
||||
}
|
||||
}
|
||||
|
||||
$email = $this->normalizeEmail($request->query->getString('email'));
|
||||
$nonce = trim($request->query->getString('nonce'));
|
||||
|
||||
if ('' === $email || '' === $nonce) {
|
||||
throw new NotFoundHttpException();
|
||||
}
|
||||
|
||||
$user = $this->userRepository->findOneByNormalizedEmail($email);
|
||||
if (null === $user || null === $user->getWelcomeActivationNonce() || false === hash_equals($user->getWelcomeActivationNonce(), $nonce)) {
|
||||
throw new NotFoundHttpException();
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
public function consumeActivation(User $user): void
|
||||
{
|
||||
$user->markWelcomeActivated();
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
|
||||
private function buildActivationUrl(string $normalizedEmail, string $nonce, \DateTimeImmutable $expiresAt): string
|
||||
{
|
||||
$url = $this->urlGenerator->generate('app_guest_account_activate', [
|
||||
'email' => $normalizedEmail,
|
||||
'nonce' => $nonce,
|
||||
'expires' => $expiresAt->getTimestamp(),
|
||||
], UrlGeneratorInterface::ABSOLUTE_URL);
|
||||
|
||||
return $this->uriSigner->sign($url);
|
||||
}
|
||||
|
||||
private function createActivationExpiry(): \DateTimeImmutable
|
||||
{
|
||||
return new \DateTimeImmutable(sprintf('+%d seconds', self::ACTIVATION_TTL_SECONDS));
|
||||
}
|
||||
|
||||
private function bookingMatches(BookingLookupResult $booking, string $normalizedEmail, int $bookingNumber): bool
|
||||
{
|
||||
return $bookingNumber === $booking->bookingNumber
|
||||
&& $normalizedEmail === $this->normalizeEmail($booking->email);
|
||||
}
|
||||
|
||||
private function sendWelcomeEmail(string $normalizedEmail, string $activationUrl, ?string $firstName, ?string $lastName): void
|
||||
{
|
||||
$this->mailer->createAndSendEmail(
|
||||
[
|
||||
'activationUrl' => $activationUrl,
|
||||
'firstName' => $this->normalizeName($firstName),
|
||||
'lastName' => $this->normalizeName($lastName),
|
||||
],
|
||||
[
|
||||
'to' => $normalizedEmail,
|
||||
'subject' => self::WELCOME_EMAIL_SUBJECT,
|
||||
'template' => self::WELCOME_EMAIL_TEMPLATE,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
private function normalizeEmail(string $email): string
|
||||
{
|
||||
return u($email)->trim()->lower()->toString();
|
||||
}
|
||||
|
||||
private function normalizeName(?string $value): ?string
|
||||
{
|
||||
if (null === $value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$trimmed = trim($value);
|
||||
|
||||
return '' === $trimmed ? null : $trimmed;
|
||||
}
|
||||
|
||||
private function generateNonce(): string
|
||||
{
|
||||
return bin2hex(random_bytes(32));
|
||||
}
|
||||
|
||||
private function generateInitialPassword(): string
|
||||
{
|
||||
return bin2hex(random_bytes(24));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ class PublicFormRateLimiter
|
||||
public function __construct(
|
||||
private readonly RateLimiterFactory $registrationLimiter,
|
||||
private readonly RateLimiterFactory $passwordResetLimiter,
|
||||
private readonly RateLimiterFactory $portalAccessRequestLimiter,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -27,6 +28,11 @@ class PublicFormRateLimiter
|
||||
return $this->consume($this->passwordResetLimiter, $email, $request);
|
||||
}
|
||||
|
||||
public function isPortalAccessAllowed(string $email, Request $request): bool
|
||||
{
|
||||
return $this->consume($this->portalAccessRequestLimiter, $email, $request);
|
||||
}
|
||||
|
||||
public function createBucketKey(string $email, ?string $clientIp): string
|
||||
{
|
||||
return hash('sha256', $this->normalizeEmail($email).'|'.($clientIp ?? 'unknown'));
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
{% extends 'layout.html.twig' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 px-4 lg:px-8 py-8 lg:py-16">
|
||||
<div>
|
||||
<h1 class="text-white uppercase pb-4 mb-8 border-b border-primary-bg/40">
|
||||
Link<br>
|
||||
abgelaufen
|
||||
</h1>
|
||||
{% include '_partials/_flashes.html.twig' %}
|
||||
<div class="pb-4">
|
||||
<p class="mb-4 text-white">
|
||||
Der Aktivierungslink ist leider abgelaufen. Du kannst dir die Willkommens-E-Mail noch einmal
|
||||
zusenden lassen.
|
||||
</p>
|
||||
{{ form_start(resendForm) }}
|
||||
<button type="submit" class="button button--primary">
|
||||
Willkommens-E-Mail erneut senden
|
||||
</button>
|
||||
{{ form_rest(resendForm) }}
|
||||
{{ form_end(resendForm) }}
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<a href="{{ path('app_login') }}" class="text-white underline">
|
||||
Zum Login
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -15,6 +15,14 @@
|
||||
} %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if forcePasswordChange %}
|
||||
<div class="mx-4 lg:mx-8">
|
||||
{% include '_partials/_alert.html.twig' with {
|
||||
level: 'info',
|
||||
messages: ['Bitte lege zuerst ein neues Passwort fest, um dein Konto zu aktivieren.']
|
||||
} %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="p-4 lg:p-8">
|
||||
<div class="divide-y divide-primary-bg/40">
|
||||
<div class="grid md:grid-cols-2 gap-x-8 gap-y-4 pb-8">
|
||||
@@ -72,7 +80,9 @@
|
||||
<h3 class="text-white">
|
||||
Passwort ändern
|
||||
</h3>
|
||||
{{ form_row(personalDataForm.currentPassword, { 'label_attr': { 'class': 'text-white' } }) }}
|
||||
{% if not forcePasswordChange %}
|
||||
{{ form_row(personalDataForm.currentPassword, { 'label_attr': { 'class': 'text-white' } }) }}
|
||||
{% endif %}
|
||||
{{ form_row(personalDataForm.newPassword.first, { 'label_attr': { 'class': 'text-white' } }) }}
|
||||
{{ form_row(personalDataForm.newPassword.second, { 'label_attr': { 'class': 'text-white' } }) }}
|
||||
</div>
|
||||
|
||||
@@ -486,6 +486,11 @@
|
||||
'label_attr': {'class': 'text-base font-semibold'}
|
||||
}) }}
|
||||
{% endif %}
|
||||
{% if form.guestAccountOptIn is defined %}
|
||||
{{ form_row(form.guestAccountOptIn, {
|
||||
'label_attr': {'class': 'text-base font-semibold'}
|
||||
}) }}
|
||||
{% endif %}
|
||||
{{ form_row(form.confirmationAccepted, {
|
||||
'label_attr': {'class': 'text-base font-semibold'}
|
||||
}) }}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
{% extends 'email/layout.html.twig' %}
|
||||
|
||||
{% block body %}
|
||||
<h1>
|
||||
Dein MyE&P Konto
|
||||
</h1>
|
||||
<p>
|
||||
Für deine Buchung wurde ein MyE&P-Konto angelegt.
|
||||
</p>
|
||||
<p>
|
||||
Über den folgenden Link meldest du dich einmalig an und legst direkt ein neues Passwort fest.
|
||||
</p>
|
||||
<p>
|
||||
<a href="{{ activationUrl }}" class="button">
|
||||
Passwort jetzt festlegen
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
Wenn du diese Buchung nicht erstellt hast, kannst du diese E-Mail ignorieren.
|
||||
</p>
|
||||
{% endblock %}
|
||||
@@ -53,6 +53,48 @@
|
||||
Jetzt registrieren
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="mt-10 pt-8 border-t border-primary-bg/40">
|
||||
<h2 class="text-white uppercase mb-4">
|
||||
Portalzugang anfordern
|
||||
</h2>
|
||||
<p class="text-white mb-4">
|
||||
Du hast noch keinen Portalzugang? Gib die E-Mail-Adresse aus deiner Buchung und deine Buchungsnummer ein.
|
||||
Wenn deine Angaben zur Buchung passen, senden wir dir einen Aktivierungslink an diese E-Mail-Adresse.
|
||||
</p>
|
||||
|
||||
<form action="{{ path('app_portal_access_request') }}" method="post">
|
||||
<div class="mb-4">
|
||||
<label for="portal_access_email" class="mb-1 font-semibold text-white">
|
||||
E-Mail:
|
||||
</label>
|
||||
<input type="email"
|
||||
id="portal_access_email"
|
||||
name="email"
|
||||
class="form-field"
|
||||
required
|
||||
autocomplete="email">
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="portal_access_booking_number" class="mb-1 font-semibold text-white">
|
||||
Buchungsnummer:
|
||||
</label>
|
||||
<input type="number"
|
||||
id="portal_access_booking_number"
|
||||
name="bookingNumber"
|
||||
class="form-field"
|
||||
required
|
||||
min="1"
|
||||
inputmode="numeric">
|
||||
</div>
|
||||
|
||||
<button type="submit" class="button button--primary">
|
||||
Portalzugang anfordern
|
||||
</button>
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('portal_access_request') }}">
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="mb-2 text-white">
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\BpnConnect;
|
||||
|
||||
use App\BpnConnect\BpnConnectClient;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
use Symfony\Component\HttpClient\MockHttpClient;
|
||||
use Symfony\Component\HttpClient\Response\MockResponse;
|
||||
|
||||
class BpnConnectClientTest extends TestCase
|
||||
{
|
||||
public function testLookupBookingMapsResponse(): void
|
||||
{
|
||||
$capturedMethod = null;
|
||||
$capturedUrl = null;
|
||||
$capturedOptions = null;
|
||||
$httpClient = new MockHttpClient(static function (string $method, string $url, array $options) use (&$capturedMethod, &$capturedUrl, &$capturedOptions): MockResponse {
|
||||
$capturedMethod = $method;
|
||||
$capturedUrl = $url;
|
||||
$capturedOptions = $options;
|
||||
|
||||
return new MockResponse(json_encode([
|
||||
'bookingNumber' => 111386,
|
||||
'bookingId' => 98609,
|
||||
'addressId' => 225672,
|
||||
'personId' => 412344,
|
||||
'email' => '[email protected]',
|
||||
'firstName' => 'Vorname 1',
|
||||
'lastName' => 'Mustermann 1',
|
||||
], JSON_THROW_ON_ERROR), [
|
||||
'response_headers' => ['content-type' => 'application/json'],
|
||||
]);
|
||||
}, 'https://bpn-connect.test/');
|
||||
|
||||
$booking = (new BpnConnectClient($httpClient, new NullLogger()))->lookupBooking(' [email protected] ', 111386);
|
||||
|
||||
self::assertSame('GET', $capturedMethod);
|
||||
self::assertSame('https://bpn-connect.test/api/bookings/[email protected]&bookingNumber=111386', $capturedUrl);
|
||||
self::assertSame(111386, $capturedOptions['query']['bookingNumber']);
|
||||
self::assertSame(98609, $booking?->bookingId);
|
||||
self::assertSame(225672, $booking?->addressId);
|
||||
self::assertSame(412344, $booking?->personId);
|
||||
self::assertSame('[email protected]', $booking?->email);
|
||||
self::assertSame('Vorname 1', $booking?->firstName);
|
||||
self::assertSame('Mustermann 1', $booking?->lastName);
|
||||
}
|
||||
|
||||
public function testLookupBookingReturnsNullForNotFound(): void
|
||||
{
|
||||
$httpClient = new MockHttpClient(new MockResponse('', ['http_code' => 404]), 'https://bpn-connect.test/');
|
||||
|
||||
self::assertNull((new BpnConnectClient($httpClient, new NullLogger()))->lookupBooking('[email protected]', 123));
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,22 @@ class BookingResponseParserTest extends TestCase
|
||||
$this->parser = new BookingResponseParser();
|
||||
}
|
||||
|
||||
public function testParseSuccessfulBookingResponse(): void
|
||||
{
|
||||
$xmlContent = '<?xml version="1.0" encoding="utf-8"?>
|
||||
<ergebnis>
|
||||
<satz typ="BUCHUNG" />
|
||||
<buchung>erfolgt</buchung>
|
||||
<vorgang>321530</vorgang>
|
||||
</ergebnis>';
|
||||
|
||||
$crawler = new Crawler($xmlContent);
|
||||
$response = $this->parser->parse($crawler->filter('ergebnis'));
|
||||
|
||||
self::assertTrue($response->isBookingSuccessful());
|
||||
self::assertSame(321530, $response->bookingNumber);
|
||||
}
|
||||
|
||||
public function testParseBookingResponseWithPurchaseVoucher(): void
|
||||
{
|
||||
$xmlContent = '<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Command;
|
||||
|
||||
use App\Command\CleanupGuestInviteAccountsCommand;
|
||||
use App\Repository\UserRepository;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
class CleanupGuestInviteAccountsCommandTest extends TestCase
|
||||
{
|
||||
public function testCommandReportsWhenNothingWasDeleted(): void
|
||||
{
|
||||
$repository = $this->createMock(UserRepository::class);
|
||||
$repository
|
||||
->expects(self::once())
|
||||
->method('deleteExpiredDormantGuestInvites')
|
||||
->with(self::isInstanceOf(\DateTimeImmutable::class))
|
||||
->willReturn(0);
|
||||
|
||||
$logger = $this->createMock(LoggerInterface::class);
|
||||
$logger->expects(self::never())->method('info');
|
||||
|
||||
$tester = new CommandTester(new CleanupGuestInviteAccountsCommand($repository, $logger));
|
||||
$exitCode = $tester->execute([]);
|
||||
|
||||
self::assertSame(0, $exitCode);
|
||||
self::assertStringContainsString('No expired dormant guest accounts found.', $tester->getDisplay());
|
||||
}
|
||||
|
||||
public function testCommandLogsAndReportsDeletionCount(): void
|
||||
{
|
||||
$repository = $this->createMock(UserRepository::class);
|
||||
$repository
|
||||
->expects(self::once())
|
||||
->method('deleteExpiredDormantGuestInvites')
|
||||
->with(self::isInstanceOf(\DateTimeImmutable::class))
|
||||
->willReturn(3);
|
||||
|
||||
$logger = $this->createMock(LoggerInterface::class);
|
||||
$logger
|
||||
->expects(self::once())
|
||||
->method('info')
|
||||
->with(
|
||||
'Deleted expired dormant guest accounts',
|
||||
self::callback(static fn (array $context): bool => 3 === $context['count'])
|
||||
);
|
||||
|
||||
$tester = new CommandTester(new CleanupGuestInviteAccountsCommand($repository, $logger));
|
||||
$exitCode = $tester->execute([]);
|
||||
|
||||
self::assertSame(0, $exitCode);
|
||||
self::assertStringContainsString('Deleted 3 expired dormant guest account(s).', $tester->getDisplay());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Controller\Account;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Exception\GuestAccountActivationExpiredException;
|
||||
use App\Service\BookingGuestAccountManager;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
class GuestAccountActivationControllerTest extends WebTestCase
|
||||
{
|
||||
public function testExpiredActivationLinkShowsResendPageInsteadOf404(): void
|
||||
{
|
||||
$client = self::createClient([], ['HTTPS' => 'on']);
|
||||
$client->disableReboot();
|
||||
|
||||
$guestAccountManager = $this->createMock(BookingGuestAccountManager::class);
|
||||
$guestAccountManager
|
||||
->expects(self::once())
|
||||
->method('validateActivationRequest')
|
||||
->with(self::isInstanceOf(Request::class))
|
||||
->willThrowException(new GuestAccountActivationExpiredException());
|
||||
$guestAccountManager->expects(self::never())->method('consumeActivation');
|
||||
$guestAccountManager->expects(self::never())->method('validateActivationUrlForResend');
|
||||
$guestAccountManager->expects(self::never())->method('resendWelcomeEmail');
|
||||
|
||||
$security = $this->createMock(Security::class);
|
||||
$security->expects(self::never())->method('login');
|
||||
|
||||
self::getContainer()->set(BookingGuestAccountManager::class, $guestAccountManager);
|
||||
self::getContainer()->set(Security::class, $security);
|
||||
|
||||
$client->request('GET', '/welcome/[email protected]&nonce=current-nonce&expires=9999999999&_hash=signature');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertStringContainsString('Link', $client->getResponse()->getContent());
|
||||
self::assertStringContainsString('Willkommens-E-Mail erneut senden', $client->getResponse()->getContent());
|
||||
}
|
||||
|
||||
public function testResendActionResendsWelcomeMailAndRedirectsToLogin(): void
|
||||
{
|
||||
$client = self::createClient([], ['HTTPS' => 'on']);
|
||||
$client->disableReboot();
|
||||
|
||||
$user = new User('[email protected]');
|
||||
$user->setWelcomeActivationNonce('current-nonce');
|
||||
|
||||
$guestAccountManager = $this->createMock(BookingGuestAccountManager::class);
|
||||
$guestAccountManager
|
||||
->expects(self::once())
|
||||
->method('validateActivationRequest')
|
||||
->with(self::isInstanceOf(Request::class))
|
||||
->willThrowException(new GuestAccountActivationExpiredException());
|
||||
$guestAccountManager
|
||||
->expects(self::once())
|
||||
->method('validateActivationUrlForResend')
|
||||
->with(self::callback(static function (string $activationUrl): bool {
|
||||
return str_contains($activationUrl, 'email=customer%40example.com')
|
||||
|| str_contains($activationUrl, '[email protected]');
|
||||
}))
|
||||
->willReturn($user);
|
||||
$guestAccountManager
|
||||
->expects(self::once())
|
||||
->method('resendWelcomeEmail')
|
||||
->with($user);
|
||||
|
||||
$security = $this->createMock(Security::class);
|
||||
$security->expects(self::never())->method('login');
|
||||
|
||||
self::getContainer()->set(BookingGuestAccountManager::class, $guestAccountManager);
|
||||
self::getContainer()->set(Security::class, $security);
|
||||
|
||||
$crawler = $client->request('GET', '/welcome/[email protected]&nonce=current-nonce&expires=9999999999&_hash=signature');
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
$form = $crawler->selectButton('Willkommens-E-Mail erneut senden')->form();
|
||||
$client->submit($form);
|
||||
|
||||
self::assertResponseRedirects('/');
|
||||
$client->followRedirect();
|
||||
|
||||
self::assertStringContainsString('Wir haben dir die Willkommens-E-Mail erneut gesendet.', $client->getResponse()->getContent());
|
||||
}
|
||||
|
||||
public function testTamperedActivationLinkStillReturns404(): void
|
||||
{
|
||||
$client = self::createClient([], ['HTTPS' => 'on']);
|
||||
$client->disableReboot();
|
||||
|
||||
$guestAccountManager = $this->createMock(BookingGuestAccountManager::class);
|
||||
$guestAccountManager
|
||||
->expects(self::once())
|
||||
->method('validateActivationRequest')
|
||||
->with(self::isInstanceOf(Request::class))
|
||||
->willThrowException(new NotFoundHttpException());
|
||||
$guestAccountManager->expects(self::never())->method('consumeActivation');
|
||||
|
||||
$security = $this->createMock(Security::class);
|
||||
$security->expects(self::never())->method('login');
|
||||
|
||||
self::getContainer()->set(BookingGuestAccountManager::class, $guestAccountManager);
|
||||
self::getContainer()->set(Security::class, $security);
|
||||
|
||||
$client->request('GET', '/welcome/[email protected]&nonce=current-nonce&expires=9999999999&_hash=invalid');
|
||||
|
||||
self::assertResponseStatusCodeSame(404);
|
||||
}
|
||||
}
|
||||
@@ -256,6 +256,59 @@ class PersonalDataControllerTest extends TestCase
|
||||
], $controller->flashes);
|
||||
}
|
||||
|
||||
public function testIndexConsumesWelcomeActivationAfterForcedPasswordChangeSucceeds(): void
|
||||
{
|
||||
$personalData = $this->createCompletePersonalData();
|
||||
$controller = $this->createController(true, true, $this->validator(), 'NewPassw0rd!');
|
||||
$controller->user->markWelcomeActivationPending('current-nonce', new \DateTimeImmutable('+30 minutes'));
|
||||
|
||||
$controller->apiClient
|
||||
->expects(self::once())
|
||||
->method('getPersonalData')
|
||||
->with('[email protected]', null, null)
|
||||
->willReturn($personalData);
|
||||
$controller->apiClient
|
||||
->expects(self::once())
|
||||
->method('updatePersonalData')
|
||||
->with('[email protected]', $personalData)
|
||||
->willReturn($personalData);
|
||||
$controller->newsletterManager
|
||||
->expects(self::once())
|
||||
->method('hasConfirmedOptIn')
|
||||
->with('[email protected]')
|
||||
->willReturn(false);
|
||||
$controller->newsletterManager
|
||||
->expects(self::once())
|
||||
->method('hasPendingConfirmation')
|
||||
->with('[email protected]')
|
||||
->willReturn(false);
|
||||
$controller->dataLoader
|
||||
->expects(self::once())
|
||||
->method('invalidateUserBookingCaches')
|
||||
->with($controller->user);
|
||||
$controller->entityManager
|
||||
->expects(self::once())
|
||||
->method('flush');
|
||||
$controller->passwordHasher
|
||||
->expects(self::once())
|
||||
->method('hashPassword')
|
||||
->with($controller->user, 'NewPassw0rd!')
|
||||
->willReturn('new-local-hash');
|
||||
|
||||
$request = Request::create('/personal-data', 'POST');
|
||||
$request->setSession(new Session(new MockArraySessionStorage()));
|
||||
$request->getSession()->set(PersonalDataController::SESSION_FORCE_PASSWORD_CHANGE_KEY, true);
|
||||
|
||||
$response = $controller->index($request);
|
||||
|
||||
self::assertSame(302, $response->getStatusCode());
|
||||
self::assertSame('new-local-hash', $controller->user->getPassword());
|
||||
self::assertNull($controller->user->getWelcomeActivationNonce());
|
||||
self::assertNull($controller->user->getWelcomeActivationExpiresAt());
|
||||
self::assertNotNull($controller->user->getWelcomeActivatedAt());
|
||||
self::assertFalse($request->getSession()->has(PersonalDataController::SESSION_FORCE_PASSWORD_CHANGE_KEY));
|
||||
}
|
||||
|
||||
public function testIndexDoesNotPersistInvalidFormSubmission(): void
|
||||
{
|
||||
$controller = $this->createController(true, false, $this->validator(), 'NewPassw0rd!');
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Controller\Account;
|
||||
|
||||
use App\Service\BookingGuestAccountManager;
|
||||
use App\Service\PublicFormRateLimiter;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
class PortalAccessRequestControllerTest extends WebTestCase
|
||||
{
|
||||
public function testPortalAccessRequestRedirectsToLoginAfterSendingActivationMail(): void
|
||||
{
|
||||
$client = self::createClient([], ['HTTPS' => 'on']);
|
||||
$client->disableReboot();
|
||||
|
||||
$rateLimiter = $this->createMock(PublicFormRateLimiter::class);
|
||||
$rateLimiter
|
||||
->expects(self::once())
|
||||
->method('isPortalAccessAllowed')
|
||||
->with('[email protected]', self::isInstanceOf(Request::class))
|
||||
->willReturn(true);
|
||||
|
||||
$guestAccountManager = $this->createMock(BookingGuestAccountManager::class);
|
||||
$guestAccountManager
|
||||
->expects(self::once())
|
||||
->method('requestPortalAccess')
|
||||
->with('[email protected]', 123)
|
||||
->willReturn(BookingGuestAccountManager::PORTAL_ACCESS_SENT);
|
||||
|
||||
self::getContainer()->set(PublicFormRateLimiter::class, $rateLimiter);
|
||||
self::getContainer()->set(BookingGuestAccountManager::class, $guestAccountManager);
|
||||
|
||||
$crawler = $client->request('GET', '/');
|
||||
$token = $crawler->filter('input[name="_token"]')->attr('value');
|
||||
$client->request('POST', '/portal-access/request', [
|
||||
'email' => '[email protected]',
|
||||
'bookingNumber' => '123',
|
||||
'_token' => $token,
|
||||
]);
|
||||
|
||||
self::assertResponseRedirects('/');
|
||||
$client->followRedirect();
|
||||
|
||||
self::assertStringContainsString('Wenn zu deiner Buchung ein Portalzugang möglich ist, haben wir dir eine E-Mail gesendet.', $client->getResponse()->getContent());
|
||||
}
|
||||
|
||||
public function testPortalAccessRequestRejectsMismatchedBookingGenerically(): void
|
||||
{
|
||||
$client = self::createClient([], ['HTTPS' => 'on']);
|
||||
$client->disableReboot();
|
||||
|
||||
$rateLimiter = $this->createMock(PublicFormRateLimiter::class);
|
||||
$rateLimiter
|
||||
->expects(self::once())
|
||||
->method('isPortalAccessAllowed')
|
||||
->with('[email protected]', self::isInstanceOf(Request::class))
|
||||
->willReturn(true);
|
||||
|
||||
$guestAccountManager = $this->createMock(BookingGuestAccountManager::class);
|
||||
$guestAccountManager
|
||||
->expects(self::once())
|
||||
->method('requestPortalAccess')
|
||||
->with('[email protected]', 123)
|
||||
->willReturn(BookingGuestAccountManager::PORTAL_ACCESS_NOT_SENT);
|
||||
|
||||
self::getContainer()->set(PublicFormRateLimiter::class, $rateLimiter);
|
||||
self::getContainer()->set(BookingGuestAccountManager::class, $guestAccountManager);
|
||||
|
||||
$crawler = $client->request('GET', '/');
|
||||
$token = $crawler->filter('input[name="_token"]')->attr('value');
|
||||
$client->request('POST', '/portal-access/request', [
|
||||
'email' => '[email protected]',
|
||||
'bookingNumber' => '123',
|
||||
'_token' => $token,
|
||||
]);
|
||||
|
||||
self::assertResponseRedirects('/');
|
||||
$client->followRedirect();
|
||||
|
||||
self::assertStringContainsString('Wenn zu deiner Buchung ein Portalzugang möglich ist, haben wir dir eine E-Mail gesendet.', $client->getResponse()->getContent());
|
||||
}
|
||||
|
||||
public function testPortalAccessRequestShowsLoginHintForAlreadyActiveAccount(): void
|
||||
{
|
||||
$client = self::createClient([], ['HTTPS' => 'on']);
|
||||
$client->disableReboot();
|
||||
|
||||
$rateLimiter = $this->createMock(PublicFormRateLimiter::class);
|
||||
$rateLimiter
|
||||
->expects(self::once())
|
||||
->method('isPortalAccessAllowed')
|
||||
->with('[email protected]', self::isInstanceOf(Request::class))
|
||||
->willReturn(true);
|
||||
|
||||
$guestAccountManager = $this->createMock(BookingGuestAccountManager::class);
|
||||
$guestAccountManager
|
||||
->expects(self::once())
|
||||
->method('requestPortalAccess')
|
||||
->with('[email protected]', 123)
|
||||
->willReturn(BookingGuestAccountManager::PORTAL_ACCESS_ALREADY_ACTIVE);
|
||||
|
||||
self::getContainer()->set(PublicFormRateLimiter::class, $rateLimiter);
|
||||
self::getContainer()->set(BookingGuestAccountManager::class, $guestAccountManager);
|
||||
|
||||
$crawler = $client->request('GET', '/');
|
||||
$token = $crawler->filter('input[name="_token"]')->attr('value');
|
||||
$client->request('POST', '/portal-access/request', [
|
||||
'email' => '[email protected]',
|
||||
'bookingNumber' => '123',
|
||||
'_token' => $token,
|
||||
]);
|
||||
|
||||
self::assertResponseRedirects('/');
|
||||
$client->followRedirect();
|
||||
|
||||
self::assertStringContainsString('Für diese E-Mail-Adresse besteht bereits ein aktiver Portalzugang.', $client->getResponse()->getContent());
|
||||
}
|
||||
|
||||
public function testPortalAccessRequestReturns429WhenThrottled(): void
|
||||
{
|
||||
$client = self::createClient([], ['HTTPS' => 'on']);
|
||||
$client->disableReboot();
|
||||
|
||||
$rateLimiter = $this->createMock(PublicFormRateLimiter::class);
|
||||
$rateLimiter
|
||||
->expects(self::once())
|
||||
->method('isPortalAccessAllowed')
|
||||
->with('[email protected]', self::isInstanceOf(Request::class))
|
||||
->willReturn(false);
|
||||
|
||||
$guestAccountManager = $this->createMock(BookingGuestAccountManager::class);
|
||||
$guestAccountManager->expects(self::never())->method('requestPortalAccess');
|
||||
|
||||
self::getContainer()->set(PublicFormRateLimiter::class, $rateLimiter);
|
||||
self::getContainer()->set(BookingGuestAccountManager::class, $guestAccountManager);
|
||||
|
||||
$crawler = $client->request('GET', '/');
|
||||
$token = $crawler->filter('input[name="_token"]')->attr('value');
|
||||
$client->request('POST', '/portal-access/request', [
|
||||
'email' => '[email protected]',
|
||||
'bookingNumber' => '123',
|
||||
'_token' => $token,
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(429);
|
||||
self::assertStringContainsString(PublicFormRateLimiter::THROTTLED_MESSAGE, $client->getResponse()->getContent());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Controller\Booking\Create;
|
||||
|
||||
use App\BusProNet\ApiClient;
|
||||
use App\BusProNet\Constants;
|
||||
use App\BusProNet\Model\BookingResponse;
|
||||
use App\BusProNet\Model\Hotel;
|
||||
use App\BusProNet\Model\Travel;
|
||||
use App\Entity\User;
|
||||
use App\Form\Model\BookingCreateContext;
|
||||
use App\Form\Model\BookingDto;
|
||||
use App\Form\Model\BookingSummaryDto;
|
||||
use App\Form\Model\BookingSummaryPricingDto;
|
||||
use App\Form\Model\BookingSummaryVoucherDto;
|
||||
use App\Form\Model\ParticipantDto;
|
||||
use App\Form\Model\RoomGroupsDto;
|
||||
use App\Repository\UserRepository;
|
||||
use App\Service\BookingConfigurator;
|
||||
use App\Service\BookingCreateContextFactory;
|
||||
use App\Service\BookingGuestAccountManager;
|
||||
use App\Service\BookingSessionManager;
|
||||
use App\Service\NewsletterManager;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
class Step4ControllerTest extends WebTestCase
|
||||
{
|
||||
protected function tearDown(): void
|
||||
{
|
||||
self::ensureKernelShutdown();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testStep4RendersCheckboxAndProvisionsGuestAccountAfterSuccessfulBooking(): void
|
||||
{
|
||||
$client = self::createClient([], ['HTTPS' => 'on']);
|
||||
$client->disableReboot();
|
||||
|
||||
$bookingDto = $this->createBookingDto();
|
||||
$context = $this->createBookingContext($bookingDto);
|
||||
|
||||
$bookingSessionManager = $this->createMock(BookingSessionManager::class);
|
||||
$bookingSessionManager
|
||||
->expects(self::exactly(2))
|
||||
->method('getOrCreateBookingCreateDto')
|
||||
->willReturn($bookingDto);
|
||||
|
||||
$contextFactory = $this->createMock(BookingCreateContextFactory::class);
|
||||
$contextFactory
|
||||
->expects(self::exactly(2))
|
||||
->method('createWithParticipantPrices')
|
||||
->with($bookingDto)
|
||||
->willReturn($context);
|
||||
|
||||
$userRepository = $this->createMock(UserRepository::class);
|
||||
$userRepository
|
||||
->expects(self::exactly(2))
|
||||
->method('findOneByNormalizedEmail')
|
||||
->with('[email protected]')
|
||||
->willReturn(null);
|
||||
|
||||
$guestAccountManager = $this->createMock(BookingGuestAccountManager::class);
|
||||
$guestAccountManager
|
||||
->expects(self::once())
|
||||
->method('provisionGuestAccountForBooking')
|
||||
->with('[email protected]', 123, 'Alice', 'Example')
|
||||
->willReturn(true);
|
||||
|
||||
$bookingConfigurator = $this->createMock(BookingConfigurator::class);
|
||||
$bookingConfigurator
|
||||
->expects(self::once())
|
||||
->method('applyCreateBookingStatusRules');
|
||||
|
||||
$apiClient = $this->createMock(ApiClient::class);
|
||||
$apiClient
|
||||
->expects(self::once())
|
||||
->method('createBooking')
|
||||
->with($bookingDto)
|
||||
->willReturn(new BookingResponse(BookingResponse::BOOKING_PROCESSED, 123));
|
||||
|
||||
$newsletterManager = $this->createMock(NewsletterManager::class);
|
||||
$newsletterManager->expects(self::never())->method('requestConfirmation');
|
||||
|
||||
self::getContainer()->set(BookingSessionManager::class, $bookingSessionManager);
|
||||
self::getContainer()->set(BookingCreateContextFactory::class, $contextFactory);
|
||||
self::getContainer()->set(UserRepository::class, $userRepository);
|
||||
self::getContainer()->set(BookingGuestAccountManager::class, $guestAccountManager);
|
||||
self::getContainer()->set(BookingConfigurator::class, $bookingConfigurator);
|
||||
self::getContainer()->set(ApiClient::class, $apiClient);
|
||||
self::getContainer()->set(NewsletterManager::class, $newsletterManager);
|
||||
|
||||
$crawler = $client->request('GET', '/bookings/create/confirmation');
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertStringContainsString('Benutzeraccount in MyE&P anlegen', $client->getResponse()->getContent());
|
||||
|
||||
$form = $crawler->selectButton('Verbindlich buchen')->form([
|
||||
'booking_create_step4[confirmationAccepted]' => '1',
|
||||
'booking_create_step4[termsAccepted]' => '1',
|
||||
'booking_create_step4[guestAccountOptIn]' => '1',
|
||||
]);
|
||||
$client->submit($form);
|
||||
|
||||
self::assertResponseRedirects('/bookings/create/success');
|
||||
}
|
||||
|
||||
public function testStep4HidesGuestAccountCheckboxForAuthenticatedUsers(): void
|
||||
{
|
||||
$bookingDto = $this->createBookingDto();
|
||||
$userRepository = $this->createMock(UserRepository::class);
|
||||
$userRepository->expects(self::never())->method('findOneByNormalizedEmail');
|
||||
|
||||
$controller = $this->createAuthenticatedController(
|
||||
user: new User('[email protected]'),
|
||||
userRepository: $userRepository,
|
||||
);
|
||||
|
||||
self::assertNull($controller->exposeGuestAccountTargetEmail($bookingDto));
|
||||
}
|
||||
|
||||
public function testStep4HidesGuestAccountCheckboxWhenLocalUserAlreadyExists(): void
|
||||
{
|
||||
$client = self::createClient([], ['HTTPS' => 'on']);
|
||||
$client->disableReboot();
|
||||
|
||||
$bookingDto = $this->createBookingDto();
|
||||
$context = $this->createBookingContext($bookingDto);
|
||||
|
||||
$bookingSessionManager = $this->createMock(BookingSessionManager::class);
|
||||
$bookingSessionManager
|
||||
->expects(self::once())
|
||||
->method('getOrCreateBookingCreateDto')
|
||||
->willReturn($bookingDto);
|
||||
|
||||
$contextFactory = $this->createMock(BookingCreateContextFactory::class);
|
||||
$contextFactory
|
||||
->expects(self::once())
|
||||
->method('createWithParticipantPrices')
|
||||
->with($bookingDto)
|
||||
->willReturn($context);
|
||||
|
||||
$userRepository = $this->createMock(UserRepository::class);
|
||||
$userRepository
|
||||
->expects(self::once())
|
||||
->method('findOneByNormalizedEmail')
|
||||
->with('[email protected]')
|
||||
->willReturn(new User('[email protected]'));
|
||||
|
||||
$guestAccountManager = $this->createMock(BookingGuestAccountManager::class);
|
||||
$guestAccountManager->expects(self::never())->method('provisionGuestAccount');
|
||||
|
||||
self::getContainer()->set(BookingSessionManager::class, $bookingSessionManager);
|
||||
self::getContainer()->set(BookingCreateContextFactory::class, $contextFactory);
|
||||
self::getContainer()->set(UserRepository::class, $userRepository);
|
||||
self::getContainer()->set(BookingGuestAccountManager::class, $guestAccountManager);
|
||||
|
||||
$client->request('GET', '/bookings/create/confirmation');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertStringNotContainsString('Benutzeraccount in MyE&P anlegen', $client->getResponse()->getContent());
|
||||
}
|
||||
|
||||
public function testGuestAccountActivationRedirectsToPersonalDataAndForcesPasswordChange(): void
|
||||
{
|
||||
$client = self::createClient([], ['HTTPS' => 'on']);
|
||||
$client->disableReboot();
|
||||
|
||||
$guestAccountManager = $this->createMock(BookingGuestAccountManager::class);
|
||||
$guestAccountManager
|
||||
->expects(self::once())
|
||||
->method('validateActivationRequest')
|
||||
->with(self::isInstanceOf(\Symfony\Component\HttpFoundation\Request::class))
|
||||
->willReturn(new User('[email protected]'));
|
||||
$guestAccountManager
|
||||
->expects(self::never())
|
||||
->method('consumeActivation');
|
||||
|
||||
$security = $this->createMock(Security::class);
|
||||
$security
|
||||
->expects(self::once())
|
||||
->method('login')
|
||||
->with(self::isInstanceOf(User::class), null, 'main')
|
||||
->willReturn(null);
|
||||
|
||||
self::getContainer()->set(BookingGuestAccountManager::class, $guestAccountManager);
|
||||
self::getContainer()->set(Security::class, $security);
|
||||
|
||||
$client->request('GET', '/welcome/[email protected]&nonce=current-nonce&expires=9999999999&_hash=signature');
|
||||
|
||||
self::assertResponseRedirects('/personal-data');
|
||||
self::assertTrue($client->getRequest()->getSession()->get(\App\Controller\Account\PersonalDataController::SESSION_FORCE_PASSWORD_CHANGE_KEY));
|
||||
}
|
||||
|
||||
private function createBookingDto(): BookingDto
|
||||
{
|
||||
$travel = new Travel();
|
||||
$travel->id = 123;
|
||||
$travel->label = 'Winterreise';
|
||||
$travel->productCode = 'PROD-1';
|
||||
$travel->dateFrom = new \DateTimeImmutable('2026-01-01');
|
||||
$travel->dateTo = new \DateTimeImmutable('2026-01-08');
|
||||
$hotel = new Hotel();
|
||||
$hotel->name = 'Hotel Test';
|
||||
$travel->hotel = $hotel;
|
||||
|
||||
$bookingDto = new BookingDto($travel, 77);
|
||||
$bookingDto->currentStep = 4;
|
||||
$bookingDto->bookingStatus = 'F';
|
||||
$bookingDto->paymentMethod = Constants::PAYMENT_METHOD_TRANSFER;
|
||||
|
||||
$participant = new ParticipantDto();
|
||||
$participant->index = 0;
|
||||
$participant->firstName = 'Alice';
|
||||
$participant->lastName = 'Example';
|
||||
$participant->email = '[email protected]';
|
||||
$participant->gender = 'M';
|
||||
$participant->nationality = 'DE';
|
||||
$participant->dateOfBirth = new \DateTimeImmutable('1990-01-01');
|
||||
$bookingDto->participants = [$participant];
|
||||
|
||||
return $bookingDto;
|
||||
}
|
||||
|
||||
private function createBookingContext(BookingDto $bookingDto): BookingCreateContext
|
||||
{
|
||||
$pricing = new BookingSummaryPricingDto([], [], null, [], 0.0);
|
||||
$vouchers = new BookingSummaryVoucherDto(null, 0.0);
|
||||
$summary = new BookingSummaryDto([], 1, $pricing, $vouchers, null);
|
||||
|
||||
return new BookingCreateContext(
|
||||
bookingDto: $bookingDto,
|
||||
summaryData: $summary,
|
||||
groupedRooms: new RoomGroupsDto([], []),
|
||||
participantPrices: [0.0],
|
||||
);
|
||||
}
|
||||
|
||||
private function createAuthenticatedController(User $user, UserRepository $userRepository): TestableStep4Controller
|
||||
{
|
||||
return new TestableStep4Controller(
|
||||
$this->createMock(BookingConfigurator::class),
|
||||
$this->createMock(BookingSessionManager::class),
|
||||
$this->createMock(BookingCreateContextFactory::class),
|
||||
$this->createMock(BookingGuestAccountManager::class),
|
||||
$this->createMock(ApiClient::class),
|
||||
$this->createMock(\Symfony\Contracts\Cache\CacheInterface::class),
|
||||
$this->createMock(NewsletterManager::class),
|
||||
$userRepository,
|
||||
$this->createMock(\Psr\Log\LoggerInterface::class),
|
||||
$user,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final class TestableStep4Controller extends \App\Controller\Booking\Create\Step4Controller
|
||||
{
|
||||
public function __construct(
|
||||
BookingConfigurator $bookingService,
|
||||
BookingSessionManager $bookingSessionService,
|
||||
BookingCreateContextFactory $createContextFactory,
|
||||
BookingGuestAccountManager $guestAccountManager,
|
||||
ApiClient $apiClient,
|
||||
\Symfony\Contracts\Cache\CacheInterface $cache,
|
||||
NewsletterManager $doubleOptInService,
|
||||
UserRepository $userRepository,
|
||||
\Psr\Log\LoggerInterface $logger,
|
||||
private readonly User $user,
|
||||
) {
|
||||
parent::__construct(
|
||||
$bookingService,
|
||||
$bookingSessionService,
|
||||
$createContextFactory,
|
||||
$guestAccountManager,
|
||||
$apiClient,
|
||||
$cache,
|
||||
$doubleOptInService,
|
||||
$userRepository,
|
||||
$logger,
|
||||
);
|
||||
}
|
||||
|
||||
public function exposeGuestAccountTargetEmail(BookingDto $bookingDto): ?string
|
||||
{
|
||||
return $this->resolveGuestAccountTargetEmail($bookingDto);
|
||||
}
|
||||
|
||||
protected function getUser(): \Symfony\Component\Security\Core\User\UserInterface|null
|
||||
{
|
||||
return $this->user;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\EventListener;
|
||||
|
||||
use App\Controller\Account\PersonalDataController;
|
||||
use App\Entity\User;
|
||||
use App\EventListener\ForcePasswordChangeRedirectListener;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use App\Htmx\HxRedirectResponse;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Session\Session;
|
||||
use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\HttpKernel\HttpKernelInterface;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
|
||||
class ForcePasswordChangeRedirectListenerTest extends TestCase
|
||||
{
|
||||
private HttpKernelInterface $kernel;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->kernel = $this->createMock(HttpKernelInterface::class);
|
||||
}
|
||||
|
||||
public function testRedirectsAwayFromNonPersonalDataRoutesWhenPasswordChangeIsForced(): void
|
||||
{
|
||||
$listener = $this->createListener();
|
||||
$request = $this->createRequest('/account', 'app_account');
|
||||
$event = new RequestEvent($this->kernel, $request, HttpKernelInterface::MAIN_REQUEST);
|
||||
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
self::assertInstanceOf(RedirectResponse::class, $event->getResponse());
|
||||
self::assertSame('/personal-data', $event->getResponse()->headers->get('Location'));
|
||||
}
|
||||
|
||||
public function testAllowsPersonalDataRouteWhilePasswordChangeIsForced(): void
|
||||
{
|
||||
$listener = $this->createListener();
|
||||
$request = $this->createRequest('/personal-data', 'app_personal_data');
|
||||
$event = new RequestEvent($this->kernel, $request, HttpKernelInterface::MAIN_REQUEST);
|
||||
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
self::assertNull($event->getResponse());
|
||||
}
|
||||
|
||||
public function testNewsletterSubRouteIsNoLongerExempt(): void
|
||||
{
|
||||
$listener = $this->createListener();
|
||||
$request = $this->createRequest('/personal-data/newsletter', 'app_personal_data_newsletter', ['HX-Request' => 'true']);
|
||||
$event = new RequestEvent($this->kernel, $request, HttpKernelInterface::MAIN_REQUEST);
|
||||
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
self::assertInstanceOf(HxRedirectResponse::class, $event->getResponse());
|
||||
self::assertSame('/personal-data', $event->getResponse()->headers->get('HX-Redirect'));
|
||||
}
|
||||
|
||||
public function testLogoutRouteRemainsReachable(): void
|
||||
{
|
||||
$listener = $this->createListener();
|
||||
$request = $this->createRequest('/logout', 'app_logout');
|
||||
$event = new RequestEvent($this->kernel, $request, HttpKernelInterface::MAIN_REQUEST);
|
||||
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
self::assertNull($event->getResponse());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $headers
|
||||
*/
|
||||
private function createRequest(string $uri, string $route, array $headers = []): Request
|
||||
{
|
||||
$server = [];
|
||||
foreach ($headers as $header => $value) {
|
||||
$server['HTTP_'.strtoupper(str_replace('-', '_', $header))] = $value;
|
||||
}
|
||||
|
||||
$request = Request::create($uri, 'GET', [], [], [], $server);
|
||||
$request->setSession(new Session(new MockArraySessionStorage()));
|
||||
$request->getSession()->set(PersonalDataController::SESSION_FORCE_PASSWORD_CHANGE_KEY, true);
|
||||
$request->attributes->set('_route', $route);
|
||||
|
||||
return $request;
|
||||
}
|
||||
|
||||
private function createListener(): ForcePasswordChangeRedirectListener
|
||||
{
|
||||
$security = $this->createMock(Security::class);
|
||||
$security
|
||||
->method('getUser')
|
||||
->willReturn(new User('[email protected]'));
|
||||
|
||||
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
|
||||
$urlGenerator
|
||||
->method('generate')
|
||||
->with('app_personal_data')
|
||||
->willReturn('/personal-data');
|
||||
|
||||
return new ForcePasswordChangeRedirectListener($security, $urlGenerator);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Repository;
|
||||
|
||||
use App\Repository\UserRepository;
|
||||
use Doctrine\ORM\Query;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class UserRepositoryTest extends TestCase
|
||||
{
|
||||
public function testDeleteExpiredDormantGuestInvitesBuildsExpectedQuery(): void
|
||||
{
|
||||
$query = $this->createMock(Query::class);
|
||||
$query
|
||||
->expects(self::once())
|
||||
->method('execute')
|
||||
->willReturn(2);
|
||||
|
||||
$threshold = new \DateTimeImmutable('2026-05-29 01:00:00');
|
||||
$queryBuilder = $this->createMock(QueryBuilder::class);
|
||||
$andWhereCalls = 0;
|
||||
$queryBuilder
|
||||
->expects(self::once())
|
||||
->method('delete')
|
||||
->willReturnSelf();
|
||||
$queryBuilder
|
||||
->expects(self::once())
|
||||
->method('where')
|
||||
->with('u.welcomeActivatedAt IS NULL')
|
||||
->willReturnSelf();
|
||||
$queryBuilder
|
||||
->expects(self::exactly(2))
|
||||
->method('andWhere')
|
||||
->willReturnCallback(function (string $condition) use (&$andWhereCalls, $queryBuilder): QueryBuilder {
|
||||
$expectedConditions = [
|
||||
'u.welcomeActivationExpiresAt IS NOT NULL',
|
||||
'u.welcomeActivationExpiresAt <= :threshold',
|
||||
];
|
||||
|
||||
self::assertSame($expectedConditions[$andWhereCalls], $condition);
|
||||
++$andWhereCalls;
|
||||
|
||||
return $queryBuilder;
|
||||
});
|
||||
$queryBuilder
|
||||
->expects(self::once())
|
||||
->method('setParameter')
|
||||
->with('threshold', $threshold)
|
||||
->willReturnSelf();
|
||||
$queryBuilder
|
||||
->expects(self::once())
|
||||
->method('getQuery')
|
||||
->willReturn($query);
|
||||
|
||||
$repository = new class($queryBuilder) extends UserRepository {
|
||||
public function __construct(private readonly QueryBuilder $queryBuilder)
|
||||
{
|
||||
}
|
||||
|
||||
public function createQueryBuilder($alias, $indexBy = null): QueryBuilder
|
||||
{
|
||||
return $this->queryBuilder;
|
||||
}
|
||||
};
|
||||
|
||||
self::assertSame(2, $repository->deleteExpiredDormantGuestInvites($threshold));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,623 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Service;
|
||||
|
||||
use App\BpnConnect\BpnConnectClient;
|
||||
use App\BpnConnect\Model\BookingLookupResult;
|
||||
use App\Email\Mailer;
|
||||
use App\Entity\User;
|
||||
use App\Exception\GuestAccountActivationExpiredException;
|
||||
use App\Repository\UserRepository;
|
||||
use App\Service\BookingGuestAccountManager;
|
||||
use App\Service\UserBootstrapper;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\UriSigner;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Mailer\Exception\TransportException;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
|
||||
class BookingGuestAccountManagerTest extends TestCase
|
||||
{
|
||||
public function testProvisionGuestAccountCreatesCustomerAndSendsActivationMail(): void
|
||||
{
|
||||
$persistedUser = null;
|
||||
$plainPassword = null;
|
||||
$generatedRoute = null;
|
||||
$generatedParameters = null;
|
||||
$generatedReferenceType = null;
|
||||
$capturedContext = null;
|
||||
$capturedOptions = null;
|
||||
|
||||
$userRepository = $this->createMock(UserRepository::class);
|
||||
$userRepository
|
||||
->expects(self::once())
|
||||
->method('findOneByNormalizedEmail')
|
||||
->with('[email protected]')
|
||||
->willReturn(null);
|
||||
|
||||
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||
$entityManager
|
||||
->expects(self::once())
|
||||
->method('persist')
|
||||
->with(self::callback(static function (User $user) use (&$persistedUser): bool {
|
||||
$persistedUser = $user;
|
||||
|
||||
return true;
|
||||
}));
|
||||
$entityManager
|
||||
->expects(self::once())
|
||||
->method('flush');
|
||||
|
||||
$passwordHasher = $this->createMock(UserPasswordHasherInterface::class);
|
||||
$passwordHasher
|
||||
->expects(self::once())
|
||||
->method('hashPassword')
|
||||
->with(self::isInstanceOf(User::class), self::callback(static function (string $password) use (&$plainPassword): bool {
|
||||
$plainPassword = $password;
|
||||
|
||||
return '' !== $password;
|
||||
}))
|
||||
->willReturn('hashed-password');
|
||||
|
||||
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
|
||||
$urlGenerator
|
||||
->expects(self::once())
|
||||
->method('generate')
|
||||
->willReturnCallback(static function (string $route, array $parameters, int $referenceType) use (&$generatedRoute, &$generatedParameters, &$generatedReferenceType): string {
|
||||
$generatedRoute = $route;
|
||||
$generatedParameters = $parameters;
|
||||
$generatedReferenceType = $referenceType;
|
||||
|
||||
return 'https://example.test/welcome/activate?'.http_build_query($parameters);
|
||||
});
|
||||
|
||||
$mailer = $this->createMock(Mailer::class);
|
||||
$mailer
|
||||
->expects(self::once())
|
||||
->method('createAndSendEmail')
|
||||
->willReturnCallback(static function (array $context, array $options) use (&$capturedContext, &$capturedOptions): void {
|
||||
$capturedContext = $context;
|
||||
$capturedOptions = $options;
|
||||
});
|
||||
|
||||
$manager = $this->createManager($userRepository, $entityManager, $passwordHasher, $urlGenerator, $mailer);
|
||||
$uriSigner = new UriSigner('test-secret');
|
||||
|
||||
self::assertTrue($manager->provisionGuestAccount(' [email protected] ', 'Alice', 'Example', 12345, 67890));
|
||||
self::assertSame('app_guest_account_activate', $generatedRoute);
|
||||
self::assertSame(UrlGeneratorInterface::ABSOLUTE_URL, $generatedReferenceType);
|
||||
self::assertSame('[email protected]', $generatedParameters['email']);
|
||||
self::assertSame($persistedUser?->getWelcomeActivationNonce(), $generatedParameters['nonce']);
|
||||
self::assertTrue($uriSigner->check((string) $capturedContext['activationUrl']));
|
||||
self::assertSame('[email protected]', $capturedOptions['to']);
|
||||
self::assertSame('Dein MyE&P Konto ist bereit', $capturedOptions['subject']);
|
||||
self::assertSame('email/booking_guest_account_welcome.html.twig', $capturedOptions['template']);
|
||||
self::assertSame('Alice', $capturedContext['firstName']);
|
||||
self::assertSame('Example', $capturedContext['lastName']);
|
||||
self::assertMatchesRegularExpression('/^[a-f0-9]{48}$/', (string) $plainPassword);
|
||||
self::assertInstanceOf(User::class, $persistedUser);
|
||||
self::assertSame('[email protected]', $persistedUser->getEmail());
|
||||
self::assertSame(12345, $persistedUser->getAddressId());
|
||||
self::assertSame(67890, $persistedUser->getPersonId());
|
||||
self::assertSame(['ROLE_USER', 'ROLE_CUSTOMER'], $persistedUser->getRoles());
|
||||
self::assertSame([], $persistedUser->getHotelCodes());
|
||||
self::assertTrue($persistedUser->isProfileComplete());
|
||||
self::assertMatchesRegularExpression('/^[a-f0-9]{64}$/', (string) $persistedUser->getWelcomeActivationNonce());
|
||||
self::assertInstanceOf(\DateTimeImmutable::class, $persistedUser->getWelcomeActivationExpiresAt());
|
||||
self::assertNull($persistedUser->getWelcomeActivatedAt());
|
||||
}
|
||||
|
||||
public function testProvisionGuestAccountReturnsFalseWhenLocalUserAlreadyExists(): void
|
||||
{
|
||||
$userRepository = $this->createMock(UserRepository::class);
|
||||
$userRepository
|
||||
->expects(self::once())
|
||||
->method('findOneByNormalizedEmail')
|
||||
->with('[email protected]')
|
||||
->willReturn(new User('[email protected]'));
|
||||
|
||||
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||
$entityManager->expects(self::never())->method('persist');
|
||||
$entityManager->expects(self::never())->method('flush');
|
||||
|
||||
$passwordHasher = $this->createMock(UserPasswordHasherInterface::class);
|
||||
$passwordHasher->expects(self::never())->method('hashPassword');
|
||||
|
||||
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
|
||||
$urlGenerator->expects(self::never())->method('generate');
|
||||
|
||||
$mailer = $this->createMock(Mailer::class);
|
||||
$mailer->expects(self::never())->method('createAndSendEmail');
|
||||
|
||||
$manager = $this->createManager($userRepository, $entityManager, $passwordHasher, $urlGenerator, $mailer);
|
||||
|
||||
self::assertFalse($manager->provisionGuestAccount('[email protected]', 'Alice', 'Example'));
|
||||
}
|
||||
|
||||
public function testRequestPortalAccessProvisionsGuestAccountWhenBookingMatches(): void
|
||||
{
|
||||
$persistedUser = null;
|
||||
$capturedContext = null;
|
||||
$capturedOptions = null;
|
||||
$booking = $this->createLookupResult('[email protected]', 123, 'Alice', 'Example');
|
||||
|
||||
$bpnConnectClient = $this->createMock(BpnConnectClient::class);
|
||||
$bpnConnectClient
|
||||
->expects(self::once())
|
||||
->method('lookupBooking')
|
||||
->with('[email protected]', 123)
|
||||
->willReturn($booking);
|
||||
|
||||
$userRepository = $this->createMock(UserRepository::class);
|
||||
$userRepository
|
||||
->expects(self::exactly(2))
|
||||
->method('findOneByNormalizedEmail')
|
||||
->with('[email protected]')
|
||||
->willReturnOnConsecutiveCalls(null, null);
|
||||
|
||||
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||
$entityManager
|
||||
->expects(self::once())
|
||||
->method('persist')
|
||||
->with(self::callback(static function (User $user) use (&$persistedUser): bool {
|
||||
$persistedUser = $user;
|
||||
|
||||
return true;
|
||||
}));
|
||||
$entityManager->expects(self::once())->method('flush');
|
||||
|
||||
$passwordHasher = $this->createMock(UserPasswordHasherInterface::class);
|
||||
$passwordHasher
|
||||
->expects(self::once())
|
||||
->method('hashPassword')
|
||||
->willReturn('hashed-password');
|
||||
|
||||
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
|
||||
$urlGenerator
|
||||
->expects(self::once())
|
||||
->method('generate')
|
||||
->willReturn('https://example.test/welcome/[email protected]&nonce=current&expires=9999999999');
|
||||
|
||||
$mailer = $this->createMock(Mailer::class);
|
||||
$mailer
|
||||
->expects(self::once())
|
||||
->method('createAndSendEmail')
|
||||
->willReturnCallback(static function (array $context, array $options) use (&$capturedContext, &$capturedOptions): void {
|
||||
$capturedContext = $context;
|
||||
$capturedOptions = $options;
|
||||
});
|
||||
|
||||
$manager = $this->createManager($userRepository, $entityManager, $passwordHasher, $urlGenerator, $mailer, $bpnConnectClient);
|
||||
|
||||
self::assertSame(BookingGuestAccountManager::PORTAL_ACCESS_SENT, $manager->requestPortalAccess('[email protected]', 123));
|
||||
self::assertInstanceOf(User::class, $persistedUser);
|
||||
self::assertSame('[email protected]', $persistedUser->getEmail());
|
||||
self::assertSame(12345, $persistedUser->getAddressId());
|
||||
self::assertSame(67890, $persistedUser->getPersonId());
|
||||
self::assertSame('[email protected]', $capturedOptions['to']);
|
||||
self::assertSame('Alice', $capturedContext['firstName']);
|
||||
self::assertSame('Example', $capturedContext['lastName']);
|
||||
self::assertMatchesRegularExpression('/^[a-f0-9]{64}$/', (string) $persistedUser->getWelcomeActivationNonce());
|
||||
self::assertInstanceOf(\DateTimeImmutable::class, $persistedUser->getWelcomeActivationExpiresAt());
|
||||
self::assertNull($persistedUser->getWelcomeActivatedAt());
|
||||
}
|
||||
|
||||
public function testRequestPortalAccessResendsPendingInviteWhenBookingMatches(): void
|
||||
{
|
||||
$user = new User('[email protected]');
|
||||
$user->setAddressId(12345);
|
||||
$user->setPersonId(67890);
|
||||
$user->setWelcomeActivationNonce('current-nonce');
|
||||
$user->setWelcomeActivationExpiresAt(new \DateTimeImmutable('+30 minutes'));
|
||||
$booking = $this->createLookupResult('[email protected]', 123, 'Alice', 'Example');
|
||||
|
||||
$bpnConnectClient = $this->createMock(BpnConnectClient::class);
|
||||
$bpnConnectClient
|
||||
->expects(self::once())
|
||||
->method('lookupBooking')
|
||||
->with('[email protected]', 123)
|
||||
->willReturn($booking);
|
||||
|
||||
$userRepository = $this->createMock(UserRepository::class);
|
||||
$userRepository
|
||||
->expects(self::once())
|
||||
->method('findOneByNormalizedEmail')
|
||||
->with('[email protected]')
|
||||
->willReturn($user);
|
||||
|
||||
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||
$entityManager->expects(self::once())->method('flush');
|
||||
|
||||
$passwordHasher = $this->createMock(UserPasswordHasherInterface::class);
|
||||
$passwordHasher->expects(self::never())->method('hashPassword');
|
||||
|
||||
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
|
||||
$urlGenerator
|
||||
->expects(self::once())
|
||||
->method('generate')
|
||||
->willReturn('https://example.test/welcome/[email protected]&nonce=new-nonce&expires=9999999999');
|
||||
|
||||
$mailer = $this->createMock(Mailer::class);
|
||||
$mailer
|
||||
->expects(self::once())
|
||||
->method('createAndSendEmail');
|
||||
|
||||
$manager = $this->createManager($userRepository, $entityManager, $passwordHasher, $urlGenerator, $mailer, $bpnConnectClient);
|
||||
|
||||
self::assertSame(BookingGuestAccountManager::PORTAL_ACCESS_SENT, $manager->requestPortalAccess('[email protected]', 123));
|
||||
self::assertNotSame('current-nonce', $user->getWelcomeActivationNonce());
|
||||
self::assertNull($user->getWelcomeActivatedAt());
|
||||
self::assertInstanceOf(\DateTimeImmutable::class, $user->getWelcomeActivationExpiresAt());
|
||||
}
|
||||
|
||||
public function testRequestPortalAccessUsesExistingUserIdsAndDoesNotMailActivatedAccount(): void
|
||||
{
|
||||
$user = new User('[email protected]');
|
||||
$user->setAddressId(12345);
|
||||
$user->setPersonId(67890);
|
||||
$user->markWelcomeActivated();
|
||||
$booking = $this->createLookupResult('[email protected]', 123, 'Alice', 'Example');
|
||||
|
||||
$bpnConnectClient = $this->createMock(BpnConnectClient::class);
|
||||
$bpnConnectClient
|
||||
->expects(self::once())
|
||||
->method('lookupBooking')
|
||||
->with('[email protected]', 123)
|
||||
->willReturn($booking);
|
||||
|
||||
$userRepository = $this->createMock(UserRepository::class);
|
||||
$userRepository
|
||||
->expects(self::once())
|
||||
->method('findOneByNormalizedEmail')
|
||||
->with('[email protected]')
|
||||
->willReturn($user);
|
||||
|
||||
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||
$entityManager->expects(self::never())->method('persist');
|
||||
$entityManager->expects(self::never())->method('flush');
|
||||
|
||||
$passwordHasher = $this->createMock(UserPasswordHasherInterface::class);
|
||||
$passwordHasher->expects(self::never())->method('hashPassword');
|
||||
|
||||
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
|
||||
$urlGenerator->expects(self::never())->method('generate');
|
||||
|
||||
$mailer = $this->createMock(Mailer::class);
|
||||
$mailer->expects(self::never())->method('createAndSendEmail');
|
||||
|
||||
$manager = $this->createManager($userRepository, $entityManager, $passwordHasher, $urlGenerator, $mailer, $bpnConnectClient);
|
||||
|
||||
self::assertSame(BookingGuestAccountManager::PORTAL_ACCESS_ALREADY_ACTIVE, $manager->requestPortalAccess('[email protected]', 123));
|
||||
}
|
||||
|
||||
public function testProvisionGuestAccountForBookingFetchesBookingIdentifiers(): void
|
||||
{
|
||||
$persistedUser = null;
|
||||
$booking = $this->createLookupResult('[email protected]', 123, 'Alice', 'Example');
|
||||
|
||||
$bpnConnectClient = $this->createMock(BpnConnectClient::class);
|
||||
$bpnConnectClient
|
||||
->expects(self::once())
|
||||
->method('lookupBooking')
|
||||
->with('[email protected]', 123)
|
||||
->willReturn($booking);
|
||||
|
||||
$userRepository = $this->createMock(UserRepository::class);
|
||||
$userRepository
|
||||
->expects(self::once())
|
||||
->method('findOneByNormalizedEmail')
|
||||
->with('[email protected]')
|
||||
->willReturn(null);
|
||||
|
||||
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||
$entityManager
|
||||
->expects(self::once())
|
||||
->method('persist')
|
||||
->with(self::callback(static function (User $user) use (&$persistedUser): bool {
|
||||
$persistedUser = $user;
|
||||
|
||||
return true;
|
||||
}));
|
||||
$entityManager->expects(self::once())->method('flush');
|
||||
|
||||
$passwordHasher = $this->createMock(UserPasswordHasherInterface::class);
|
||||
$passwordHasher
|
||||
->expects(self::once())
|
||||
->method('hashPassword')
|
||||
->willReturn('hashed-password');
|
||||
|
||||
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
|
||||
$urlGenerator
|
||||
->expects(self::once())
|
||||
->method('generate')
|
||||
->willReturn('https://example.test/welcome/[email protected]&nonce=current&expires=9999999999');
|
||||
|
||||
$mailer = $this->createMock(Mailer::class);
|
||||
$mailer
|
||||
->expects(self::once())
|
||||
->method('createAndSendEmail');
|
||||
|
||||
$manager = $this->createManager($userRepository, $entityManager, $passwordHasher, $urlGenerator, $mailer, $bpnConnectClient);
|
||||
|
||||
self::assertTrue($manager->provisionGuestAccountForBooking('[email protected]', 123));
|
||||
self::assertInstanceOf(User::class, $persistedUser);
|
||||
self::assertSame('[email protected]', $persistedUser->getEmail());
|
||||
self::assertSame(12345, $persistedUser->getAddressId());
|
||||
self::assertSame(67890, $persistedUser->getPersonId());
|
||||
}
|
||||
|
||||
public function testRequestPortalAccessRejectsMismatchedBookingGenerically(): void
|
||||
{
|
||||
$booking = $this->createLookupResult('[email protected]', 123, 'Alice', 'Example');
|
||||
|
||||
$bpnConnectClient = $this->createMock(BpnConnectClient::class);
|
||||
$bpnConnectClient
|
||||
->expects(self::once())
|
||||
->method('lookupBooking')
|
||||
->with('[email protected]', 123)
|
||||
->willReturn($booking);
|
||||
|
||||
$userRepository = $this->createMock(UserRepository::class);
|
||||
$userRepository
|
||||
->expects(self::once())
|
||||
->method('findOneByNormalizedEmail')
|
||||
->with('[email protected]')
|
||||
->willReturn(null);
|
||||
|
||||
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||
$entityManager->expects(self::never())->method('persist');
|
||||
$entityManager->expects(self::never())->method('flush');
|
||||
|
||||
$passwordHasher = $this->createMock(UserPasswordHasherInterface::class);
|
||||
$passwordHasher->expects(self::never())->method('hashPassword');
|
||||
|
||||
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
|
||||
$urlGenerator->expects(self::never())->method('generate');
|
||||
|
||||
$mailer = $this->createMock(Mailer::class);
|
||||
$mailer->expects(self::never())->method('createAndSendEmail');
|
||||
|
||||
$manager = $this->createManager($userRepository, $entityManager, $passwordHasher, $urlGenerator, $mailer, $bpnConnectClient);
|
||||
|
||||
self::assertSame(BookingGuestAccountManager::PORTAL_ACCESS_NOT_SENT, $manager->requestPortalAccess('[email protected]', 123));
|
||||
}
|
||||
|
||||
public function testConsumeActivationMarksInviteAsActivated(): void
|
||||
{
|
||||
$user = new User('[email protected]');
|
||||
$user->setWelcomeActivationNonce('current-nonce');
|
||||
$user->setWelcomeActivationExpiresAt(new \DateTimeImmutable('+30 minutes'));
|
||||
|
||||
$manager = $this->createValidationManager($user);
|
||||
$manager->consumeActivation($user);
|
||||
|
||||
self::assertNull($user->getWelcomeActivationNonce());
|
||||
self::assertNull($user->getWelcomeActivationExpiresAt());
|
||||
self::assertInstanceOf(\DateTimeImmutable::class, $user->getWelcomeActivatedAt());
|
||||
}
|
||||
|
||||
public function testValidateActivationRequestAcceptsValidSignedRequestAndRejectsReplay(): void
|
||||
{
|
||||
$user = new User('[email protected]');
|
||||
$user->setWelcomeActivationNonce('current-nonce');
|
||||
|
||||
$manager = $this->createValidationManager($user);
|
||||
$request = Request::create(
|
||||
$this->buildActivationUrl('[email protected]', 'current-nonce', time() + 3600),
|
||||
'GET'
|
||||
);
|
||||
|
||||
self::assertSame($user, $manager->validateActivationRequest($request));
|
||||
|
||||
$manager->consumeActivation($user);
|
||||
|
||||
$this->expectException(NotFoundHttpException::class);
|
||||
$manager->validateActivationRequest($request);
|
||||
}
|
||||
|
||||
public function testValidateActivationRequestRejectsExpiredLink(): void
|
||||
{
|
||||
$user = new User('[email protected]');
|
||||
$user->setWelcomeActivationNonce('current-nonce');
|
||||
|
||||
$manager = $this->createValidationManager($user);
|
||||
$request = Request::create(
|
||||
$this->buildActivationUrl('[email protected]', 'current-nonce', time() - 60),
|
||||
'GET'
|
||||
);
|
||||
|
||||
$this->expectException(GuestAccountActivationExpiredException::class);
|
||||
$manager->validateActivationRequest($request);
|
||||
}
|
||||
|
||||
public function testValidateActivationUrlForResendAcceptsExpiredLink(): void
|
||||
{
|
||||
$user = new User('[email protected]');
|
||||
$user->setWelcomeActivationNonce('current-nonce');
|
||||
|
||||
$manager = $this->createValidationManager($user);
|
||||
$activationUrl = $this->buildActivationUrl('[email protected]', 'current-nonce', time() - 60);
|
||||
|
||||
self::assertSame($user, $manager->validateActivationUrlForResend($activationUrl));
|
||||
}
|
||||
|
||||
public function testResendWelcomeEmailRotatesNonceAndSendsActivationMail(): void
|
||||
{
|
||||
$user = new User('[email protected]');
|
||||
$user->setWelcomeActivationNonce('old-nonce');
|
||||
|
||||
$generatedRoute = null;
|
||||
$generatedParameters = null;
|
||||
$generatedReferenceType = null;
|
||||
$capturedContext = null;
|
||||
$capturedOptions = null;
|
||||
|
||||
$userRepository = $this->createMock(UserRepository::class);
|
||||
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||
$passwordHasher = $this->createMock(UserPasswordHasherInterface::class);
|
||||
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
|
||||
$mailer = $this->createMock(Mailer::class);
|
||||
|
||||
$entityManager->expects(self::once())->method('flush');
|
||||
$passwordHasher->expects(self::never())->method('hashPassword');
|
||||
$userRepository->expects(self::never())->method('findOneByNormalizedEmail');
|
||||
$urlGenerator
|
||||
->expects(self::once())
|
||||
->method('generate')
|
||||
->willReturnCallback(static function (string $route, array $parameters, int $referenceType) use (&$generatedRoute, &$generatedParameters, &$generatedReferenceType): string {
|
||||
$generatedRoute = $route;
|
||||
$generatedParameters = $parameters;
|
||||
$generatedReferenceType = $referenceType;
|
||||
|
||||
return 'https://example.test/welcome/activate?'.http_build_query($parameters);
|
||||
});
|
||||
$mailer
|
||||
->expects(self::once())
|
||||
->method('createAndSendEmail')
|
||||
->willReturnCallback(static function (array $context, array $options) use (&$capturedContext, &$capturedOptions): void {
|
||||
$capturedContext = $context;
|
||||
$capturedOptions = $options;
|
||||
});
|
||||
|
||||
$manager = $this->createManager($userRepository, $entityManager, $passwordHasher, $urlGenerator, $mailer);
|
||||
|
||||
$manager->resendWelcomeEmail($user);
|
||||
|
||||
self::assertSame('app_guest_account_activate', $generatedRoute);
|
||||
self::assertSame(UrlGeneratorInterface::ABSOLUTE_URL, $generatedReferenceType);
|
||||
self::assertSame('[email protected]', $generatedParameters['email']);
|
||||
self::assertSame($user->getWelcomeActivationNonce(), $generatedParameters['nonce']);
|
||||
self::assertSame('[email protected]', $capturedOptions['to']);
|
||||
self::assertSame('Dein MyE&P Konto ist bereit', $capturedOptions['subject']);
|
||||
self::assertSame('email/booking_guest_account_welcome.html.twig', $capturedOptions['template']);
|
||||
self::assertTrue((new UriSigner('test-secret'))->check((string) $capturedContext['activationUrl']));
|
||||
self::assertNotSame('old-nonce', $user->getWelcomeActivationNonce());
|
||||
self::assertMatchesRegularExpression('/^[a-f0-9]{64}$/', (string) $user->getWelcomeActivationNonce());
|
||||
}
|
||||
|
||||
public function testProvisionGuestAccountKeepsPersistedUserWhenMailSendFails(): void
|
||||
{
|
||||
$persistedUser = null;
|
||||
|
||||
$userRepository = $this->createMock(UserRepository::class);
|
||||
$userRepository
|
||||
->expects(self::once())
|
||||
->method('findOneByNormalizedEmail')
|
||||
->with('[email protected]')
|
||||
->willReturn(null);
|
||||
|
||||
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||
$entityManager
|
||||
->expects(self::once())
|
||||
->method('persist')
|
||||
->with(self::callback(static function (User $user) use (&$persistedUser): bool {
|
||||
$persistedUser = $user;
|
||||
|
||||
return true;
|
||||
}));
|
||||
$entityManager->expects(self::once())->method('flush');
|
||||
|
||||
$passwordHasher = $this->createMock(UserPasswordHasherInterface::class);
|
||||
$passwordHasher
|
||||
->expects(self::once())
|
||||
->method('hashPassword')
|
||||
->willReturn('hashed-password');
|
||||
|
||||
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
|
||||
$urlGenerator
|
||||
->expects(self::once())
|
||||
->method('generate')
|
||||
->willReturn('https://example.test/welcome/[email protected]&nonce=current&expires=9999999999');
|
||||
|
||||
$mailer = $this->createMock(Mailer::class);
|
||||
$mailer
|
||||
->expects(self::once())
|
||||
->method('createAndSendEmail')
|
||||
->willThrowException(new TransportException('smtp down'));
|
||||
|
||||
$manager = $this->createManager($userRepository, $entityManager, $passwordHasher, $urlGenerator, $mailer);
|
||||
|
||||
$this->expectException(TransportException::class);
|
||||
try {
|
||||
$manager->provisionGuestAccount('[email protected]', 'Alice', 'Example');
|
||||
} finally {
|
||||
self::assertInstanceOf(User::class, $persistedUser);
|
||||
self::assertSame('[email protected]', $persistedUser->getEmail());
|
||||
self::assertSame(['ROLE_USER', 'ROLE_CUSTOMER'], $persistedUser->getRoles());
|
||||
self::assertTrue($persistedUser->isProfileComplete());
|
||||
self::assertNotNull($persistedUser->getWelcomeActivationNonce());
|
||||
}
|
||||
}
|
||||
|
||||
private function createManager(
|
||||
UserRepository $userRepository,
|
||||
EntityManagerInterface $entityManager,
|
||||
UserPasswordHasherInterface $passwordHasher,
|
||||
UrlGeneratorInterface $urlGenerator,
|
||||
Mailer $mailer,
|
||||
?BpnConnectClient $bpnConnectClient = null,
|
||||
): BookingGuestAccountManager {
|
||||
$bpnConnectClient ??= $this->createMock(BpnConnectClient::class);
|
||||
|
||||
return new BookingGuestAccountManager(
|
||||
$bpnConnectClient,
|
||||
$userRepository,
|
||||
$entityManager,
|
||||
new UserBootstrapper(),
|
||||
$passwordHasher,
|
||||
$urlGenerator,
|
||||
new UriSigner('test-secret'),
|
||||
$mailer,
|
||||
new NullLogger(),
|
||||
);
|
||||
}
|
||||
|
||||
private function createValidationManager(User $user): BookingGuestAccountManager
|
||||
{
|
||||
$userRepository = $this->createMock(UserRepository::class);
|
||||
$userRepository
|
||||
->method('findOneByNormalizedEmail')
|
||||
->willReturnCallback(static function (string $email) use ($user): ?User {
|
||||
return '[email protected]' === $email ? $user : null;
|
||||
});
|
||||
|
||||
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||
|
||||
$passwordHasher = $this->createMock(UserPasswordHasherInterface::class);
|
||||
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
|
||||
$mailer = $this->createMock(Mailer::class);
|
||||
$bpnConnectClient = $this->createMock(BpnConnectClient::class);
|
||||
|
||||
return $this->createManager($userRepository, $entityManager, $passwordHasher, $urlGenerator, $mailer, $bpnConnectClient);
|
||||
}
|
||||
|
||||
private function buildActivationUrl(string $email, string $nonce, int $expires): string
|
||||
{
|
||||
$baseUrl = 'https://example.test/welcome/activate?'.http_build_query([
|
||||
'email' => $email,
|
||||
'nonce' => $nonce,
|
||||
'expires' => $expires,
|
||||
]);
|
||||
|
||||
return (new UriSigner('test-secret'))->sign($baseUrl);
|
||||
}
|
||||
|
||||
private function createLookupResult(string $email, int $bookingNumber, string $firstName, string $lastName): BookingLookupResult
|
||||
{
|
||||
return new BookingLookupResult(
|
||||
bookingNumber: $bookingNumber,
|
||||
bookingId: 98765,
|
||||
addressId: 12345,
|
||||
personId: 67890,
|
||||
email: $email,
|
||||
firstName: $firstName,
|
||||
lastName: $lastName,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -173,6 +173,35 @@ class PasswordResetManagerTest extends TestCase
|
||||
self::assertMatchesRegularExpression('/^[a-f0-9]{64}$/', (string) $user->getPasswordResetNonce());
|
||||
}
|
||||
|
||||
public function testResetPasswordClearsPendingWelcomeInvite(): void
|
||||
{
|
||||
$user = new User('[email protected]');
|
||||
$user->setPasswordResetNonce('current-nonce');
|
||||
$user->markWelcomeActivationPending('welcome-nonce', new \DateTimeImmutable('-30 minutes'));
|
||||
|
||||
$userRepository = $this->createMock(UserRepository::class);
|
||||
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||
$passwordHasher = $this->createMock(UserPasswordHasherInterface::class);
|
||||
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
|
||||
$mailer = $this->createMock(Mailer::class);
|
||||
|
||||
$passwordHasher
|
||||
->expects(self::once())
|
||||
->method('hashPassword')
|
||||
->with($user, 'new-password')
|
||||
->willReturn('hashed-password');
|
||||
$entityManager->expects(self::once())->method('flush');
|
||||
|
||||
$manager = $this->createManager($userRepository, $entityManager, $passwordHasher, $urlGenerator, $mailer);
|
||||
|
||||
$manager->resetPassword($user, 'new-password');
|
||||
|
||||
self::assertSame('hashed-password', $user->getPassword());
|
||||
self::assertNull($user->getWelcomeActivationNonce());
|
||||
self::assertNull($user->getWelcomeActivationExpiresAt());
|
||||
self::assertNotNull($user->getWelcomeActivatedAt());
|
||||
}
|
||||
|
||||
public function testNewPasswordResetRequestInvalidatesPreviousLink(): void
|
||||
{
|
||||
$user = new User('[email protected]');
|
||||
|
||||
@@ -28,6 +28,11 @@ class TestPublicFormRateLimiter extends PublicFormRateLimiter
|
||||
return $this->consume($email, $request);
|
||||
}
|
||||
|
||||
public function isPortalAccessAllowed(string $email, Request $request): bool
|
||||
{
|
||||
return $this->consume($email, $request);
|
||||
}
|
||||
|
||||
public function createBucketKey(string $email, ?string $clientIp): string
|
||||
{
|
||||
return hash('sha256', mb_strtolower(trim($email)).'|'.($clientIp ?? 'unknown'));
|
||||
|
||||
Reference in new issue
Block a user