From cfe901b9c2cf704957f48f16e9c5358c5ab9c839 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Tue, 8 Sep 2026 15:41:28 +0200 Subject: [PATCH] feat: include accommodation bookings in db anonymization --- src/Command/DbAnonymizeCommand.php | 3 +- src/Service/DatabaseAnonymizer.php | 81 +++++++++++++++++++++--- tests/Command/DbAnonymizeCommandTest.php | 3 +- tests/Service/DatabaseAnonymizerTest.php | 67 ++++++++++++++++++++ 4 files changed, 142 insertions(+), 12 deletions(-) diff --git a/src/Command/DbAnonymizeCommand.php b/src/Command/DbAnonymizeCommand.php index 4463a31..25d2630 100644 --- a/src/Command/DbAnonymizeCommand.php +++ b/src/Command/DbAnonymizeCommand.php @@ -50,11 +50,12 @@ final class DbAnonymizeCommand extends Command } $io->success(sprintf( - 'Anonymized %d users, %d newsletter consents, %d newsletter opt-in requests, and %d booking drafts.', + 'Anonymized %d users, %d newsletter consents, %d newsletter opt-in requests, %d booking drafts, and %d accommodation bookings.', $report['users'], $report['newsletterConsents'], $report['newsletterOptInRequests'], $report['bookingEditDrafts'], + $report['accommodationBookings'], )); return Command::SUCCESS; diff --git a/src/Service/DatabaseAnonymizer.php b/src/Service/DatabaseAnonymizer.php index 7758139..b12e751 100644 --- a/src/Service/DatabaseAnonymizer.php +++ b/src/Service/DatabaseAnonymizer.php @@ -5,12 +5,13 @@ declare(strict_types=1); namespace App\Service; use App\Entity\BookingEditDraft; +use App\Entity\Groups\AccommodationBooking; use App\Entity\NewsletterConsent; use App\Entity\NewsletterOptInRequest; use App\Entity\User; +use Doctrine\ORM\EntityManagerInterface; use Faker\Factory; use Faker\Generator; -use Doctrine\ORM\EntityManagerInterface; use Psr\Log\LoggerInterface; /** @@ -49,7 +50,7 @@ class DatabaseAnonymizer } /** - * @return array{users: int, newsletterConsents: int, newsletterOptInRequests: int, bookingEditDrafts: int} + * @return array{users: int, newsletterConsents: int, newsletterOptInRequests: int, bookingEditDrafts: int, accommodationBookings: int} */ public function anonymizeAll(int $batchSize = self::DEFAULT_BATCH_SIZE): array { @@ -80,6 +81,13 @@ class DatabaseAnonymizer fn (BookingEditDraft $draft) => $this->anonymizeBookingEditDraft($draft), $batchSize, ), + // Last, so that a booking whose email matches a mirrored BusPro account resolves to + // the synthetic identity the user pass has already minted for that person. + 'accommodationBookings' => $this->anonymizeQuery( + 'SELECT b FROM App\Entity\Groups\AccommodationBooking b ORDER BY b.id ASC', + fn (AccommodationBooking $booking) => $this->anonymizeAccommodationBooking($booking), + $batchSize, + ), ]; $this->entityManager->flush(); @@ -108,8 +116,8 @@ class DatabaseAnonymizer $consent->setEmail($identity['email']); $consent->setNames( - $this->shouldReplaceName($consent->getFirstName()) ? $identity['firstName'] : $consent->getFirstName(), - $this->shouldReplaceName($consent->getLastName()) ? $identity['lastName'] : $consent->getLastName(), + $this->hasReplaceableValue($consent->getFirstName()) ? $identity['firstName'] : $consent->getFirstName(), + $this->hasReplaceableValue($consent->getLastName()) ? $identity['lastName'] : $consent->getLastName(), ); } @@ -123,8 +131,8 @@ class DatabaseAnonymizer $request->setEmail($identity['email']); $request->setNames( - $this->shouldReplaceName($request->getFirstName()) ? $identity['firstName'] : $request->getFirstName(), - $this->shouldReplaceName($request->getLastName()) ? $identity['lastName'] : $request->getLastName(), + $this->hasReplaceableValue($request->getFirstName()) ? $identity['firstName'] : $request->getFirstName(), + $this->hasReplaceableValue($request->getLastName()) ? $identity['lastName'] : $request->getLastName(), ); } @@ -136,6 +144,58 @@ class DatabaseAnonymizer $draft->replaceFormData($formData); } + /** + * The uuid is deliberately left alone: it is what the signed customer access link resolves + * against, so regenerating it would silently invalidate every link in an anonymized dump. + * The salutation stays too, being no more identifying than the gender it stands for. + */ + public function anonymizeAccommodationBooking(AccommodationBooking $booking): void + { + $identity = $this->resolveIdentity( + email: $booking->getEmail(), + firstName: $booking->getFirstName(), + lastName: $booking->getLastName(), + ); + + // Only fields that actually hold something are replaced, so a half-prepared draft stays + // half-prepared instead of becoming a record the `edit` group would suddenly accept. + if ($this->hasReplaceableValue($booking->getGroupName())) { + $booking->setGroupName($identity['groupName']); + } + + if ($this->hasReplaceableValue($booking->getFirstName())) { + $booking->setFirstName($identity['firstName']); + } + + if ($this->hasReplaceableValue($booking->getLastName())) { + $booking->setLastName($identity['lastName']); + } + + if ($this->hasReplaceableValue($booking->getEmail())) { + $booking->setEmail($identity['email']); + } + + if ($this->hasReplaceableValue($booking->getPhone())) { + $booking->setPhone($identity['phone']); + } + + if ($this->hasReplaceableValue($booking->getStreet())) { + $booking->setStreet($identity['street']); + } + + if ($this->hasReplaceableValue($booking->getZip())) { + $booking->setZip($identity['postCode']); + } + + if ($this->hasReplaceableValue($booking->getCity())) { + $booking->setCity($identity['city']); + } + + if ($this->hasReplaceableValue($booking->getRemarks())) { + $booking->setRemarks($this->placeholderText($identity, 'remarks')); + } + } + /** * @param callable(object):void $anonymize */ @@ -230,7 +290,7 @@ class DatabaseAnonymizer } /** - * @param array $personalData + * @param array $personalData * @param array $identity * * @return array @@ -265,7 +325,7 @@ class DatabaseAnonymizer } /** - * @param array $addressData + * @param array $addressData * @param array $identity * * @return array @@ -296,7 +356,7 @@ class DatabaseAnonymizer } /** - * @param array $bankAccountData + * @param array $bankAccountData * @param array|null $identity * * @return array @@ -385,6 +445,7 @@ class DatabaseAnonymizer 'city' => $this->faker->city(), 'country' => self::SYNTHETIC_COUNTRY, 'district' => $this->faker->citySuffix(), + 'groupName' => $this->faker->company(), 'accountHolder' => $firstName.' '.$lastName, 'bankName' => $this->faker->company(), 'iban' => self::SYNTHETIC_IBAN, @@ -403,7 +464,7 @@ class DatabaseAnonymizer return $type.':'.mb_strtolower($normalized); } - private function shouldReplaceName(?string $value): bool + private function hasReplaceableValue(?string $value): bool { return null !== $this->stringOrNull($value); } diff --git a/tests/Command/DbAnonymizeCommandTest.php b/tests/Command/DbAnonymizeCommandTest.php index 61ecdca..1e5f46c 100644 --- a/tests/Command/DbAnonymizeCommandTest.php +++ b/tests/Command/DbAnonymizeCommandTest.php @@ -35,6 +35,7 @@ class DbAnonymizeCommandTest extends TestCase 'newsletterConsents' => 2, 'newsletterOptInRequests' => 3, 'bookingEditDrafts' => 4, + 'accommodationBookings' => 5, ]); $tester = new CommandTester($this->createCommand($anonymizer, 'dev')); @@ -42,7 +43,7 @@ class DbAnonymizeCommandTest extends TestCase self::assertSame(Command::SUCCESS, $tester->getStatusCode()); self::assertStringContainsString( - 'Anonymized 1 users, 2 newsletter consents, 3 newsletter opt-in requests, and 4 booking drafts.', + 'Anonymized 1 users, 2 newsletter consents, 3 newsletter opt-in requests, 4 booking drafts, and 5 accommodation bookings.', $this->normalizeConsoleOutput($tester->getDisplay()), ); } diff --git a/tests/Service/DatabaseAnonymizerTest.php b/tests/Service/DatabaseAnonymizerTest.php index 68cbcd4..cf965fa 100644 --- a/tests/Service/DatabaseAnonymizerTest.php +++ b/tests/Service/DatabaseAnonymizerTest.php @@ -5,9 +5,12 @@ declare(strict_types=1); namespace App\Tests\Service; use App\Entity\BookingEditDraft; +use App\Entity\Groups\AccommodationBooking; use App\Entity\NewsletterConsent; use App\Entity\NewsletterOptInRequest; use App\Entity\User; +use App\Enum\Groups\AccommodationBookingOrigin; +use App\Enum\Groups\AccommodationBookingStatus; use App\Service\DatabaseAnonymizer; use Doctrine\ORM\EntityManagerInterface; use PHPUnit\Framework\TestCase; @@ -127,6 +130,25 @@ class DatabaseAnonymizerTest extends TestCase $draft->setDateId(55); $draft->setHotelId(77); + $booking = new AccommodationBooking(); + $booking->setGroupName('Gymnasium Musterstadt, Klasse 10b'); + $booking->setSalutation('Frau'); + $booking->setFirstName('Mia'); + $booking->setLastName('Muster'); + $booking->setEmail('Mia.Muster@example.com'); + $booking->setPhone('+49123456789'); + $booking->setStreet('Main Street 1'); + $booking->setZip('12345'); + $booking->setCity('Berlin'); + $booking->setRemarks('Ruft immer donnerstags an.'); + $booking->setDateFrom(new \DateTimeImmutable('2027-01-10')); + $booking->setDateTo(new \DateTimeImmutable('2027-01-17')); + $booking->setPaxCount(42); + $booking->setPriceSnapshot(['accommodation' => 1000], 1000, 'EUR', 2); + $booking->setStatus(AccommodationBookingStatus::Confirmed); + $booking->setOrigin(AccommodationBookingOrigin::Direct); + $originalBookingUuid = $booking->getUuid(); + $originalConsentCreatedAt = $consent->getCreatedAt(); $originalConsentUpdatedAt = $consent->getUpdatedAt(); $originalRequestCreatedAt = $request->getCreatedAt(); @@ -137,6 +159,7 @@ class DatabaseAnonymizerTest extends TestCase $service->anonymizeNewsletterConsent($consent); $service->anonymizeNewsletterOptInRequest($request); $service->anonymizeBookingEditDraft($draft); + $service->anonymizeAccommodationBooking($booking); self::assertStringEndsWith('@example.test', $user->getEmail()); self::assertSame('plain-text-password', $user->getPassword()); @@ -186,5 +209,49 @@ class DatabaseAnonymizerTest extends TestCase self::assertSame(77, $draft->getHotelId()); self::assertSame($originalDraftCreatedAt, $draft->getCreatedAt()); self::assertSame($originalDraftUpdatedAt, $draft->getUpdatedAt()); + + self::assertStringEndsWith('@example.test', (string) $booking->getEmail()); + self::assertSame($user->getEmail(), $booking->getEmail()); + self::assertSame($consent->getFirstName(), $booking->getFirstName()); + self::assertSame($consent->getLastName(), $booking->getLastName()); + self::assertNotSame('Gymnasium Musterstadt, Klasse 10b', $booking->getGroupName()); + self::assertNotSame('Ruft immer donnerstags an.', $booking->getRemarks()); + self::assertNotSame('+49123456789', $booking->getPhone()); + self::assertNotSame('Main Street 1', $booking->getStreet()); + self::assertNotSame('12345', $booking->getZip()); + self::assertNotSame('Berlin', $booking->getCity()); + self::assertSame($originalBookingUuid, $booking->getUuid()); + self::assertSame('Frau', $booking->getSalutation()); + self::assertSame('2027-01-10', $booking->getDateFrom()?->format('Y-m-d')); + self::assertSame('2027-01-17', $booking->getDateTo()?->format('Y-m-d')); + self::assertSame(42, $booking->getPaxCount()); + self::assertSame(1000, $booking->getTotalPrice()); + self::assertSame(['accommodation' => 1000], $booking->getPriceBreakdown()); + self::assertSame('EUR', $booking->getPricingCurrency()); + self::assertSame(AccommodationBookingStatus::Confirmed, $booking->getStatus()); + self::assertSame(AccommodationBookingOrigin::Direct, $booking->getOrigin()); + } + + public function testAnonymizeAccommodationBookingLeavesUnsetContactFieldsNull(): void + { + $service = new DatabaseAnonymizer( + $this->createStub(EntityManagerInterface::class), + new NullLogger(), + ); + + $booking = new AccommodationBooking(); + $booking->setEmail('Mia.Muster@example.com'); + + $service->anonymizeAccommodationBooking($booking); + + self::assertStringEndsWith('@example.test', (string) $booking->getEmail()); + self::assertNull($booking->getGroupName()); + self::assertNull($booking->getFirstName()); + self::assertNull($booking->getLastName()); + self::assertNull($booking->getPhone()); + self::assertNull($booking->getStreet()); + self::assertNull($booking->getZip()); + self::assertNull($booking->getCity()); + self::assertNull($booking->getRemarks()); } }