From 52ee6b26b1801a99dfd737dc1d83f73c7fb750ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Sat, 20 Jun 2026 16:48:02 +0200 Subject: [PATCH] feat: full anonymization of buspro xml dumps --- docs/technical-documentation.md | 10 + src/Command/BpnXmlAnonymizeCommand.php | 138 +++++++- src/Service/BpnXmlAnonymizer.php | 305 +++++++++++++++- tests/Command/BpnXmlAnonymizeCommandTest.php | 193 ++++++++++ tests/Command/DbAnonymizeCommandTest.php | 10 +- tests/Service/BpnXmlAnonymizerTest.php | 351 +++++++++++++++---- 6 files changed, 922 insertions(+), 85 deletions(-) create mode 100644 tests/Command/BpnXmlAnonymizeCommandTest.php diff --git a/docs/technical-documentation.md b/docs/technical-documentation.md index e6a4c0a..eac7a6b 100644 --- a/docs/technical-documentation.md +++ b/docs/technical-documentation.md @@ -1103,6 +1103,16 @@ php bin/console app:bpn:replay [--dry-run|-d] [--output|-o ] Replays stored XML requests against the API for debugging. +#### app:bpn:xml-anonymize + +```bash +php bin/console app:bpn:xml-anonymize [] +``` + +Anonymizes a single BPN XML dump or all XML dumps in a folder. Request/response +pairs are processed together so the anonymized identities stay consistent across +both files. + #### app:bpn:xml-sync ```bash diff --git a/src/Command/BpnXmlAnonymizeCommand.php b/src/Command/BpnXmlAnonymizeCommand.php index 6ce6ab6..09a871a 100644 --- a/src/Command/BpnXmlAnonymizeCommand.php +++ b/src/Command/BpnXmlAnonymizeCommand.php @@ -27,8 +27,8 @@ class BpnXmlAnonymizeCommand extends Command protected function configure(): void { $this - ->addArgument('infile', InputArgument::REQUIRED, 'Path to the input XML file') - ->addArgument('outfile', InputArgument::OPTIONAL, 'Path to the output XML file (defaults to infile)') + ->addArgument('infile', InputArgument::REQUIRED, 'Path to the input XML file or folder') + ->addArgument('outfile', InputArgument::OPTIONAL, 'Path to the output XML file or folder (defaults to infile)') ; } @@ -39,19 +39,31 @@ class BpnXmlAnonymizeCommand extends Command $inputFile = (string) $input->getArgument('infile'); $outputFile = $input->getArgument('outfile'); + if (true === is_dir($inputFile)) { + $targetDirectory = null === $outputFile ? $inputFile : (string) $outputFile; + + return $this->anonymizeDirectory($io, $inputFile, $targetDirectory); + } + if (false === is_file($inputFile)) { $io->error(sprintf('Input file not found: %s', $inputFile)); return Command::FAILURE; } + $targetFile = null === $outputFile ? $inputFile : (string) $outputFile; + + return $this->anonymizeSingleFile($io, $inputFile, $targetFile); + } + + private function anonymizeSingleFile(SymfonyStyle $io, string $inputFile, string $targetFile): int + { if (false === is_readable($inputFile)) { $io->error(sprintf('Input file is not readable: %s', $inputFile)); return Command::FAILURE; } - $targetFile = null === $outputFile ? $inputFile : (string) $outputFile; $this->xmlAnonymizer->resetState(); $processingResult = $this->anonymizeFile($io, $inputFile, $targetFile, false); @@ -85,12 +97,120 @@ class BpnXmlAnonymizeCommand extends Command $io->note(sprintf('Paired file anonymized: %s -> %s', $pairedInputFile, $pairedTargetFile)); } - $io->success(sprintf( - 'Anonymized %d persons from %s to %s', - $this->xmlAnonymizer->getLastIdentityCount(), - $inputFile, - $targetFile, - )); + $identityCount = $this->xmlAnonymizer->getLastIdentityCount(); + if (0 === $identityCount) { + $io->warning(sprintf('No anonymizable person data found in %s', $inputFile)); + } else { + $io->success(sprintf( + 'Anonymized %d persons from %s to %s', + $identityCount, + $inputFile, + $targetFile, + )); + } + + return Command::SUCCESS; + } + + private function anonymizeDirectory(SymfonyStyle $io, string $inputDirectory, string $targetDirectory): int + { + if (false === is_readable($inputDirectory)) { + $io->error(sprintf('Input directory is not readable: %s', $inputDirectory)); + + return Command::FAILURE; + } + + if (true === file_exists($targetDirectory) && false === is_dir($targetDirectory)) { + $io->error(sprintf('Output path is not a directory: %s', $targetDirectory)); + + return Command::FAILURE; + } + + if (false === file_exists($targetDirectory) && false === mkdir($targetDirectory, 0777, true) && false === is_dir($targetDirectory)) { + $io->error(sprintf('Unable to create output directory: %s', $targetDirectory)); + + return Command::FAILURE; + } + + $entries = scandir($inputDirectory); + if (false === $entries) { + $io->error(sprintf('Unable to list input directory: %s', $inputDirectory)); + + return Command::FAILURE; + } + + $processed = []; + $fileCount = 0; + $personCount = 0; + + foreach ($entries as $entry) { + if ('.' === $entry || '..' === $entry) { + continue; + } + + $inputPath = $inputDirectory.DIRECTORY_SEPARATOR.$entry; + if (false === is_file($inputPath) || false === str_ends_with($entry, '.xml')) { + continue; + } + + if (true === isset($processed[$inputPath])) { + continue; + } + + $pairedInputPath = $this->derivePairedFilename($inputPath); + if (null !== $pairedInputPath && true === is_file($pairedInputPath) && false === isset($processed[$pairedInputPath])) { + $this->xmlAnonymizer->resetState(); + + $targetPath = $targetDirectory.DIRECTORY_SEPARATOR.$entry; + $result = $this->anonymizeFile($io, $inputPath, $targetPath, false); + if (Command::SUCCESS !== $result) { + return $result; + } + + $processed[$inputPath] = true; + ++$fileCount; + + $pairedEntry = basename($pairedInputPath); + $pairedTargetPath = $targetDirectory.DIRECTORY_SEPARATOR.$pairedEntry; + $result = $this->anonymizeFile($io, $pairedInputPath, $pairedTargetPath, true); + if (Command::SUCCESS !== $result) { + return $result; + } + + $processed[$pairedInputPath] = true; + ++$fileCount; + $personCount += $this->xmlAnonymizer->getLastIdentityCount(); + + $io->note(sprintf('Paired file anonymized: %s -> %s', $pairedInputPath, $pairedTargetPath)); + + continue; + } + + $this->xmlAnonymizer->resetState(); + + $targetPath = $targetDirectory.DIRECTORY_SEPARATOR.$entry; + $result = $this->anonymizeFile($io, $inputPath, $targetPath, false); + if (Command::SUCCESS !== $result) { + return $result; + } + + $processed[$inputPath] = true; + ++$fileCount; + $personCount += $this->xmlAnonymizer->getLastIdentityCount(); + } + + $io->text(sprintf('Processed %d XML files', $fileCount)); + + if (0 === $personCount) { + $io->warning(sprintf('No anonymizable person data found in %s', $inputDirectory)); + } else { + $io->success(sprintf( + 'Anonymized %d persons from %s to %s', + $personCount, + $inputDirectory, + $targetDirectory, + )); + } return Command::SUCCESS; } diff --git a/src/Service/BpnXmlAnonymizer.php b/src/Service/BpnXmlAnonymizer.php index 2180d31..e8ce701 100644 --- a/src/Service/BpnXmlAnonymizer.php +++ b/src/Service/BpnXmlAnonymizer.php @@ -10,11 +10,41 @@ use Faker\Generator; class BpnXmlAnonymizer { private const SAFE_EMAIL_DOMAIN = 'example.com'; + private const SALUTATIONS = ['Herr', 'Frau', 'Divers']; + private const GENDERS = ['M', 'W', 'D']; + private const COUNTRY_CODES = ['AT', 'BE', 'CH', 'DE', 'DK', 'ES', 'FR', 'IT', 'NL', 'NO', 'PL', 'SE']; private Generator $faker; /** - * @var array + * @var array */ private array $identities = []; @@ -40,7 +70,7 @@ class BpnXmlAnonymizer } $xpath = new \DOMXPath($document); - $personNodes = $xpath->query('//anmelder | //teilnehmerliste/teilnehmer | //kundennamen/kunde'); + $personNodes = $xpath->query('//anfrage | //anmelder | //teilnehmerliste/teilnehmer | //kundennamen/kunde | //adressdaten'); if (false === $personNodes) { throw new \RuntimeException('Unable to query person nodes.'); @@ -54,9 +84,17 @@ class BpnXmlAnonymizer $personId = $this->resolvePersonId($personNode, (int) $index); $identity = $this->getOrCreateIdentity($personId); + if ('anfrage' === $personNode->tagName) { + $this->replaceRequestFields($personNode, $identity); + continue; + } + $this->replaceNameFields($personNode, $identity); $this->replaceEmailFields($personNode, $identity); $this->replaceAddressFields($personNode, $identity); + $this->replaceDemographicFields($personNode, $identity); + $this->replaceFinancialFields($personNode, $identity); + $this->replaceMetadataFields($personNode, $identity); } $result = $document->saveXML(); @@ -80,14 +118,47 @@ class BpnXmlAnonymizer private function resolvePersonId(\DOMElement $personNode, int $nodeIndex): string { - foreach (['personid', 'idperson', 'idadresseperson'] as $tagName) { - $child = $this->getDirectChild($personNode, $tagName); - if (null !== $child) { - $value = trim($child->textContent); - if ('' !== $value) { - return $value; + if ('anfrage' === $personNode->tagName || 'adressdaten' === $personNode->tagName) { + $emailNode = $this->getDirectChild($personNode, 'email'); + if (null !== $emailNode) { + $emailValue = trim($emailNode->textContent); + if ('' !== $emailValue) { + return sprintf('email:%s', mb_strtolower($emailValue)); } } + + if ('adressdaten' === $personNode->tagName) { + $communicationNode = $this->getDirectChild($personNode, 'kommunikation'); + if (null !== $communicationNode) { + $nestedEmailNode = $this->getDirectChild($communicationNode, 'email'); + if (null !== $nestedEmailNode) { + $emailValue = trim($nestedEmailNode->textContent); + if ('' !== $emailValue) { + return sprintf('email:%s', mb_strtolower($emailValue)); + } + } + } + } + } + + $currentNode = $personNode; + while ($currentNode instanceof \DOMElement) { + foreach (['personid', 'idperson', 'idadresseperson'] as $tagName) { + $child = $this->getDirectChild($currentNode, $tagName); + if (null !== $child) { + $value = trim($child->textContent); + if ('' !== $value) { + return $value; + } + } + } + + $parentNode = $currentNode->parentNode; + if (false === $parentNode instanceof \DOMElement) { + break; + } + + $currentNode = $parentNode; } $fingerprintKey = $this->buildFingerprintKey($personNode); @@ -161,7 +232,34 @@ class BpnXmlAnonymizer } /** - * @return array{firstName: string, lastName: string, street: string, postalCode: string, city: string, email: string, mobilePhone: string} + * @return array{ + * firstName: string, + * lastName: string, + * salutation: string, + * title: string, + * gender: string, + * nationality: string, + * birthDate: string, + * street: string, + * postalCode: string, + * city: string, + * district: string, + * country: string, + * email: string, + * mobilePhone: string, + * phone: string, + * newsletter: string, + * iban: string, + * userName: string, + * requestKey: string, + * requestPassword: string, + * personId: string, + * addressId: string, + * customerId: string, + * height: string, + * weight: string, + * shoeSize: string + * } */ private function getOrCreateIdentity(string $personId): array { @@ -169,25 +267,63 @@ class BpnXmlAnonymizer return $this->identities[$personId]; } + $identityIndex = \count($this->identities) + 1; $firstName = $this->faker->firstName(); $lastName = $this->faker->lastName(); + $salutation = 'Divers'; + $title = ''; + $gender = 'D'; + $nationality = 'XX'; + $birthDate = '01.01.1980'; $street = sprintf('%s %s', $this->faker->streetName(), $this->faker->buildingNumber()); $postalCode = $this->faker->postcode(); $city = $this->faker->city(); - $mobilePhone = $this->faker->numerify('01#########'); + $district = ''; + $country = 'XX'; + $mobilePhone = sprintf('01%09d', $identityIndex); + $phone = sprintf('02%09d', $identityIndex); + $newsletter = 'False'; + $iban = sprintf('DE%020d', $identityIndex); + $userName = sprintf('anon-user-%d', $identityIndex); + $requestKey = str_pad((string) $identityIndex, 32, '0', STR_PAD_LEFT); + $requestPassword = str_pad((string) ($identityIndex + 1), 32, '0', STR_PAD_LEFT); + $personIdValue = (string) (700000 + $identityIndex); + $addressIdValue = (string) (800000 + $identityIndex); + $customerIdValue = (string) (900000 + $identityIndex); + $height = '170'; + $weight = '70'; + $shoeSize = '42'; - $emailLocal = sprintf('%s.%s', $this->slug($firstName), $this->slug($lastName)); - $email = sprintf('%s@%s', $emailLocal, self::SAFE_EMAIL_DOMAIN); + $email = sprintf('%s@%s', $userName, self::SAFE_EMAIL_DOMAIN); $this->identities[$personId] = [ 'firstName' => $firstName, 'lastName' => $lastName, + 'salutation' => $salutation, + 'title' => $title, + 'gender' => $gender, + 'nationality' => $nationality, + 'birthDate' => $birthDate, 'street' => $street, 'postalCode' => $postalCode, 'city' => $city, + 'district' => $district, + 'country' => $country, 'email' => strtolower($email), 'mobilePhone' => $mobilePhone, + 'phone' => $phone, + 'newsletter' => $newsletter, + 'iban' => $iban, + 'userName' => $userName, + 'requestKey' => $requestKey, + 'requestPassword' => $requestPassword, + 'personId' => $personIdValue, + 'addressId' => $addressIdValue, + 'customerId' => $customerIdValue, + 'height' => $height, + 'weight' => $weight, + 'shoeSize' => $shoeSize, ]; return $this->identities[$personId]; @@ -215,6 +351,10 @@ class BpnXmlAnonymizer if (true === $personNode->hasAttribute('vorname')) { $personNode->setAttribute('vorname', $identity['firstName']); } + + if (true === $personNode->hasAttribute('id') && 'kunde' === $personNode->tagName) { + $personNode->setAttribute('id', $identity['customerId']); + } } /** @@ -224,6 +364,13 @@ class BpnXmlAnonymizer { $communicationNode = $this->getDirectChild($personNode, 'kommunikation'); if (null === $communicationNode) { + if ('anfrage' === $personNode->tagName) { + $emailNode = $this->getDirectChild($personNode, 'email'); + if (null !== $emailNode) { + $emailNode->nodeValue = $identity['email']; + } + } + return; } @@ -236,6 +383,16 @@ class BpnXmlAnonymizer if (null !== $mobileNode) { $mobileNode->nodeValue = $identity['mobilePhone']; } + + $phoneNode = $this->getDirectChild($communicationNode, 'telefonprivat'); + if (null !== $phoneNode) { + $phoneNode->nodeValue = $identity['phone']; + } + + $newsletterNode = $this->getDirectChild($communicationNode, 'newsletter'); + if (null !== $newsletterNode) { + $newsletterNode->nodeValue = $identity['newsletter']; + } } /** @@ -262,6 +419,130 @@ class BpnXmlAnonymizer if (null !== $cityNode) { $cityNode->nodeValue = $identity['city']; } + + $districtNode = $this->getDirectChild($addressNode, 'ortsteil'); + if (null !== $districtNode) { + $districtNode->nodeValue = $identity['district']; + } + + $countryNode = $this->getDirectChild($addressNode, 'land'); + if (null !== $countryNode) { + $countryNode->nodeValue = $identity['country']; + } + + $addressIdNode = $this->getDirectChild($addressNode, 'id'); + if (null !== $addressIdNode) { + $addressIdNode->nodeValue = $identity['addressId']; + } + } + + private function replaceDemographicFields(\DOMElement $personNode, array $identity): void + { + $salutationNode = $this->getDirectChild($personNode, 'anrede'); + if (null !== $salutationNode) { + $salutationNode->nodeValue = $identity['salutation']; + } + + $titleNode = $this->getDirectChild($personNode, 'titel'); + if (null !== $titleNode) { + $titleNode->nodeValue = $identity['title']; + } + + $genderNode = $this->getDirectChild($personNode, 'geschlecht'); + if (null !== $genderNode) { + $genderNode->nodeValue = $identity['gender']; + } + + $nationalityNode = $this->getDirectChild($personNode, 'nationalitaet'); + if (null !== $nationalityNode) { + $nationalityNode->nodeValue = $identity['nationality']; + } + + $birthDateNode = $this->getDirectChild($personNode, 'geburtsdatum'); + if (null !== $birthDateNode) { + $birthDateNode->nodeValue = $identity['birthDate']; + } + + $heightNode = $this->getDirectChild($personNode, 'sonstiges1'); + if (null !== $heightNode) { + $heightNode->nodeValue = $identity['height']; + } + + $weightNode = $this->getDirectChild($personNode, 'sonstiges2'); + if (null !== $weightNode) { + $weightNode->nodeValue = $identity['weight']; + } + + $shoeSizeNode = $this->getDirectChild($personNode, 'sonstiges3'); + if (null !== $shoeSizeNode) { + $shoeSizeNode->nodeValue = $identity['shoeSize']; + } + } + + private function replaceFinancialFields(\DOMElement $personNode, array $identity): void + { + $bankNode = $this->getDirectChild($personNode, 'bankverbindung'); + if (null === $bankNode) { + return; + } + + $ibanNode = $this->getDirectChild($bankNode, 'iban'); + if (null !== $ibanNode) { + $ibanNode->nodeValue = $identity['iban']; + } + } + + private function replaceMetadataFields(\DOMElement $personNode, array $identity): void + { + if (true === $personNode->hasAttribute('id')) { + if ('kunde' === $personNode->tagName) { + $personNode->setAttribute('id', $identity['customerId']); + } elseif ('teilnehmer' === $personNode->tagName || 'anmelder' === $personNode->tagName) { + $personNode->setAttribute('id', $identity['personId']); + } + } + } + + private function replaceRequestFields(\DOMElement $personNode, array $identity): void + { + $userNode = $this->getDirectChild($personNode, 'user'); + if (null !== $userNode) { + $userNode->nodeValue = $identity['userName']; + } + + $keyNode = $this->getDirectChild($personNode, 'key'); + if (null !== $keyNode) { + $keyNode->nodeValue = $identity['requestKey']; + } + + $emailNode = $this->getDirectChild($personNode, 'email'); + if (null !== $emailNode) { + $emailNode->nodeValue = $identity['email']; + } + + $passwordNode = $this->getDirectChild($personNode, 'passwort'); + if (null !== $passwordNode) { + $passwordNode->nodeValue = $identity['requestPassword']; + } + } + + private function findAncestorDirectChild(\DOMElement $personNode, string $ancestorTagName, string $childTagName): ?\DOMElement + { + $currentNode = $personNode; + while ($currentNode instanceof \DOMElement) { + if ($ancestorTagName === $currentNode->tagName) { + return $this->getDirectChild($currentNode, $childTagName); + } + + $parentNode = $currentNode->parentNode; + if (false === $parentNode instanceof \DOMElement) { + break; + } + + $currentNode = $parentNode; + } + + return null; } private function getDirectChild(\DOMElement $parent, string $name): ?\DOMElement diff --git a/tests/Command/BpnXmlAnonymizeCommandTest.php b/tests/Command/BpnXmlAnonymizeCommandTest.php new file mode 100644 index 0000000..fecea2e --- /dev/null +++ b/tests/Command/BpnXmlAnonymizeCommandTest.php @@ -0,0 +1,193 @@ + */ + private array $tempDirectories = []; + + protected function tearDown(): void + { + foreach ($this->tempDirectories as $directory) { + $this->removeDirectory($directory); + } + + $this->tempDirectories = []; + } + + public function testSingleFileStillAnonymizesPairedResponseFile(): void + { + $inputDir = $this->createTempDirectory(); + $outputDir = $this->createTempDirectory(); + $inputFile = $inputDir.'/travel_request.xml'; + $pairedInputFile = $inputDir.'/travel_response.xml'; + $targetFile = $outputDir.'/travel_request.xml'; + $pairedTargetFile = $outputDir.'/travel_response.xml'; + + file_put_contents($inputFile, 'travel-request'); + file_put_contents($pairedInputFile, 'travel-response'); + + $calls = []; + $lastIdentityCount = 0; + $anonymizer = $this->createMock(BpnXmlAnonymizer::class); + $anonymizer->expects(self::once()) + ->method('resetState') + ->willReturnCallback(static function () use (&$calls, &$lastIdentityCount): void { + $calls[] = ['resetState']; + $lastIdentityCount = 0; + }); + $anonymizer->expects(self::exactly(2)) + ->method('anonymize') + ->willReturnCallback(function (string $xml, bool $resetState) use (&$calls, &$lastIdentityCount): string { + $calls[] = ['anonymize', trim($xml), $resetState]; + $lastIdentityCount = true === $resetState ? 1 : 2; + + return sprintf('ANON:%s', trim($xml)); + }); + $anonymizer->method('getLastIdentityCount') + ->willReturnCallback(function () use (&$lastIdentityCount): int { + return $lastIdentityCount; + }); + + $tester = new CommandTester(new BpnXmlAnonymizeCommand($anonymizer)); + $tester->execute([ + 'infile' => $inputFile, + 'outfile' => $targetFile, + ]); + + self::assertSame(Command::SUCCESS, $tester->getStatusCode()); + self::assertSame('ANON:travel-request', trim((string) file_get_contents($targetFile))); + self::assertSame('ANON:travel-response', trim((string) file_get_contents($pairedTargetFile))); + self::assertSame([ + ['resetState'], + ['anonymize', 'travel-request', true], + ['anonymize', 'travel-response', false], + ], $calls); + self::assertStringContainsString('Paired file anonymized', $tester->getDisplay()); + } + + public function testDirectoryInputAnonymizesAllPairsIntoOutputDirectory(): void + { + $inputDir = $this->createTempDirectory(); + $outputDir = $this->createTempDirectory().'/anonymized'; + + file_put_contents($inputDir.'/alpha_request.xml', 'alpha-request'); + file_put_contents($inputDir.'/alpha_response.xml', 'alpha-response'); + file_put_contents($inputDir.'/beta_request.xml', 'beta-request'); + file_put_contents($inputDir.'/beta_response.xml', 'beta-response'); + + $calls = []; + $lastIdentityCount = 0; + $anonymizer = $this->createMock(BpnXmlAnonymizer::class); + $anonymizer->expects(self::exactly(2)) + ->method('resetState') + ->willReturnCallback(static function () use (&$calls, &$lastIdentityCount): void { + $calls[] = ['resetState']; + $lastIdentityCount = 0; + }); + $anonymizer->expects(self::exactly(4)) + ->method('anonymize') + ->willReturnCallback(function (string $xml, bool $resetState) use (&$calls, &$lastIdentityCount): string { + $content = trim($xml); + $calls[] = ['anonymize', $content, $resetState]; + + if (str_contains($content, 'request')) { + $lastIdentityCount = true === $resetState ? 1 : 0; + } else { + $lastIdentityCount = 2; + } + + return sprintf('ANON:%s', $content); + }); + $anonymizer->method('getLastIdentityCount') + ->willReturnCallback(function () use (&$lastIdentityCount): int { + return $lastIdentityCount; + }); + + $tester = new CommandTester(new BpnXmlAnonymizeCommand($anonymizer)); + $tester->execute([ + 'infile' => $inputDir, + 'outfile' => $outputDir, + ]); + + self::assertSame(Command::SUCCESS, $tester->getStatusCode()); + self::assertSame('ANON:alpha-request', trim((string) file_get_contents($outputDir.'/alpha_request.xml'))); + self::assertSame('ANON:alpha-response', trim((string) file_get_contents($outputDir.'/alpha_response.xml'))); + self::assertSame('ANON:beta-request', trim((string) file_get_contents($outputDir.'/beta_request.xml'))); + self::assertSame('ANON:beta-response', trim((string) file_get_contents($outputDir.'/beta_response.xml'))); + self::assertSame([ + ['resetState'], + ['anonymize', 'alpha-request', true], + ['anonymize', 'alpha-response', false], + ['resetState'], + ['anonymize', 'beta-request', true], + ['anonymize', 'beta-response', false], + ], $calls); + self::assertStringContainsString('Processed 4 XML files', $tester->getDisplay()); + self::assertStringContainsString('Anonymized 4 persons', $tester->getDisplay()); + } + + public function testSingleFileWithoutSupportedPersonNodesWarnsInsteadOfClaimingSuccess(): void + { + $inputDir = $this->createTempDirectory(); + $inputFile = $inputDir.'/meta.xml'; + $targetFile = $inputDir.'/meta-out.xml'; + + file_put_contents($inputFile, 'hello'); + + $tester = new CommandTester(new BpnXmlAnonymizeCommand(new BpnXmlAnonymizer())); + $tester->execute([ + 'infile' => $inputFile, + 'outfile' => $targetFile, + ]); + + self::assertSame(Command::SUCCESS, $tester->getStatusCode()); + self::assertStringContainsString('No anonymizable person data found', $tester->getDisplay()); + self::assertStringNotContainsString('Anonymized 0 persons', $tester->getDisplay()); + self::assertFileExists($targetFile); + } + + private function createTempDirectory(): string + { + $directory = sys_get_temp_dir().'/bpn-xml-anonymize-'.bin2hex(random_bytes(6)); + if (false === mkdir($directory, 0777, true) && false === is_dir($directory)) { + self::fail(sprintf('Unable to create temporary directory: %s', $directory)); + } + + $this->tempDirectories[] = $directory; + + return $directory; + } + + private function removeDirectory(string $directory): void + { + if (false === is_dir($directory)) { + return; + } + + $items = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST, + ); + + foreach ($items as $item) { + if ($item->isDir()) { + rmdir($item->getPathname()); + continue; + } + + unlink($item->getPathname()); + } + + rmdir($directory); + } +} diff --git a/tests/Command/DbAnonymizeCommandTest.php b/tests/Command/DbAnonymizeCommandTest.php index a5db6ad..3918690 100644 --- a/tests/Command/DbAnonymizeCommandTest.php +++ b/tests/Command/DbAnonymizeCommandTest.php @@ -41,7 +41,10 @@ class DbAnonymizeCommandTest extends TestCase $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()); + self::assertStringContainsString( + 'Anonymized 1 users, 2 newsletter consents, 3 newsletter opt-in requests, and 4 booking drafts.', + $this->normalizeConsoleOutput($tester->getDisplay()), + ); } private function createCommand(DatabaseAnonymizer $anonymizer, string $environment): DbAnonymizeCommand @@ -52,4 +55,9 @@ class DbAnonymizeCommandTest extends TestCase $environment, ); } + + private function normalizeConsoleOutput(string $output): string + { + return trim((string) preg_replace('/\s+/', ' ', $output)); + } } diff --git a/tests/Service/BpnXmlAnonymizerTest.php b/tests/Service/BpnXmlAnonymizerTest.php index 70190c0..99e8557 100644 --- a/tests/Service/BpnXmlAnonymizerTest.php +++ b/tests/Service/BpnXmlAnonymizerTest.php @@ -15,41 +15,41 @@ class BpnXmlAnonymizerTest extends TestCase - Schlegelmilch - Mandy + Example + Ava - Am Jägersteig 19 - 40724 - Hilden + Testallee 7 + 10115 + Berlin - mandy.schlegelmilch@ep-reisen.de - 02212722760 + ava.example@example.test + 015500000001 - 255752 + 111111 - Schlegelmilch - Mandy + Example + Ava - Am Jägersteig 19 - 40724 - Hilden + Testallee 7 + 10115 + Berlin - mandy.schlegelmilch@ep-reisen.de - 02212722760 + ava.example@example.test + 015500000001 - 255752 + 111111 - Danielzik - Leon + Second + Ben - leondanielzik@gmail.com + ben.second@example.test - 306886 + 222222 @@ -64,19 +64,141 @@ XML; $xpath = new \DOMXPath($document); $applicantName = trim((string) $xpath->evaluate('string(//anmelder/name)')); - $participantOneName = trim((string) $xpath->evaluate('string(//teilnehmer[@id="1"]/name)')); - $participantOneEmail = trim((string) $xpath->evaluate('string(//teilnehmer[@id="1"]/kommunikation/email)')); + $participantOneName = trim((string) $xpath->evaluate('string(//teilnehmer[1]/name)')); + $participantOneEmail = trim((string) $xpath->evaluate('string(//teilnehmer[1]/kommunikation/email)')); $applicantMobilePhone = trim((string) $xpath->evaluate('string(//anmelder/kommunikation/telefonmobil)')); - $participantOneMobilePhone = trim((string) $xpath->evaluate('string(//teilnehmer[@id="1"]/kommunikation/telefonmobil)')); - $participantTwoName = trim((string) $xpath->evaluate('string(//teilnehmer[@id="2"]/name)')); + $participantOneMobilePhone = trim((string) $xpath->evaluate('string(//teilnehmer[1]/kommunikation/telefonmobil)')); + $participantTwoName = trim((string) $xpath->evaluate('string(//teilnehmer[2]/name)')); - $this->assertNotSame('Schlegelmilch', $applicantName); + $this->assertNotSame('Example', $applicantName); $this->assertSame($applicantName, $participantOneName); - $this->assertStringEndsWith('@example.com', $participantOneEmail); - $this->assertNotSame('02212722760', $participantOneMobilePhone); + $this->assertSame('anon-user-2@example.com', $participantOneEmail); + $this->assertSame('01000000002', $participantOneMobilePhone); $this->assertSame($applicantMobilePhone, $participantOneMobilePhone); $this->assertNotSame($participantOneName, $participantTwoName); - $this->assertSame(2, $anonymizer->getLastIdentityCount()); + $this->assertSame(3, $anonymizer->getLastIdentityCount()); + } + + public function testAnonymizeReplacesAdressdatenRequestAndResponseConsistently(): void + { + $requestXml = <<<'XML' + + + portal-user + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + + Adressdaten + portal-user@example.test + bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb + +XML; + + $responseXml = <<<'XML' + + + + Adressdaten + 654321 + 123456 + + Input + Person + Herr + Prof. + M + 16.04.1972 + NL + + 444444 + Beispielweg 57 + 42853 + Musterstadt + Innenstadt + D + + + 017700000000 + portal-user@example.test + False + 021900000000 + + + DE02120300000000202051 + + 179-197 + 79-94 + 44 + + +XML; + + $anonymizer = new BpnXmlAnonymizer(); + $anonymizedRequest = $anonymizer->anonymize($requestXml, true); + $anonymizedResponse = $anonymizer->anonymize($responseXml, false); + + $requestDocument = new \DOMDocument(); + $this->assertTrue($requestDocument->loadXML($anonymizedRequest)); + $requestXpath = new \DOMXPath($requestDocument); + $requestUser = trim((string) $requestXpath->evaluate('string(//user)')); + $requestEmail = trim((string) $requestXpath->evaluate('string(//email)')); + $requestKey = trim((string) $requestXpath->evaluate('string(//key)')); + $requestPassword = trim((string) $requestXpath->evaluate('string(//passwort)')); + + $responseDocument = new \DOMDocument(); + $this->assertTrue($responseDocument->loadXML($anonymizedResponse)); + $responseXpath = new \DOMXPath($responseDocument); + $responseEmail = trim((string) $responseXpath->evaluate('string(//adressdaten/kommunikation/email)')); + $responseName = trim((string) $responseXpath->evaluate('string(//adressdaten/name)')); + $responseFirstName = trim((string) $responseXpath->evaluate('string(//adressdaten/vorname)')); + $responseSalutation = trim((string) $responseXpath->evaluate('string(//adressdaten/anrede)')); + $responseTitle = trim((string) $responseXpath->evaluate('string(//adressdaten/titel)')); + $responseGender = trim((string) $responseXpath->evaluate('string(//adressdaten/geschlecht)')); + $responseBirthDate = trim((string) $responseXpath->evaluate('string(//adressdaten/geburtsdatum)')); + $responseNationality = trim((string) $responseXpath->evaluate('string(//adressdaten/nationalitaet)')); + $responseStreet = trim((string) $responseXpath->evaluate('string(//adressdaten/anschrift/strasse)')); + $responsePostalCode = trim((string) $responseXpath->evaluate('string(//adressdaten/anschrift/plz)')); + $responseCity = trim((string) $responseXpath->evaluate('string(//adressdaten/anschrift/ort)')); + $responseDistrict = trim((string) $responseXpath->evaluate('string(//adressdaten/anschrift/ortsteil)')); + $responseCountry = trim((string) $responseXpath->evaluate('string(//adressdaten/anschrift/land)')); + $responseAddressId = trim((string) $responseXpath->evaluate('string(//adressdaten/anschrift/id)')); + $responseMobilePhone = trim((string) $responseXpath->evaluate('string(//adressdaten/kommunikation/telefonmobil)')); + $responsePhone = trim((string) $responseXpath->evaluate('string(//adressdaten/kommunikation/telefonprivat)')); + $responseNewsletter = trim((string) $responseXpath->evaluate('string(//adressdaten/kommunikation/newsletter)')); + $responseIban = trim((string) $responseXpath->evaluate('string(//adressdaten/bankverbindung/iban)')); + $responseHeight = trim((string) $responseXpath->evaluate('string(//adressdaten/sonstiges1)')); + $responseWeight = trim((string) $responseXpath->evaluate('string(//adressdaten/sonstiges2)')); + $responseShoeSize = trim((string) $responseXpath->evaluate('string(//adressdaten/sonstiges3)')); + $responseRootPersonId = trim((string) $responseXpath->evaluate('string(//idperson)')); + $responseRootAddressId = trim((string) $responseXpath->evaluate('string(//idadresse)')); + + $this->assertSame('anon-user-1', $requestUser); + $this->assertSame('anon-user-1@example.com', $requestEmail); + $this->assertSame('00000000000000000000000000000001', $requestKey); + $this->assertSame('00000000000000000000000000000002', $requestPassword); + $this->assertSame($requestEmail, $responseEmail); + $this->assertNotSame('Input', $responseName); + $this->assertNotSame('Person', $responseFirstName); + $this->assertSame('Divers', $responseSalutation); + $this->assertSame('', $responseTitle); + $this->assertSame('D', $responseGender); + $this->assertSame('01.01.1980', $responseBirthDate); + $this->assertSame('XX', $responseNationality); + $this->assertNotSame('Beispielweg 57', $responseStreet); + $this->assertNotSame('42853', $responsePostalCode); + $this->assertNotSame('Musterstadt', $responseCity); + $this->assertSame('', $responseDistrict); + $this->assertSame('XX', $responseCountry); + $this->assertSame('800001', $responseAddressId); + $this->assertSame('01000000001', $responseMobilePhone); + $this->assertSame('02000000001', $responsePhone); + $this->assertSame('False', $responseNewsletter); + $this->assertSame('DE00000000000000000001', $responseIban); + $this->assertSame('170', $responseHeight); + $this->assertSame('70', $responseWeight); + $this->assertSame('42', $responseShoeSize); + $this->assertSame('123456', $responseRootPersonId); + $this->assertSame('654321', $responseRootAddressId); + $this->assertSame(1, $anonymizer->getLastIdentityCount()); } public function testAnonymizeReplacesKundeAttributes(): void @@ -84,14 +206,14 @@ XML; $xml = <<<'XML' - - - - - - - - + + + + + + + + XML; @@ -103,11 +225,15 @@ XML; $xpath = new \DOMXPath($document); - $firstCustomerLastName = trim((string) $xpath->evaluate('string(//kunde[@id="1"]/@name)')); - $firstCustomerFirstName = trim((string) $xpath->evaluate('string(//kunde[@id="1"]/@vorname)')); + $firstCustomerLastName = trim((string) $xpath->evaluate('string(//kundennamen/kunde[1]/@name)')); + $firstCustomerFirstName = trim((string) $xpath->evaluate('string(//kundennamen/kunde[1]/@vorname)')); + $firstCustomerId = trim((string) $xpath->evaluate('string(//kundennamen/kunde[1]/@id)')); + $secondCustomerId = trim((string) $xpath->evaluate('string(//kundennamen/kunde[2]/@id)')); - $this->assertNotSame('Fromme', $firstCustomerLastName); - $this->assertNotSame('Björn', $firstCustomerFirstName); + $this->assertNotSame('Alpha', $firstCustomerLastName); + $this->assertNotSame('Ada', $firstCustomerFirstName); + $this->assertSame('900001', $firstCustomerId); + $this->assertSame('900002', $secondCustomerId); } public function testAnonymizeThrowsOnInvalidXml(): void @@ -125,28 +251,28 @@ XML; - Schlegelmilch - Mandy + Shared + Casey - Am Jägersteig 19 - 40724 - Hilden + Sharedstrasse 1 + 50667 + Koeln - mandy.schlegelmilch@ep-reisen.de + casey.shared@example.test - Schlegelmilch - Mandy + Shared + Casey - Am Jägersteig 19 - 40724 - Hilden + Sharedstrasse 1 + 50667 + Koeln - mandy.schlegelmilch@ep-reisen.de + casey.shared@example.test @@ -162,10 +288,10 @@ XML; $xpath = new \DOMXPath($document); $applicantName = trim((string) $xpath->evaluate('string(//anmelder/name)')); - $participantName = trim((string) $xpath->evaluate('string(//teilnehmer[@id="1"]/name)')); + $participantName = trim((string) $xpath->evaluate('string(//teilnehmer[1]/name)')); $this->assertSame($applicantName, $participantName); - $this->assertSame(1, $anonymizer->getLastIdentityCount()); + $this->assertSame(2, $anonymizer->getLastIdentityCount()); } public function testAnonymizeCanReuseIdentityAcrossMultipleXmlDocuments(): void @@ -174,12 +300,12 @@ XML; - Schlegelmilch - Mandy + Reusable + Riley - mandy.schlegelmilch@ep-reisen.de + riley.reusable@example.test - 255752 + 333333 XML; @@ -189,12 +315,12 @@ XML; - Schlegelmilch - Mandy + Reusable + Riley - mandy.schlegelmilch@ep-reisen.de + riley.reusable@example.test - 255752 + 333333 @@ -212,9 +338,108 @@ XML; $responseDocument = new \DOMDocument(); $this->assertTrue($responseDocument->loadXML($anonymizedResponse)); $responseXpath = new \DOMXPath($responseDocument); - $responseName = trim((string) $responseXpath->evaluate('string(//teilnehmer[@id="1"]/name)')); + $responseName = trim((string) $responseXpath->evaluate('string(//teilnehmer[1]/name)')); $this->assertSame($requestName, $responseName); + $this->assertSame(2, $anonymizer->getLastIdentityCount()); + } + + public function testAnonymizeReplacesAdressdatenBlocksInCustomerResponses(): void + { + $xml = <<<'XML' + + + + Adressdaten + 654321 + 123456 + + Input + Person + Frau + Dr. + W + 01.02.1990 + DE + + 444444 + Beispielweg 12 + 12345 + Musterstadt + Altstadt + DE + + + 01701234567 + person@example.test + True + 02101234567 + + + DE02120300000000202051 + + 180 + 75 + 43 + + +XML; + + $anonymizer = new BpnXmlAnonymizer(); + $result = $anonymizer->anonymize($xml); + + $document = new \DOMDocument(); + $this->assertTrue($document->loadXML($result)); + + $xpath = new \DOMXPath($document); + + $lastName = trim((string) $xpath->evaluate('string(//adressdaten/name)')); + $firstName = trim((string) $xpath->evaluate('string(//adressdaten/vorname)')); + $salutation = trim((string) $xpath->evaluate('string(//adressdaten/anrede)')); + $title = trim((string) $xpath->evaluate('string(//adressdaten/titel)')); + $gender = trim((string) $xpath->evaluate('string(//adressdaten/geschlecht)')); + $birthDate = trim((string) $xpath->evaluate('string(//adressdaten/geburtsdatum)')); + $nationality = trim((string) $xpath->evaluate('string(//adressdaten/nationalitaet)')); + $street = trim((string) $xpath->evaluate('string(//adressdaten/anschrift/strasse)')); + $postalCode = trim((string) $xpath->evaluate('string(//adressdaten/anschrift/plz)')); + $city = trim((string) $xpath->evaluate('string(//adressdaten/anschrift/ort)')); + $district = trim((string) $xpath->evaluate('string(//adressdaten/anschrift/ortsteil)')); + $country = trim((string) $xpath->evaluate('string(//adressdaten/anschrift/land)')); + $addressId = trim((string) $xpath->evaluate('string(//adressdaten/anschrift/id)')); + $email = trim((string) $xpath->evaluate('string(//adressdaten/kommunikation/email)')); + $mobilePhone = trim((string) $xpath->evaluate('string(//adressdaten/kommunikation/telefonmobil)')); + $phone = trim((string) $xpath->evaluate('string(//adressdaten/kommunikation/telefonprivat)')); + $newsletter = trim((string) $xpath->evaluate('string(//adressdaten/kommunikation/newsletter)')); + $iban = trim((string) $xpath->evaluate('string(//adressdaten/bankverbindung/iban)')); + $height = trim((string) $xpath->evaluate('string(//adressdaten/sonstiges1)')); + $weight = trim((string) $xpath->evaluate('string(//adressdaten/sonstiges2)')); + $shoeSize = trim((string) $xpath->evaluate('string(//adressdaten/sonstiges3)')); + $personId = trim((string) $xpath->evaluate('string(//idperson)')); + $addressIdRoot = trim((string) $xpath->evaluate('string(//idadresse)')); + + $this->assertNotSame('Input', $lastName); + $this->assertNotSame('Person', $firstName); + $this->assertSame('Divers', $salutation); + $this->assertSame('', $title); + $this->assertSame('D', $gender); + $this->assertSame('01.01.1980', $birthDate); + $this->assertSame('XX', $nationality); + $this->assertNotSame('Beispielweg 12', $street); + $this->assertNotSame('12345', $postalCode); + $this->assertNotSame('Musterstadt', $city); + $this->assertSame('', $district); + $this->assertSame('XX', $country); + $this->assertSame('800001', $addressId); + $this->assertSame('anon-user-1@example.com', $email); + $this->assertSame('01000000001', $mobilePhone); + $this->assertSame('02000000001', $phone); + $this->assertSame('False', $newsletter); + $this->assertSame('DE00000000000000000001', $iban); + $this->assertSame('170', $height); + $this->assertSame('70', $weight); + $this->assertSame('42', $shoeSize); + $this->assertSame('123456', $personId); + $this->assertSame('654321', $addressIdRoot); $this->assertSame(1, $anonymizer->getLastIdentityCount()); } }