feat: cli command to anonymize local database
This commit is contained in:
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Command;
|
||||||
|
|
||||||
|
use App\Service\DatabaseAnonymizer;
|
||||||
|
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:db:anonymize',
|
||||||
|
description: 'Anonymize personal data in the local database',
|
||||||
|
)]
|
||||||
|
final class DbAnonymizeCommand extends Command
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly DatabaseAnonymizer $databaseAnonymizer,
|
||||||
|
private readonly LoggerInterface $logger,
|
||||||
|
private readonly string $environment,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$io = new SymfonyStyle($input, $output);
|
||||||
|
|
||||||
|
if ('prod' === $this->environment) {
|
||||||
|
$io->error('The database anonymizer is disabled in the prod environment.');
|
||||||
|
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$report = $this->databaseAnonymizer->anonymizeAll();
|
||||||
|
} catch (\Throwable $exception) {
|
||||||
|
$this->logger->error('Database anonymization failed', [
|
||||||
|
'environment' => $this->environment,
|
||||||
|
'error' => $exception->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$io->error($exception->getMessage());
|
||||||
|
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$io->success(sprintf(
|
||||||
|
'Anonymized %d users, %d newsletter consents, %d newsletter opt-in requests, and %d booking drafts.',
|
||||||
|
$report['users'],
|
||||||
|
$report['newsletterConsents'],
|
||||||
|
$report['newsletterOptInRequests'],
|
||||||
|
$report['bookingEditDrafts'],
|
||||||
|
));
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -164,6 +164,20 @@ class BookingEditDraft
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces the form data without touching timestamps.
|
||||||
|
*
|
||||||
|
* Used by anonymization tooling that must not alter audit dates.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $formData
|
||||||
|
*/
|
||||||
|
public function replaceFormData(array $formData): static
|
||||||
|
{
|
||||||
|
$this->formData = $formData;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
public function getCreatedAt(): \DateTimeImmutable
|
public function getCreatedAt(): \DateTimeImmutable
|
||||||
{
|
{
|
||||||
return $this->createdAt;
|
return $this->createdAt;
|
||||||
|
|||||||
@@ -65,6 +65,11 @@ class NewsletterConsent
|
|||||||
return $this->email;
|
return $this->email;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function setEmail(string $email): void
|
||||||
|
{
|
||||||
|
$this->email = mb_strtolower(trim($email));
|
||||||
|
}
|
||||||
|
|
||||||
public function getMailjetListId(): int
|
public function getMailjetListId(): int
|
||||||
{
|
{
|
||||||
return $this->mailjetListId;
|
return $this->mailjetListId;
|
||||||
@@ -90,6 +95,16 @@ class NewsletterConsent
|
|||||||
return $this->lastName;
|
return $this->lastName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getCreatedAt(): \DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUpdatedAt(): \DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
public function isConfirmed(): bool
|
public function isConfirmed(): bool
|
||||||
{
|
{
|
||||||
return null !== $this->confirmedAt && null === $this->revokedAt;
|
return null !== $this->confirmedAt && null === $this->revokedAt;
|
||||||
|
|||||||
@@ -76,6 +76,11 @@ class NewsletterOptInRequest
|
|||||||
return $this->email;
|
return $this->email;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function setEmail(string $email): void
|
||||||
|
{
|
||||||
|
$this->email = mb_strtolower(trim($email));
|
||||||
|
}
|
||||||
|
|
||||||
public function getTokenHash(): string
|
public function getTokenHash(): string
|
||||||
{
|
{
|
||||||
return $this->tokenHash;
|
return $this->tokenHash;
|
||||||
|
|||||||
@@ -0,0 +1,445 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
use App\Entity\BookingEditDraft;
|
||||||
|
use App\Entity\NewsletterConsent;
|
||||||
|
use App\Entity\NewsletterOptInRequest;
|
||||||
|
use App\Entity\User;
|
||||||
|
use Faker\Factory;
|
||||||
|
use Faker\Generator;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anonymizes local database records while keeping identifiers and dates intact.
|
||||||
|
*
|
||||||
|
* The service reuses the same synthetic identity for repeated source identities
|
||||||
|
* during one run so related rows stay linked after anonymization.
|
||||||
|
*/
|
||||||
|
class DatabaseAnonymizer
|
||||||
|
{
|
||||||
|
private const string SYNTHETIC_EMAIL_DOMAIN = 'example.test';
|
||||||
|
private const string SYNTHETIC_COUNTRY = 'DE';
|
||||||
|
private const string SYNTHETIC_IBAN = 'DE89370400440532013000';
|
||||||
|
private const int DEFAULT_BATCH_SIZE = 100;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, array<string, string>>
|
||||||
|
*/
|
||||||
|
private array $identityMap = [];
|
||||||
|
|
||||||
|
private int $identitySequence = 0;
|
||||||
|
|
||||||
|
private readonly Generator $faker;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
private readonly LoggerInterface $logger,
|
||||||
|
) {
|
||||||
|
$this->faker = Factory::create('de_DE');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resetState(): void
|
||||||
|
{
|
||||||
|
$this->identityMap = [];
|
||||||
|
$this->identitySequence = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function anonymizeAll(int $batchSize = self::DEFAULT_BATCH_SIZE): array
|
||||||
|
{
|
||||||
|
if ($batchSize < 1) {
|
||||||
|
throw new \InvalidArgumentException('Batch size must be at least 1.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->resetState();
|
||||||
|
|
||||||
|
$report = [
|
||||||
|
'users' => $this->anonymizeQuery(
|
||||||
|
'SELECT u FROM App\Entity\User u ORDER BY u.id ASC',
|
||||||
|
fn (User $user) => $this->anonymizeUser($user),
|
||||||
|
$batchSize,
|
||||||
|
),
|
||||||
|
'newsletterConsents' => $this->anonymizeQuery(
|
||||||
|
'SELECT c FROM App\Entity\NewsletterConsent c ORDER BY c.id ASC',
|
||||||
|
fn (NewsletterConsent $consent) => $this->anonymizeNewsletterConsent($consent),
|
||||||
|
$batchSize,
|
||||||
|
),
|
||||||
|
'newsletterOptInRequests' => $this->anonymizeQuery(
|
||||||
|
'SELECT r FROM App\Entity\NewsletterOptInRequest r ORDER BY r.id ASC',
|
||||||
|
fn (NewsletterOptInRequest $request) => $this->anonymizeNewsletterOptInRequest($request),
|
||||||
|
$batchSize,
|
||||||
|
),
|
||||||
|
'bookingEditDrafts' => $this->anonymizeQuery(
|
||||||
|
'SELECT d FROM App\Entity\BookingEditDraft d ORDER BY d.id ASC',
|
||||||
|
fn (BookingEditDraft $draft) => $this->anonymizeBookingEditDraft($draft),
|
||||||
|
$batchSize,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
$this->logger->info('Anonymized local database records', $report);
|
||||||
|
|
||||||
|
return $report;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function anonymizeUser(User $user): void
|
||||||
|
{
|
||||||
|
$identity = $this->resolveIdentity(
|
||||||
|
email: $user->getEmail(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$user->setEmail($identity['email']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function anonymizeNewsletterConsent(NewsletterConsent $consent): void
|
||||||
|
{
|
||||||
|
$identity = $this->resolveIdentity(
|
||||||
|
email: $consent->getEmail(),
|
||||||
|
firstName: $consent->getFirstName(),
|
||||||
|
lastName: $consent->getLastName(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$consent->setEmail($identity['email']);
|
||||||
|
$consent->setNames(
|
||||||
|
$this->shouldReplaceName($consent->getFirstName()) ? $identity['firstName'] : $consent->getFirstName(),
|
||||||
|
$this->shouldReplaceName($consent->getLastName()) ? $identity['lastName'] : $consent->getLastName(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function anonymizeNewsletterOptInRequest(NewsletterOptInRequest $request): void
|
||||||
|
{
|
||||||
|
$identity = $this->resolveIdentity(
|
||||||
|
email: $request->getEmail(),
|
||||||
|
firstName: $request->getFirstName(),
|
||||||
|
lastName: $request->getLastName(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$request->setEmail($identity['email']);
|
||||||
|
$request->setNames(
|
||||||
|
$this->shouldReplaceName($request->getFirstName()) ? $identity['firstName'] : $request->getFirstName(),
|
||||||
|
$this->shouldReplaceName($request->getLastName()) ? $identity['lastName'] : $request->getLastName(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function anonymizeBookingEditDraft(BookingEditDraft $draft): void
|
||||||
|
{
|
||||||
|
$formData = $draft->getFormData();
|
||||||
|
$formData = $this->anonymizeDraftFormData($formData);
|
||||||
|
|
||||||
|
$draft->replaceFormData($formData);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param callable(object):void $anonymize
|
||||||
|
*/
|
||||||
|
private function anonymizeQuery(string $dql, callable $anonymize, int $batchSize): int
|
||||||
|
{
|
||||||
|
$query = $this->entityManager->createQuery($dql);
|
||||||
|
$processed = 0;
|
||||||
|
$pendingFlush = 0;
|
||||||
|
|
||||||
|
foreach ($query->toIterable() as $entity) {
|
||||||
|
$anonymize($entity);
|
||||||
|
++$processed;
|
||||||
|
++$pendingFlush;
|
||||||
|
|
||||||
|
if (0 === $pendingFlush % $batchSize) {
|
||||||
|
$this->entityManager->flush();
|
||||||
|
$this->entityManager->clear();
|
||||||
|
$pendingFlush = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($pendingFlush > 0) {
|
||||||
|
$this->entityManager->flush();
|
||||||
|
$this->entityManager->clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $processed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $formData
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function anonymizeDraftFormData(array $formData): array
|
||||||
|
{
|
||||||
|
if (isset($formData['bankAccount']) && true === is_array($formData['bankAccount'])) {
|
||||||
|
$formData['bankAccount'] = $this->anonymizeBankAccountData($formData['bankAccount']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($formData['participants']) && true === is_array($formData['participants'])) {
|
||||||
|
foreach ($formData['participants'] as $index => $participantData) {
|
||||||
|
if (false === is_array($participantData)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$formData['participants'][$index] = $this->anonymizeParticipantData($participantData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $formData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $participantData
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function anonymizeParticipantData(array $participantData): array
|
||||||
|
{
|
||||||
|
$identity = $this->resolveIdentity(
|
||||||
|
email: $this->stringOrNull($participantData['personalData']['email'] ?? null),
|
||||||
|
firstName: $this->stringOrNull($participantData['personalData']['firstName'] ?? null),
|
||||||
|
lastName: $this->stringOrNull($participantData['personalData']['lastName'] ?? null),
|
||||||
|
fullName: $this->stringOrNull($participantData['personalData']['name'] ?? null),
|
||||||
|
fallback: $this->stringOrNull($participantData['licensePlate'] ?? null),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isset($participantData['personalData']) && true === is_array($participantData['personalData'])) {
|
||||||
|
$participantData['personalData'] = $this->anonymizePersonalData($participantData['personalData'], $identity);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($participantData['address']) && true === is_array($participantData['address'])) {
|
||||||
|
$participantData['address'] = $this->anonymizeAddressData($participantData['address'], $identity);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($participantData['roomAssignment']) && true === is_array($participantData['roomAssignment'])) {
|
||||||
|
if (array_key_exists('remarksRoom', $participantData['roomAssignment'])) {
|
||||||
|
$participantData['roomAssignment']['remarksRoom'] = $this->placeholderText($identity, 'remarks');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('licensePlate', $participantData)) {
|
||||||
|
$participantData['licensePlate'] = $this->placeholderText($identity, 'plate');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($participantData['bankAccount']) && true === is_array($participantData['bankAccount'])) {
|
||||||
|
$participantData['bankAccount'] = $this->anonymizeBankAccountData($participantData['bankAccount'], $identity);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $participantData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $personalData
|
||||||
|
* @param array<string, string> $identity
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function anonymizePersonalData(array $personalData, array $identity): array
|
||||||
|
{
|
||||||
|
if (array_key_exists('firstName', $personalData) && null !== $personalData['firstName']) {
|
||||||
|
$personalData['firstName'] = $identity['firstName'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('lastName', $personalData) && null !== $personalData['lastName']) {
|
||||||
|
$personalData['lastName'] = $identity['lastName'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('email', $personalData) && null !== $personalData['email']) {
|
||||||
|
$personalData['email'] = $identity['email'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('mobile', $personalData) && null !== $personalData['mobile']) {
|
||||||
|
$personalData['mobile'] = $identity['mobile'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('phone', $personalData) && null !== $personalData['phone']) {
|
||||||
|
$personalData['phone'] = $identity['phone'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('name', $personalData) && null !== $personalData['name']) {
|
||||||
|
$personalData['name'] = $identity['lastName'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $personalData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $addressData
|
||||||
|
* @param array<string, string> $identity
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function anonymizeAddressData(array $addressData, array $identity): array
|
||||||
|
{
|
||||||
|
if (array_key_exists('street', $addressData) && null !== $addressData['street']) {
|
||||||
|
$addressData['street'] = $identity['street'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('postCode', $addressData) && null !== $addressData['postCode']) {
|
||||||
|
$addressData['postCode'] = $identity['postCode'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('city', $addressData) && null !== $addressData['city']) {
|
||||||
|
$addressData['city'] = $identity['city'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('country', $addressData) && null !== $addressData['country']) {
|
||||||
|
$addressData['country'] = $identity['country'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('district', $addressData) && null !== $addressData['district']) {
|
||||||
|
$addressData['district'] = $identity['district'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $addressData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $bankAccountData
|
||||||
|
* @param array<string, string>|null $identity
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function anonymizeBankAccountData(array $bankAccountData, ?array $identity = null): array
|
||||||
|
{
|
||||||
|
$identity ??= $this->resolveIdentity(
|
||||||
|
fullName: $this->stringOrNull($bankAccountData['accountHolder'] ?? null),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (array_key_exists('iban', $bankAccountData) && null !== $bankAccountData['iban']) {
|
||||||
|
$bankAccountData['iban'] = $identity['iban'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('accountHolder', $bankAccountData) && null !== $bankAccountData['accountHolder']) {
|
||||||
|
$bankAccountData['accountHolder'] = $identity['accountHolder'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('bankName', $bankAccountData) && null !== $bankAccountData['bankName']) {
|
||||||
|
$bankAccountData['bankName'] = $identity['bankName'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('bic', $bankAccountData) && null !== $bankAccountData['bic']) {
|
||||||
|
$bankAccountData['bic'] = $identity['bic'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $bankAccountData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function resolveIdentity(?string $email = null, ?string $firstName = null, ?string $lastName = null, ?string $fullName = null, ?string $fallback = null): array
|
||||||
|
{
|
||||||
|
$aliases = [];
|
||||||
|
|
||||||
|
foreach ([
|
||||||
|
$this->normalizeIdentityKey('email', $email),
|
||||||
|
$this->normalizeIdentityKey('name', null !== $fullName ? $fullName : trim(($firstName ?? '').' '.($lastName ?? ''))),
|
||||||
|
$this->normalizeIdentityKey('fallback', $fallback),
|
||||||
|
] as $alias) {
|
||||||
|
if (null !== $alias) {
|
||||||
|
$aliases[] = $alias;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($aliases as $alias) {
|
||||||
|
if (isset($this->identityMap[$alias])) {
|
||||||
|
$identity = $this->identityMap[$alias];
|
||||||
|
foreach ($aliases as $candidateAlias) {
|
||||||
|
$this->identityMap[$candidateAlias] = $identity;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $identity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$identity = $this->createIdentity();
|
||||||
|
foreach ($aliases as $alias) {
|
||||||
|
$this->identityMap[$alias] = $identity;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $identity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function createIdentity(): array
|
||||||
|
{
|
||||||
|
++$this->identitySequence;
|
||||||
|
$sequence = $this->identitySequence;
|
||||||
|
|
||||||
|
$firstName = $this->faker->firstName();
|
||||||
|
$lastName = $this->faker->lastName();
|
||||||
|
$emailLocal = sprintf('%s.%s.%04d', $this->slug($firstName), $this->slug($lastName), $sequence);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'firstName' => $firstName,
|
||||||
|
'lastName' => $lastName,
|
||||||
|
'email' => sprintf('%s@%s', $emailLocal, self::SYNTHETIC_EMAIL_DOMAIN),
|
||||||
|
'mobile' => $this->faker->numerify('01#########'),
|
||||||
|
'phone' => $this->faker->numerify('0##########'),
|
||||||
|
'street' => sprintf('%s %s', $this->faker->streetName(), $this->faker->buildingNumber()),
|
||||||
|
'postCode' => $this->faker->postcode(),
|
||||||
|
'city' => $this->faker->city(),
|
||||||
|
'country' => self::SYNTHETIC_COUNTRY,
|
||||||
|
'district' => $this->faker->citySuffix(),
|
||||||
|
'accountHolder' => $firstName.' '.$lastName,
|
||||||
|
'bankName' => $this->faker->company(),
|
||||||
|
'iban' => self::SYNTHETIC_IBAN,
|
||||||
|
'bic' => 'GENODEF1SYN',
|
||||||
|
'licensePlate' => sprintf('SYN-%05d', $sequence),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeIdentityKey(string $type, ?string $value): ?string
|
||||||
|
{
|
||||||
|
$normalized = $this->stringOrNull($value);
|
||||||
|
if (null === $normalized) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $type.':'.mb_strtolower($normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function shouldReplaceName(?string $value): bool
|
||||||
|
{
|
||||||
|
return null !== $this->stringOrNull($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function placeholderText(array $identity, string $suffix): string
|
||||||
|
{
|
||||||
|
return sprintf('%s-%s', $identity['lastName'], $suffix);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function stringOrNull(mixed $value): ?string
|
||||||
|
{
|
||||||
|
if (null === $value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (false === is_string($value)) {
|
||||||
|
return trim((string) $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
$trimmed = trim($value);
|
||||||
|
|
||||||
|
return '' === $trimmed ? null : $trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function slug(string $value): string
|
||||||
|
{
|
||||||
|
$normalizedValue = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $value);
|
||||||
|
if (false === $normalizedValue) {
|
||||||
|
$normalizedValue = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalizedValue = strtolower($normalizedValue);
|
||||||
|
$normalizedValue = preg_replace('/[^a-z0-9]+/', '.', $normalizedValue);
|
||||||
|
$normalizedValue = trim((string) $normalizedValue, '.');
|
||||||
|
|
||||||
|
if ('' === $normalizedValue) {
|
||||||
|
return 'user';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $normalizedValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Command;
|
||||||
|
|
||||||
|
use App\Command\DbAnonymizeCommand;
|
||||||
|
use App\Service\DatabaseAnonymizer;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Tester\CommandTester;
|
||||||
|
|
||||||
|
class DbAnonymizeCommandTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testCommandRefusesToRunInProdEnvironment(): void
|
||||||
|
{
|
||||||
|
$anonymizer = $this->createMock(DatabaseAnonymizer::class);
|
||||||
|
$anonymizer->expects(self::never())->method('anonymizeAll');
|
||||||
|
|
||||||
|
$tester = new CommandTester($this->createCommand($anonymizer, 'prod'));
|
||||||
|
$tester->execute([]);
|
||||||
|
|
||||||
|
self::assertSame(Command::FAILURE, $tester->getStatusCode());
|
||||||
|
self::assertStringContainsString('disabled in the prod environment', $tester->getDisplay());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCommandDelegatesOutsideProdEnvironment(): void
|
||||||
|
{
|
||||||
|
$anonymizer = $this->createMock(DatabaseAnonymizer::class);
|
||||||
|
$anonymizer->expects(self::once())
|
||||||
|
->method('anonymizeAll')
|
||||||
|
->willReturn([
|
||||||
|
'users' => 1,
|
||||||
|
'newsletterConsents' => 2,
|
||||||
|
'newsletterOptInRequests' => 3,
|
||||||
|
'bookingEditDrafts' => 4,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tester = new CommandTester($this->createCommand($anonymizer, 'dev'));
|
||||||
|
$tester->execute([]);
|
||||||
|
|
||||||
|
self::assertSame(Command::SUCCESS, $tester->getStatusCode());
|
||||||
|
self::assertStringContainsString('Anonymized 1 users, 2 newsletter consents, 3 newsletter opt-in requests, and 4 booking drafts.', $tester->getDisplay());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createCommand(DatabaseAnonymizer $anonymizer, string $environment): DbAnonymizeCommand
|
||||||
|
{
|
||||||
|
return new DbAnonymizeCommand(
|
||||||
|
$anonymizer,
|
||||||
|
$this->createMock(LoggerInterface::class),
|
||||||
|
$environment,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Service;
|
||||||
|
|
||||||
|
use App\Entity\BookingEditDraft;
|
||||||
|
use App\Entity\NewsletterConsent;
|
||||||
|
use App\Entity\NewsletterOptInRequest;
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Service\DatabaseAnonymizer;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Psr\Log\NullLogger;
|
||||||
|
|
||||||
|
class DatabaseAnonymizerTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testAnonymizeEntityMethodsReuseSharedIdentityAndPreserveDraftTimestamps(): void
|
||||||
|
{
|
||||||
|
$service = new DatabaseAnonymizer(
|
||||||
|
$this->createMock(EntityManagerInterface::class),
|
||||||
|
new NullLogger(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$user = new User('[email protected]');
|
||||||
|
$user->setPassword('plain-text-password');
|
||||||
|
$user->setPersonId(12345);
|
||||||
|
$user->setAddressId(67890);
|
||||||
|
$user->setRoles(['ROLE_ADMIN']);
|
||||||
|
$user->setHotelCodes(['ABC']);
|
||||||
|
$lastLoginAt = new \DateTimeImmutable('2026-06-01 10:00:00');
|
||||||
|
$user->setLastLoginAt($lastLoginAt);
|
||||||
|
$user->setProfileComplete(true);
|
||||||
|
|
||||||
|
$consent = new NewsletterConsent('[email protected]', 42, 'Mia', 'Muster');
|
||||||
|
$consent->markConfirmed(new \DateTimeImmutable('2026-06-02 11:00:00'));
|
||||||
|
|
||||||
|
$request = new NewsletterOptInRequest(
|
||||||
|
'[email protected]',
|
||||||
|
str_repeat('a', 64),
|
||||||
|
new \DateTimeImmutable('2026-07-01 12:00:00'),
|
||||||
|
[11, 22],
|
||||||
|
'Mia',
|
||||||
|
'Muster',
|
||||||
|
);
|
||||||
|
$request->markConfirmed(new \DateTimeImmutable('2026-06-03 13:00:00'));
|
||||||
|
|
||||||
|
$draft = new BookingEditDraft($user, 999, new \DateTimeImmutable('2026-08-15'), [
|
||||||
|
'paymentMethod' => 'debit',
|
||||||
|
'bankAccount' => [
|
||||||
|
'iban' => 'DE02123412341234123412',
|
||||||
|
'accountHolder' => 'Mia Muster',
|
||||||
|
'bankName' => 'Example Bank',
|
||||||
|
'sepaMandateAccepted' => true,
|
||||||
|
],
|
||||||
|
'participants' => [
|
||||||
|
0 => [
|
||||||
|
'personalData' => [
|
||||||
|
'firstName' => 'Mia',
|
||||||
|
'lastName' => 'Muster',
|
||||||
|
'dateOfBirth' => '1990-05-15',
|
||||||
|
'email' => '[email protected]',
|
||||||
|
'mobile' => '+49123456789',
|
||||||
|
'gender' => 'W',
|
||||||
|
'nationality' => 'DE',
|
||||||
|
],
|
||||||
|
'address' => [
|
||||||
|
'street' => 'Main Street 1',
|
||||||
|
'postCode' => '12345',
|
||||||
|
'city' => 'Berlin',
|
||||||
|
'country' => 'DE',
|
||||||
|
'district' => 'Mitte',
|
||||||
|
],
|
||||||
|
'bodyDimensions' => [
|
||||||
|
'height' => '180',
|
||||||
|
'weight' => '70',
|
||||||
|
'shoeSize' => '42',
|
||||||
|
],
|
||||||
|
'roomAssignment' => [
|
||||||
|
'assignedRoomId' => 7,
|
||||||
|
'remarksRoom' => 'Window please',
|
||||||
|
],
|
||||||
|
'licensePlate' => 'B-AB-1234',
|
||||||
|
'services' => [
|
||||||
|
'skiPass' => 10,
|
||||||
|
'courses' => [11],
|
||||||
|
'board' => [12],
|
||||||
|
'rentals' => [13],
|
||||||
|
'rentalInsurance' => 14,
|
||||||
|
'additionalServices' => [15],
|
||||||
|
'transportationOutbound' => 16,
|
||||||
|
'transportationInbound' => 17,
|
||||||
|
'pickup' => 18,
|
||||||
|
'dropOff' => 19,
|
||||||
|
'parking' => true,
|
||||||
|
'insurance' => 20,
|
||||||
|
'bulkInsuranceBooking' => false,
|
||||||
|
],
|
||||||
|
'vouchers' => [
|
||||||
|
'purchaseVoucherCode' => 'PURCHASE-123',
|
||||||
|
'promoVoucherCode' => 'PROMO-456',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
1 => [
|
||||||
|
'personalData' => [
|
||||||
|
'firstName' => 'Mia',
|
||||||
|
'lastName' => 'Muster',
|
||||||
|
'dateOfBirth' => '1990-05-15',
|
||||||
|
'email' => '[email protected]',
|
||||||
|
'mobile' => '+49123456789',
|
||||||
|
'gender' => 'W',
|
||||||
|
'nationality' => 'DE',
|
||||||
|
],
|
||||||
|
'address' => [
|
||||||
|
'street' => 'Main Street 1',
|
||||||
|
'postCode' => '12345',
|
||||||
|
'city' => 'Berlin',
|
||||||
|
'country' => 'DE',
|
||||||
|
],
|
||||||
|
'services' => [
|
||||||
|
'additionalServices' => [99],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$draft->setBookingNumber(321);
|
||||||
|
$draft->setDateId(55);
|
||||||
|
$draft->setHotelId(77);
|
||||||
|
|
||||||
|
$originalConsentCreatedAt = $consent->getCreatedAt();
|
||||||
|
$originalConsentUpdatedAt = $consent->getUpdatedAt();
|
||||||
|
$originalRequestCreatedAt = $request->getCreatedAt();
|
||||||
|
$originalDraftCreatedAt = $draft->getCreatedAt();
|
||||||
|
$originalDraftUpdatedAt = $draft->getUpdatedAt();
|
||||||
|
|
||||||
|
$service->anonymizeUser($user);
|
||||||
|
$service->anonymizeNewsletterConsent($consent);
|
||||||
|
$service->anonymizeNewsletterOptInRequest($request);
|
||||||
|
$service->anonymizeBookingEditDraft($draft);
|
||||||
|
|
||||||
|
self::assertStringEndsWith('@example.test', $user->getEmail());
|
||||||
|
self::assertSame('plain-text-password', $user->getPassword());
|
||||||
|
self::assertSame(12345, $user->getPersonId());
|
||||||
|
self::assertSame(67890, $user->getAddressId());
|
||||||
|
self::assertSame(['ROLE_USER', 'ROLE_ADMIN'], $user->getRoles());
|
||||||
|
self::assertSame(['ABC'], $user->getHotelCodes());
|
||||||
|
self::assertSame($lastLoginAt, $user->getLastLoginAt());
|
||||||
|
self::assertTrue($user->isProfileComplete());
|
||||||
|
|
||||||
|
self::assertSame($user->getEmail(), $consent->getEmail());
|
||||||
|
self::assertSame($consent->getEmail(), $request->getEmail());
|
||||||
|
self::assertSame($consent->getFirstName(), $request->getFirstName());
|
||||||
|
self::assertSame($consent->getLastName(), $request->getLastName());
|
||||||
|
self::assertSame(42, $consent->getMailjetListId());
|
||||||
|
self::assertSame([11, 22], $request->getMailjetListIds());
|
||||||
|
self::assertSame(str_repeat('a', 64), $request->getTokenHash());
|
||||||
|
self::assertTrue($consent->isConfirmed());
|
||||||
|
self::assertTrue($request->isConfirmed());
|
||||||
|
self::assertSame($originalConsentCreatedAt, $consent->getCreatedAt());
|
||||||
|
self::assertSame($originalConsentUpdatedAt, $consent->getUpdatedAt());
|
||||||
|
self::assertSame($originalRequestCreatedAt, $request->getCreatedAt());
|
||||||
|
|
||||||
|
$formData = $draft->getFormData();
|
||||||
|
self::assertSame('debit', $formData['paymentMethod']);
|
||||||
|
self::assertNotSame('DE02123412341234123412', $formData['bankAccount']['iban']);
|
||||||
|
self::assertSame(
|
||||||
|
$formData['participants'][0]['personalData']['firstName'].' '.$formData['participants'][0]['personalData']['lastName'],
|
||||||
|
$formData['bankAccount']['accountHolder'],
|
||||||
|
);
|
||||||
|
self::assertNotSame('Example Bank', $formData['bankAccount']['bankName']);
|
||||||
|
self::assertSame('DE', $formData['participants'][0]['personalData']['nationality']);
|
||||||
|
self::assertSame('W', $formData['participants'][0]['personalData']['gender']);
|
||||||
|
self::assertSame('1990-05-15', $formData['participants'][0]['personalData']['dateOfBirth']);
|
||||||
|
self::assertSame($formData['participants'][0]['personalData']['email'], $formData['participants'][1]['personalData']['email']);
|
||||||
|
self::assertSame($formData['participants'][0]['personalData']['firstName'], $formData['participants'][1]['personalData']['firstName']);
|
||||||
|
self::assertSame($formData['participants'][0]['personalData']['lastName'], $formData['participants'][1]['personalData']['lastName']);
|
||||||
|
self::assertSame($formData['participants'][0]['address']['street'], $formData['participants'][1]['address']['street']);
|
||||||
|
self::assertSame($formData['participants'][0]['address']['postCode'], $formData['participants'][1]['address']['postCode']);
|
||||||
|
self::assertSame($formData['participants'][0]['address']['city'], $formData['participants'][1]['address']['city']);
|
||||||
|
self::assertSame($formData['participants'][0]['address']['country'], $formData['participants'][1]['address']['country']);
|
||||||
|
self::assertSame(7, $formData['participants'][0]['roomAssignment']['assignedRoomId']);
|
||||||
|
self::assertSame('PURCHASE-123', $formData['participants'][0]['vouchers']['purchaseVoucherCode']);
|
||||||
|
self::assertSame('PROMO-456', $formData['participants'][0]['vouchers']['promoVoucherCode']);
|
||||||
|
self::assertSame(321, $draft->getBookingNumber());
|
||||||
|
self::assertSame(55, $draft->getDateId());
|
||||||
|
self::assertSame(77, $draft->getHotelId());
|
||||||
|
self::assertSame($originalDraftCreatedAt, $draft->getCreatedAt());
|
||||||
|
self::assertSame($originalDraftUpdatedAt, $draft->getUpdatedAt());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user