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_CONNECTION_TIMEOUT=5
|
||||||
APP_BPN_STREAM_TIMEOUT=30
|
APP_BPN_STREAM_TIMEOUT=30
|
||||||
APP_BPN_TOTAL_TIMEOUT=45
|
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_TRAVEL_INFO_BASE_URL="https://www.ep-reisen.de/reiseinformationen/"
|
||||||
APP_LEGAL_NOTICE_URL="https://www.ep-reisen.de/unternehmen/impressum-skireiseveranstalter/"
|
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_APP_ENV=panther
|
||||||
PANTHER_ERROR_SCREENSHOT_DIR=./var/error-screenshots
|
PANTHER_ERROR_SCREENSHOT_DIR=./var/error-screenshots
|
||||||
MAILJET_WEBHOOK_BASIC_PASSWORD=secret
|
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
|
policy: sliding_window
|
||||||
limit: 5
|
limit: 5
|
||||||
interval: 15 minutes
|
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.
|
# 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.
|
# Remove or comment this section to explicitly disable session support.
|
||||||
@@ -34,6 +38,11 @@ framework:
|
|||||||
base_uri: '%env(resolve:APP_CMS_API_BASE_URL)%'
|
base_uri: '%env(resolve:APP_CMS_API_BASE_URL)%'
|
||||||
headers:
|
headers:
|
||||||
accept: 'text/json'
|
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:
|
when@test:
|
||||||
framework:
|
framework:
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ zenstruck_schedule:
|
|||||||
frequency: "45 1 * * *"
|
frequency: "45 1 * * *"
|
||||||
description: "Removes expired pending newsletter double opt-in requests"
|
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
|
- task: league:oauth2-server:clear-expired-tokens
|
||||||
frequency: "0 * * * *"
|
frequency: "0 * * * *"
|
||||||
description: "Removes expired oauth2 tokens hourly"
|
description: "Removes expired oauth2 tokens hourly"
|
||||||
|
|||||||
@@ -100,6 +100,10 @@ services:
|
|||||||
stream_timeout: '%env(int:APP_BPN_STREAM_TIMEOUT)%'
|
stream_timeout: '%env(int:APP_BPN_STREAM_TIMEOUT)%'
|
||||||
total_timeout: '%env(int:APP_BPN_TOTAL_TIMEOUT)%'
|
total_timeout: '%env(int:APP_BPN_TOTAL_TIMEOUT)%'
|
||||||
|
|
||||||
|
App\BpnConnect\BpnConnectClient:
|
||||||
|
arguments:
|
||||||
|
$httpClient: '@bpn_connect.client'
|
||||||
|
|
||||||
App\Twig\AppRuntime:
|
App\Twig\AppRuntime:
|
||||||
arguments:
|
arguments:
|
||||||
$intlExtension: '@twig.extension.intl'
|
$intlExtension: '@twig.extension.intl'
|
||||||
@@ -140,6 +144,7 @@ services:
|
|||||||
arguments:
|
arguments:
|
||||||
$registrationLimiter: '@limiter.registration'
|
$registrationLimiter: '@limiter.registration'
|
||||||
$passwordResetLimiter: '@limiter.password_reset'
|
$passwordResetLimiter: '@limiter.password_reset'
|
||||||
|
$portalAccessRequestLimiter: '@limiter.portal_access_request'
|
||||||
|
|
||||||
App\Service\TravelDataProvider:
|
App\Service\TravelDataProvider:
|
||||||
arguments:
|
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 {
|
try {
|
||||||
$this->xmlDump->write($requestId.'_'.$type.'.xml', $body);
|
$this->xmlDump->write($requestId.'_'.$type.'.xml', $body);
|
||||||
} catch (FilesystemException $e) {
|
} 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;
|
use HxTrait;
|
||||||
|
|
||||||
public const string SESSION_REDIRECT_KEY = '_profile_completion_redirect';
|
public const string SESSION_REDIRECT_KEY = '_profile_completion_redirect';
|
||||||
|
public const string SESSION_FORCE_PASSWORD_CHANGE_KEY = '_force_password_change';
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ApiClient $apiClient,
|
private readonly ApiClient $apiClient,
|
||||||
@@ -68,6 +69,8 @@ class PersonalDataController extends AbstractController
|
|||||||
{
|
{
|
||||||
/** @var User $user */
|
/** @var User $user */
|
||||||
$user = $this->getUser();
|
$user = $this->getUser();
|
||||||
|
$forcePasswordChange = $request->hasSession()
|
||||||
|
&& true === $request->getSession()->get(self::SESSION_FORCE_PASSWORD_CHANGE_KEY, false);
|
||||||
$email = $user->getEmail();
|
$email = $user->getEmail();
|
||||||
$personalData = $this->loadPersonalData($user);
|
$personalData = $this->loadPersonalData($user);
|
||||||
$newsletterSubscribed = $this->newsletterManager->hasConfirmedOptIn($email);
|
$newsletterSubscribed = $this->newsletterManager->hasConfirmedOptIn($email);
|
||||||
@@ -75,6 +78,8 @@ class PersonalDataController extends AbstractController
|
|||||||
|
|
||||||
$personalDataForm = $this->createForm(AccountPersonalDataType::class, $personalData, [
|
$personalDataForm = $this->createForm(AccountPersonalDataType::class, $personalData, [
|
||||||
'attr' => ['novalidate' => 'novalidate'],
|
'attr' => ['novalidate' => 'novalidate'],
|
||||||
|
'require_current_password' => false === $forcePasswordChange,
|
||||||
|
'force_password_change' => $forcePasswordChange,
|
||||||
]);
|
]);
|
||||||
$personalDataForm->handleRequest($request);
|
$personalDataForm->handleRequest($request);
|
||||||
|
|
||||||
@@ -86,6 +91,9 @@ class PersonalDataController extends AbstractController
|
|||||||
if ('' !== $newPassword) {
|
if ('' !== $newPassword) {
|
||||||
$user->setPassword($this->passwordHasher->hashPassword($user, $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
|
// Invalidate cached bookings to ensure edit mode shows updated applicant data
|
||||||
$this->dataLoader->invalidateUserBookingCaches($user);
|
$this->dataLoader->invalidateUserBookingCaches($user);
|
||||||
@@ -95,17 +103,21 @@ class PersonalDataController extends AbstractController
|
|||||||
$user->setProfileComplete($isComplete);
|
$user->setProfileComplete($isComplete);
|
||||||
$this->entityManager->flush();
|
$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->addFlash('success', 'Deine persönlichen Daten wurden aktualisiert');
|
||||||
$this->logger->info('Updated personal data', [
|
$this->logger->info('Updated personal data', [
|
||||||
'email' => $user->getEmail(),
|
'email' => $user->getEmail(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Handle profile completion redirect
|
// Handle profile completion redirect
|
||||||
$session = $request->getSession();
|
$session = true === $request->hasSession() ? $request->getSession() : null;
|
||||||
$redirectUrl = $session->get(self::SESSION_REDIRECT_KEY);
|
$redirectUrl = $session?->get(self::SESSION_REDIRECT_KEY);
|
||||||
|
|
||||||
if (null !== $redirectUrl && true === $isComplete) {
|
if (null !== $redirectUrl && true === $isComplete) {
|
||||||
$session->remove(self::SESSION_REDIRECT_KEY);
|
$session?->remove(self::SESSION_REDIRECT_KEY);
|
||||||
|
|
||||||
return $this->redirect($redirectUrl);
|
return $this->redirect($redirectUrl);
|
||||||
}
|
}
|
||||||
@@ -121,6 +133,7 @@ class PersonalDataController extends AbstractController
|
|||||||
'personalDataForm' => $personalDataForm,
|
'personalDataForm' => $personalDataForm,
|
||||||
'newsletterSubscribed' => $newsletterSubscribed,
|
'newsletterSubscribed' => $newsletterSubscribed,
|
||||||
'newsletterPendingConfirmation' => $newsletterPendingConfirmation,
|
'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\BookingCreateStep4Type;
|
||||||
use App\Form\Model\BookingDto;
|
use App\Form\Model\BookingDto;
|
||||||
use App\Form\Model\ParticipantDto;
|
use App\Form\Model\ParticipantDto;
|
||||||
|
use App\Repository\UserRepository;
|
||||||
use App\Htmx\HxTrait;
|
use App\Htmx\HxTrait;
|
||||||
use App\Service\BookingConfigurator;
|
use App\Service\BookingConfigurator;
|
||||||
use App\Service\BookingCreateContextFactory;
|
use App\Service\BookingCreateContextFactory;
|
||||||
|
use App\Service\BookingGuestAccountManager;
|
||||||
use App\Service\BookingSessionManager;
|
use App\Service\BookingSessionManager;
|
||||||
use App\Service\NewsletterManager;
|
use App\Service\NewsletterManager;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
@@ -38,9 +40,11 @@ class Step4Controller extends AbstractBookingCreateController
|
|||||||
private readonly BookingConfigurator $bookingService,
|
private readonly BookingConfigurator $bookingService,
|
||||||
private readonly BookingSessionManager $bookingSessionService,
|
private readonly BookingSessionManager $bookingSessionService,
|
||||||
private readonly BookingCreateContextFactory $createContextFactory,
|
private readonly BookingCreateContextFactory $createContextFactory,
|
||||||
|
private readonly BookingGuestAccountManager $guestAccountManager,
|
||||||
private readonly ApiClient $apiClient,
|
private readonly ApiClient $apiClient,
|
||||||
private readonly CacheInterface $cache,
|
private readonly CacheInterface $cache,
|
||||||
private readonly NewsletterManager $doubleOptInService,
|
private readonly NewsletterManager $doubleOptInService,
|
||||||
|
private readonly UserRepository $userRepository,
|
||||||
private readonly LoggerInterface $logger,
|
private readonly LoggerInterface $logger,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
@@ -64,10 +68,13 @@ class Step4Controller extends AbstractBookingCreateController
|
|||||||
|
|
||||||
$newsletterTargetEmail = $this->resolveNewsletterTargetEmail($bookingCreateDto);
|
$newsletterTargetEmail = $this->resolveNewsletterTargetEmail($bookingCreateDto);
|
||||||
$newsletterOptInVisible = null !== $newsletterTargetEmail;
|
$newsletterOptInVisible = null !== $newsletterTargetEmail;
|
||||||
|
$guestAccountTargetEmail = $this->resolveGuestAccountTargetEmail($bookingCreateDto);
|
||||||
|
$guestAccountOptInVisible = null !== $guestAccountTargetEmail;
|
||||||
|
|
||||||
$form = $this->createForm(BookingCreateStep4Type::class, $bookingCreateDto, [
|
$form = $this->createForm(BookingCreateStep4Type::class, $bookingCreateDto, [
|
||||||
'show_newsletter_opt_in' => $newsletterOptInVisible,
|
'show_newsletter_opt_in' => $newsletterOptInVisible,
|
||||||
'newsletter_target_email' => $newsletterTargetEmail,
|
'newsletter_target_email' => $newsletterTargetEmail,
|
||||||
|
'show_guest_account_opt_in' => $guestAccountOptInVisible,
|
||||||
]);
|
]);
|
||||||
$form->handleRequest($request);
|
$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
|
// Success: Store booking data in flash for conversion tracking
|
||||||
$bookingCreateContext = $this->createContextFactory->createWithParticipantPrices($bookingCreateDto);
|
$bookingCreateContext = $this->createContextFactory->createWithParticipantPrices($bookingCreateDto);
|
||||||
$this->addFlash('booking_number', $bookingResponse->bookingNumber);
|
$this->addFlash('booking_number', $bookingResponse->bookingNumber);
|
||||||
@@ -224,6 +252,36 @@ class Step4Controller extends AbstractBookingCreateController
|
|||||||
return $participant;
|
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.
|
* 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)]
|
#[ORM\Column(type: 'string', length: 64, nullable: true)]
|
||||||
private ?string $passwordResetNonce = null;
|
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)
|
public function __construct(string $email)
|
||||||
{
|
{
|
||||||
$this->email = self::normalizeEmail($email);
|
$this->email = self::normalizeEmail($email);
|
||||||
@@ -202,6 +211,78 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
|||||||
return $this;
|
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
|
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,
|
'label' => false,
|
||||||
'inherit_data' => true,
|
'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, [
|
->add('newPassword', RepeatedType::class, [
|
||||||
'type' => PasswordType::class,
|
'type' => PasswordType::class,
|
||||||
'mapped' => false,
|
'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
|
public function configureOptions(OptionsResolver $resolver): void
|
||||||
{
|
{
|
||||||
$resolver->setDefaults([
|
$resolver->setDefaults([
|
||||||
'data_class' => PersonalData::class,
|
'data_class' => PersonalData::class,
|
||||||
|
'require_current_password' => true,
|
||||||
|
'force_password_change' => false,
|
||||||
'validation_groups' => function (FormInterface $form): array {
|
'validation_groups' => function (FormInterface $form): array {
|
||||||
$groups = ['Default', 'personal_data'];
|
$groups = ['Default', 'personal_data'];
|
||||||
|
|
||||||
if ($this->isPasswordSectionUsed($form)) {
|
if (true === $form->getConfig()->getOption('force_password_change') || $this->isPasswordSectionUsed($form)) {
|
||||||
$groups[] = 'password_change';
|
$groups[] = 'password_change';
|
||||||
}
|
}
|
||||||
|
|
||||||
return $groups;
|
return $groups;
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$resolver->setAllowedTypes('require_current_password', 'bool');
|
||||||
|
$resolver->setAllowedTypes('force_password_change', 'bool');
|
||||||
}
|
}
|
||||||
|
|
||||||
private function isPasswordSectionUsed(FormInterface $form): 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();
|
|| '' !== (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
|
$builder
|
||||||
->add('confirmationAccepted', CheckboxType::class, [
|
->add('confirmationAccepted', CheckboxType::class, [
|
||||||
'label' => 'Ich bestätige, dass alle Angaben korrekt sind und möchte verbindlich buchen.',
|
'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,
|
'data_class' => BookingDto::class,
|
||||||
'show_newsletter_opt_in' => false,
|
'show_newsletter_opt_in' => false,
|
||||||
'newsletter_target_email' => null,
|
'newsletter_target_email' => null,
|
||||||
|
'show_guest_account_opt_in' => false,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$resolver->setAllowedTypes('show_newsletter_opt_in', 'bool');
|
$resolver->setAllowedTypes('show_newsletter_opt_in', 'bool');
|
||||||
$resolver->setAllowedTypes('newsletter_target_email', ['null', 'string']);
|
$resolver->setAllowedTypes('newsletter_target_email', ['null', 'string']);
|
||||||
|
$resolver->setAllowedTypes('show_guest_account_opt_in', 'bool');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -26,4 +26,16 @@ class UserRepository extends ServiceEntityRepository
|
|||||||
->getQuery()
|
->getQuery()
|
||||||
->getOneOrNullResult();
|
->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->setPassword($this->passwordHasher->hashPassword($user, $plainPassword));
|
||||||
$user->setPasswordResetNonce($this->generateNonce());
|
$user->setPasswordResetNonce($this->generateNonce());
|
||||||
|
if (true === $user->hasPendingWelcomeInvite()) {
|
||||||
|
$user->markWelcomeActivated();
|
||||||
|
}
|
||||||
|
|
||||||
$this->entityManager->flush();
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ class PublicFormRateLimiter
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly RateLimiterFactory $registrationLimiter,
|
private readonly RateLimiterFactory $registrationLimiter,
|
||||||
private readonly RateLimiterFactory $passwordResetLimiter,
|
private readonly RateLimiterFactory $passwordResetLimiter,
|
||||||
|
private readonly RateLimiterFactory $portalAccessRequestLimiter,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,6 +28,11 @@ class PublicFormRateLimiter
|
|||||||
return $this->consume($this->passwordResetLimiter, $email, $request);
|
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
|
public function createBucketKey(string $email, ?string $clientIp): string
|
||||||
{
|
{
|
||||||
return hash('sha256', $this->normalizeEmail($email).'|'.($clientIp ?? 'unknown'));
|
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>
|
</div>
|
||||||
{% endif %}
|
{% 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="p-4 lg:p-8">
|
||||||
<div class="divide-y divide-primary-bg/40">
|
<div class="divide-y divide-primary-bg/40">
|
||||||
<div class="grid md:grid-cols-2 gap-x-8 gap-y-4 pb-8">
|
<div class="grid md:grid-cols-2 gap-x-8 gap-y-4 pb-8">
|
||||||
@@ -72,7 +80,9 @@
|
|||||||
<h3 class="text-white">
|
<h3 class="text-white">
|
||||||
Passwort ändern
|
Passwort ändern
|
||||||
</h3>
|
</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.first, { 'label_attr': { 'class': 'text-white' } }) }}
|
||||||
{{ form_row(personalDataForm.newPassword.second, { 'label_attr': { 'class': 'text-white' } }) }}
|
{{ form_row(personalDataForm.newPassword.second, { 'label_attr': { 'class': 'text-white' } }) }}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -486,6 +486,11 @@
|
|||||||
'label_attr': {'class': 'text-base font-semibold'}
|
'label_attr': {'class': 'text-base font-semibold'}
|
||||||
}) }}
|
}) }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if form.guestAccountOptIn is defined %}
|
||||||
|
{{ form_row(form.guestAccountOptIn, {
|
||||||
|
'label_attr': {'class': 'text-base font-semibold'}
|
||||||
|
}) }}
|
||||||
|
{% endif %}
|
||||||
{{ form_row(form.confirmationAccepted, {
|
{{ form_row(form.confirmationAccepted, {
|
||||||
'label_attr': {'class': 'text-base font-semibold'}
|
'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
|
Jetzt registrieren
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</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>
|
||||||
<div>
|
<div>
|
||||||
<h3 class="mb-2 text-white">
|
<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();
|
$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
|
public function testParseBookingResponseWithPurchaseVoucher(): void
|
||||||
{
|
{
|
||||||
$xmlContent = '<?xml version="1.0" encoding="utf-8"?>
|
$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);
|
], $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
|
public function testIndexDoesNotPersistInvalidFormSubmission(): void
|
||||||
{
|
{
|
||||||
$controller = $this->createController(true, false, $this->validator(), 'NewPassw0rd!');
|
$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());
|
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
|
public function testNewPasswordResetRequestInvalidatesPreviousLink(): void
|
||||||
{
|
{
|
||||||
$user = new User('[email protected]');
|
$user = new User('[email protected]');
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ class TestPublicFormRateLimiter extends PublicFormRateLimiter
|
|||||||
return $this->consume($email, $request);
|
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
|
public function createBucketKey(string $email, ?string $clientIp): string
|
||||||
{
|
{
|
||||||
return hash('sha256', mb_strtolower(trim($email)).'|'.($clientIp ?? 'unknown'));
|
return hash('sha256', mb_strtolower(trim($email)).'|'.($clientIp ?? 'unknown'));
|
||||||
|
|||||||
Reference in new issue
Block a user