diff --git a/config/services_ci.yaml b/config/services_ci.yaml
index dc786dda76..58817f0368 100644
--- a/config/services_ci.yaml
+++ b/config/services_ci.yaml
@@ -73,6 +73,10 @@ services:
$logFile: '/tmp/eb-fixtures/log-records.ndjson'
tags: ['fob.context']
+ OpenConext\EngineBlockFunctionalTestingBundle\Saml2\Recording\SamlTrafficRecorder:
+
+ OpenConext\EngineBlockFunctionalTestingBundle\Command\NormalizeSamlRecordingCommand:
+
OpenConext\EngineBlockFunctionalTestingBundle\Fixtures\SbsClientStateManager:
arguments:
- "@engineblock.functional_testing.data_store.sbs_client_state_mananger"
diff --git a/src/OpenConext/EngineBlockFunctionalTestingBundle/Command/NormalizeSamlRecordingCommand.php b/src/OpenConext/EngineBlockFunctionalTestingBundle/Command/NormalizeSamlRecordingCommand.php
new file mode 100644
index 0000000000..2bbd5226f6
--- /dev/null
+++ b/src/OpenConext/EngineBlockFunctionalTestingBundle/Command/NormalizeSamlRecordingCommand.php
@@ -0,0 +1,92 @@
+addArgument('source', InputArgument::REQUIRED, 'Directory written by the recorder (EB_SAML_RECORD_DIR)')
+ ->addArgument('target', InputArgument::REQUIRED, 'Directory to write the normalized dumps to');
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $source = rtrim((string) $input->getArgument('source'), '/');
+ $target = rtrim((string) $input->getArgument('target'), '/');
+
+ if (!is_dir($source)) {
+ $output->writeln(sprintf('Source directory "%s" does not exist', $source));
+ return Command::FAILURE;
+ }
+
+ $scenarios = 0;
+ $messages = 0;
+ $failures = 0;
+
+ foreach (glob($source . '/*', GLOB_ONLYDIR) ?: [] as $scenarioDirectory) {
+ $scenario = basename($scenarioDirectory);
+ $files = glob($scenarioDirectory . '/*.xml') ?: [];
+ sort($files);
+
+ if (!is_dir($target . '/' . $scenario)) {
+ mkdir($target . '/' . $scenario, 0777, true);
+ }
+
+ $normalizer = new SamlXmlNormalizer();
+ $scenarios++;
+
+ foreach ($files as $file) {
+ try {
+ $dump = $normalizer->normalize((string) file_get_contents($file));
+ } catch (InvalidArgumentException $exception) {
+ $dump = '!! ' . $exception->getMessage() . "\n";
+ $failures++;
+ }
+
+ file_put_contents($target . '/' . $scenario . '/' . basename($file, '.xml') . '.txt', $dump);
+ $messages++;
+ }
+ }
+
+ $output->writeln(sprintf(
+ 'Normalized %d messages from %d scenarios (%d unparsable)',
+ $messages,
+ $scenarios,
+ $failures
+ ));
+
+ return Command::SUCCESS;
+ }
+}
diff --git a/src/OpenConext/EngineBlockFunctionalTestingBundle/Features/Context/SamlRecordingContext.php b/src/OpenConext/EngineBlockFunctionalTestingBundle/Features/Context/SamlRecordingContext.php
new file mode 100644
index 0000000000..c8ea35fd21
--- /dev/null
+++ b/src/OpenConext/EngineBlockFunctionalTestingBundle/Features/Context/SamlRecordingContext.php
@@ -0,0 +1,71 @@
+getFeature()->getFile() ?? 'unknown', '.feature'),
+ $scope->getScenario()->getLine()
+ );
+
+ file_put_contents(
+ SamlTrafficRecorder::MARKER_FILE,
+ json_encode(['directory' => $directory, 'scenario' => $scenario], JSON_THROW_ON_ERROR)
+ );
+ }
+
+ /**
+ * @AfterSuite
+ */
+ public static function stopRecording(): void
+ {
+ self::clearMarker();
+ }
+
+ private static function clearMarker(): void
+ {
+ if (is_file(SamlTrafficRecorder::MARKER_FILE)) {
+ unlink(SamlTrafficRecorder::MARKER_FILE);
+ }
+ }
+}
diff --git a/src/OpenConext/EngineBlockFunctionalTestingBundle/Saml2/Recording/SamlTrafficRecorder.php b/src/OpenConext/EngineBlockFunctionalTestingBundle/Saml2/Recording/SamlTrafficRecorder.php
new file mode 100644
index 0000000000..20f4907516
--- /dev/null
+++ b/src/OpenConext/EngineBlockFunctionalTestingBundle/Saml2/Recording/SamlTrafficRecorder.php
@@ -0,0 +1,199 @@
+ ['onKernelResponse', -1024]];
+ }
+
+ public function onKernelResponse(ResponseEvent $event): void
+ {
+ $recording = $this->readMarker();
+ if ($recording === null) {
+ return;
+ }
+
+ $messages = $this->extractMessages($event->getResponse());
+ if ($messages === []) {
+ return;
+ }
+
+ $request = $event->getRequest();
+ $source = preg_replace('/[^A-Za-z0-9.]+/', '_', $request->getHost() . $request->getPathInfo());
+
+ foreach ($messages as $type => $xml) {
+ $this->write($recording['directory'], $recording['scenario'], $type, (string) $source, $xml);
+ }
+ }
+
+ /**
+ * @return array SAML message type (SAMLRequest|SAMLResponse) => raw XML
+ */
+ public function extractMessages(Response $response): array
+ {
+ return $this->extractFromRedirect($response) + $this->extractFromForm($response);
+ }
+
+ /**
+ * @return array
+ */
+ private function extractFromRedirect(Response $response): array
+ {
+ $location = $response->headers->get('Location');
+ if ($location === null) {
+ return [];
+ }
+
+ parse_str((string) parse_url($location, PHP_URL_QUERY), $query);
+
+ $messages = [];
+ foreach (self::MESSAGE_PARAMETERS as $parameter) {
+ $xml = is_string($query[$parameter] ?? null) ? $this->decode($query[$parameter], true) : null;
+ if ($xml !== null) {
+ $messages[$parameter] = $xml;
+ }
+ }
+
+ return $messages;
+ }
+
+ /**
+ * @return array
+ */
+ private function extractFromForm(Response $response): array
+ {
+ $content = (string) $response->getContent();
+ if (!str_contains($content, 'SAML') || !str_contains((string) $response->headers->get('Content-Type'), 'html')) {
+ return [];
+ }
+
+ $messages = [];
+ foreach ($this->extractFormFields($content) as $parameter => $value) {
+ $xml = $this->decode($value, false);
+ if ($xml !== null) {
+ $messages[$parameter] = $xml;
+ }
+ }
+
+ return $messages;
+ }
+
+ /**
+ * @return array
+ */
+ private function extractFormFields(string $html): array
+ {
+ $previous = libxml_use_internal_errors(true);
+ $document = new DOMDocument();
+ $document->loadHTML($html);
+ libxml_clear_errors();
+ libxml_use_internal_errors($previous);
+
+ $fields = [];
+ $xpath = new DOMXPath($document);
+ foreach ($xpath->query('//input[@name="SAMLRequest" or @name="SAMLResponse"]') as $input) {
+ $fields[$input->getAttribute('name')] = $input->getAttribute('value');
+ }
+
+ return $fields;
+ }
+
+ private function decode(string $encoded, bool $deflated): ?string
+ {
+ $decoded = base64_decode($encoded, true);
+ if ($decoded === false) {
+ return null;
+ }
+
+ if ($deflated) {
+ $decoded = $this->inflate($decoded) ?? $decoded;
+ }
+
+ return str_starts_with(ltrim($decoded), '<') ? $decoded : null;
+ }
+
+ private function inflate(string $data): ?string
+ {
+ // Recording must never break the request, so a corrupt payload is ignored instead of raising a warning.
+ set_error_handler(static fn(): bool => true);
+ try {
+ $inflated = gzinflate($data);
+ } finally {
+ restore_error_handler();
+ }
+
+ return $inflated === false ? null : $inflated;
+ }
+
+ /**
+ * @return array{directory: string, scenario: string}|null
+ */
+ private function readMarker(): ?array
+ {
+ if (!is_file($this->markerFile)) {
+ return null;
+ }
+
+ $marker = json_decode((string) file_get_contents($this->markerFile), true);
+ if (!is_array($marker) || !is_string($marker['directory'] ?? null) || $marker['directory'] === '') {
+ return null;
+ }
+
+ return [
+ 'directory' => $marker['directory'],
+ 'scenario' => is_string($marker['scenario'] ?? null) && $marker['scenario'] !== ''
+ ? $marker['scenario']
+ : 'unscoped',
+ ];
+ }
+
+ private function write(string $directory, string $scenario, string $type, string $source, string $xml): void
+ {
+ $scenarioDirectory = $directory . '/' . $scenario;
+
+ if (!is_dir($scenarioDirectory)) {
+ mkdir($scenarioDirectory, 0777, true);
+ }
+
+ $sequence = count(glob($scenarioDirectory . '/*.xml') ?: []) + 1;
+
+ file_put_contents(sprintf('%s/%03d-%s-%s.xml', $scenarioDirectory, $sequence, $type, $source), $xml);
+ }
+}
diff --git a/src/OpenConext/EngineBlockFunctionalTestingBundle/Saml2/Recording/SamlXmlNormalizer.php b/src/OpenConext/EngineBlockFunctionalTestingBundle/Saml2/Recording/SamlXmlNormalizer.php
new file mode 100644
index 0000000000..7faa1e81c9
--- /dev/null
+++ b/src/OpenConext/EngineBlockFunctionalTestingBundle/Saml2/Recording/SamlXmlNormalizer.php
@@ -0,0 +1,240 @@
+ 'samlp',
+ 'urn:oasis:names:tc:SAML:2.0:assertion' => 'saml',
+ 'urn:oasis:names:tc:SAML:2.0:metadata' => 'md',
+ 'urn:oasis:names:tc:SAML:metadata:ui' => 'mdui',
+ 'urn:oasis:names:tc:SAML:metadata:attribute' => 'mdattr',
+ 'urn:oasis:names:tc:SAML:profiles:SSO:idp-discovery-protocol' => 'idpdisc',
+ 'urn:mace:shibboleth:metadata:1.0' => 'shibmd',
+ 'http://www.w3.org/2000/09/xmldsig#' => 'ds',
+ 'http://www.w3.org/2001/04/xmlenc#' => 'xenc',
+ 'http://www.w3.org/2001/XMLSchema' => 'xs',
+ 'http://www.w3.org/2001/XMLSchema-instance' => 'xsi',
+ 'http://www.w3.org/XML/1998/namespace' => 'xml',
+ ];
+
+ private const MASKED_ELEMENTS = [
+ 'http://www.w3.org/2000/09/xmldsig#' => ['SignatureValue', 'DigestValue'],
+ 'http://www.w3.org/2001/04/xmlenc#' => ['CipherValue'],
+ ];
+
+ private const REFERENCE_ATTRIBUTES = ['ID', 'InResponseTo'];
+
+ private const IDENTIFIER_PATTERN =
+ '/(? */
+ private array $identifiers = [];
+
+ public function normalize(string $xml): string
+ {
+ $previous = libxml_use_internal_errors(true);
+ $document = new DOMDocument();
+ $loaded = $document->loadXML($xml);
+ $errors = libxml_get_errors();
+ libxml_clear_errors();
+ libxml_use_internal_errors($previous);
+
+ if (!$loaded || $document->documentElement === null) {
+ throw new InvalidArgumentException(
+ 'Unable to parse SAML message: ' . ($errors[0]->message ?? 'unknown error')
+ );
+ }
+
+ $lines = [];
+ $this->dumpElement($document->documentElement, 0, $lines);
+
+ return implode("\n", $lines) . "\n";
+ }
+
+ /**
+ * @param string[] $lines
+ */
+ private function dumpElement(DOMElement $element, int $depth, array &$lines): void
+ {
+ $indent = str_repeat(' ', $depth);
+ $name = $this->qualifiedName($element->namespaceURI, $element->localName);
+ $attributes = $this->attributesOf($element);
+ $childElements = $this->childElementsOf($element);
+ $text = $childElements === [] ? $this->textOf($element) : '';
+
+ if ($attributes === [] && $childElements === []) {
+ $lines[] = $text === '' ? $indent . $name : $indent . $name . ': ' . $text;
+ return;
+ }
+
+ $lines[] = $indent . $name;
+ foreach ($attributes as $attributeName => $value) {
+ $lines[] = $indent . ' @' . $attributeName . ': ' . $value;
+ }
+ if ($text !== '') {
+ $lines[] = $indent . ' #text: ' . $text;
+ }
+ foreach ($childElements as $child) {
+ $this->dumpElement($child, $depth + 1, $lines);
+ }
+ }
+
+ /**
+ * @return array
+ */
+ private function attributesOf(DOMElement $element): array
+ {
+ $attributes = [];
+ foreach ($element->attributes as $attribute) {
+ if ($attribute->namespaceURI === self::XMLNS) {
+ continue;
+ }
+ $attributeName = $this->qualifiedName($attribute->namespaceURI, $attribute->localName);
+ $attributes[$attributeName] = match (true) {
+ $attributeName === 'xsi:type' => $this->normalizeQName($element, $attribute->value),
+ in_array($attributeName, self::REFERENCE_ATTRIBUTES, true) => $this->identifier($attribute->value),
+ default => $this->maskVolatile($attribute->value),
+ };
+ }
+ ksort($attributes);
+
+ return $attributes;
+ }
+
+ private function identifier(string $value): string
+ {
+ if ($value === '') {
+ return $value;
+ }
+
+ return $this->identifiers[$value] ??= 'ID-' . (count($this->identifiers) + 1);
+ }
+
+ /**
+ * @return DOMElement[]
+ */
+ private function childElementsOf(DOMElement $element): array
+ {
+ $children = [];
+ foreach ($element->childNodes as $child) {
+ if ($child instanceof DOMElement) {
+ $children[] = $child;
+ }
+ }
+
+ return $children;
+ }
+
+ private function textOf(DOMElement $element): string
+ {
+ $text = '';
+ foreach ($element->childNodes as $child) {
+ if ($child instanceof DOMText) {
+ $text .= $child->nodeValue;
+ }
+ }
+ $text = trim($text);
+
+ if ($this->isMasked($element)) {
+ return $text === '' ? '' : '';
+ }
+
+ if ($this->isCertificate($element)) {
+ return preg_replace('/\s+/', '', $text) ?? $text;
+ }
+
+ return $this->maskVolatile($text);
+ }
+
+ private function qualifiedName(?string $namespace, string $localName): string
+ {
+ if ($namespace === null || $namespace === '') {
+ return $localName;
+ }
+
+ $prefix = self::NAMESPACE_PREFIXES[$namespace] ?? '{' . $namespace . '}';
+
+ return str_starts_with($prefix, '{') ? $prefix . $localName : $prefix . ':' . $localName;
+ }
+
+ private function normalizeQName(DOMNode $context, string $value): string
+ {
+ if (!str_contains($value, ':')) {
+ return $value;
+ }
+
+ [$prefix, $localName] = explode(':', $value, 2);
+ $namespace = $context->lookupNamespaceURI($prefix);
+
+ return $namespace === null ? $value : $this->qualifiedName($namespace, $localName);
+ }
+
+ private function isMasked(DOMElement $element): bool
+ {
+ return in_array($element->localName, self::MASKED_ELEMENTS[$element->namespaceURI ?? ''] ?? [], true);
+ }
+
+ private function isCertificate(DOMElement $element): bool
+ {
+ return $element->localName === 'X509Certificate'
+ && $element->namespaceURI === 'http://www.w3.org/2000/09/xmldsig#';
+ }
+
+ private function maskVolatile(string $value): string
+ {
+ $value = preg_replace_callback(
+ self::TIMESTAMP_PATTERN,
+ static fn(array $match): string => sprintf(
+ '',
+ ($match[1] ?? '') !== '' ? '+fraction' : '',
+ $match[2] === 'Z' ? '' : '+offset'
+ ),
+ $value
+ ) ?? $value;
+
+ foreach ([self::IDENTIFIER_PATTERN, self::UUID_PATTERN] as $pattern) {
+ $value = preg_replace_callback(
+ $pattern,
+ fn(array $match): string => $this->identifier($match[0]),
+ $value
+ ) ?? $value;
+ }
+
+ return $value;
+ }
+}
diff --git a/tests/behat.yml b/tests/behat.yml
index 18c453c452..53ce5a939f 100644
--- a/tests/behat.yml
+++ b/tests/behat.yml
@@ -46,6 +46,7 @@ default:
- OpenConext\EngineBlockFunctionalTestingBundle\Features\Context\LoggingContext:
logFile: '/tmp/eb-fixtures/log-records.ndjson'
- OpenConext\EngineBlockFunctionalTestingBundle\Features\Context\MinkContext:
+ - OpenConext\EngineBlockFunctionalTestingBundle\Features\Context\SamlRecordingContext:
functional:
mink_session: chrome
mink_javascript_session: chrome
diff --git a/tests/unit/OpenConext/EngineBlockFunctionalTestingBundle/Saml2/Recording/SamlTrafficRecorderTest.php b/tests/unit/OpenConext/EngineBlockFunctionalTestingBundle/Saml2/Recording/SamlTrafficRecorderTest.php
new file mode 100644
index 0000000000..702e645338
--- /dev/null
+++ b/tests/unit/OpenConext/EngineBlockFunctionalTestingBundle/Saml2/Recording/SamlTrafficRecorderTest.php
@@ -0,0 +1,148 @@
+';
+
+ private SamlTrafficRecorder $recorder;
+
+ private string $workDirectory;
+
+ protected function setUp(): void
+ {
+ $this->workDirectory = sys_get_temp_dir() . '/saml-recorder-test-' . bin2hex(random_bytes(4));
+ mkdir($this->workDirectory);
+ $this->recorder = new SamlTrafficRecorder($this->workDirectory . '/marker.json');
+ }
+
+ protected function tearDown(): void
+ {
+ (new Filesystem())->remove($this->workDirectory);
+ }
+
+ public function testRecordsMessagesIntoTheScenarioDirectoryFromTheMarker(): void
+ {
+ file_put_contents(
+ $this->workDirectory . '/marker.json',
+ json_encode(['directory' => $this->workDirectory . '/out', 'scenario' => 'Feature_L12'])
+ );
+
+ $this->recorder->onKernelResponse($this->event(new RedirectResponse(
+ 'https://idp.example.org/sso?' . http_build_query(['SAMLRequest' => base64_encode((string) gzdeflate(self::XML))])
+ )));
+
+ $files = glob($this->workDirectory . '/out/Feature_L12/*.xml') ?: [];
+ $this->assertCount(1, $files);
+ $this->assertSame('001-SAMLRequest-engine.example.org_authn.xml', basename($files[0]));
+ $this->assertSame(self::XML, file_get_contents($files[0]));
+ }
+
+ public function testRecordsNothingWithoutMarker(): void
+ {
+ $this->recorder->onKernelResponse($this->event(new RedirectResponse(
+ 'https://idp.example.org/sso?' . http_build_query(['SAMLRequest' => base64_encode((string) gzdeflate(self::XML))])
+ )));
+
+ $this->assertSame([], glob($this->workDirectory . '/*') ?: []);
+ }
+
+ private function event(Response $response): ResponseEvent
+ {
+ return new ResponseEvent(
+ $this->createMock(HttpKernelInterface::class),
+ Request::create('https://engine.example.org/authn'),
+ HttpKernelInterface::MAIN_REQUEST,
+ $response
+ );
+ }
+
+ public function testExtractsMessageFromHttpRedirect(): void
+ {
+ $query = http_build_query([
+ 'SAMLRequest' => base64_encode((string) gzdeflate(self::XML)),
+ 'RelayState' => 'abc',
+ ]);
+
+ $messages = $this->recorder->extractMessages(new RedirectResponse('https://idp.example.org/sso?' . $query));
+
+ $this->assertSame(['SAMLRequest' => self::XML], $messages);
+ }
+
+ public function testExtractsMessageFromHttpPostForm(): void
+ {
+ $html = sprintf(
+ '',
+ htmlspecialchars(base64_encode(self::XML))
+ );
+
+ $messages = $this->recorder->extractMessages(new Response($html, 200, ['Content-Type' => 'text/html']));
+
+ $this->assertSame(['SAMLResponse' => self::XML], $messages);
+ }
+
+ public function testIgnoresResponsesWithoutSamlMessage(): void
+ {
+ $this->assertSame([], $this->recorder->extractMessages(new RedirectResponse('https://example.org/?a=b')));
+ $this->assertSame(
+ [],
+ $this->recorder->extractMessages(new Response('SAML is mentioned', 200, ['Content-Type' => 'text/html']))
+ );
+ }
+
+ public function testIgnoresParametersThatAreNotXml(): void
+ {
+ $messages = $this->recorder->extractMessages(
+ new RedirectResponse('https://example.org/?SAMLRequest=' . base64_encode('not xml'))
+ );
+
+ $this->assertSame([], $messages);
+ }
+
+ public function testCorruptDeflatedRedirectPayloadIsIgnoredWithoutWarning(): void
+ {
+ set_error_handler(static function (int $severity, string $message): never {
+ throw new ErrorException($message, 0, $severity);
+ });
+
+ try {
+ $messages = $this->recorder->extractMessages(
+ new RedirectResponse('https://example.org/?SAMLRequest=' . rawurlencode(base64_encode("\x01\x02garbage")))
+ );
+ } finally {
+ restore_error_handler();
+ }
+
+ $this->assertSame([], $messages);
+ }
+}
diff --git a/tests/unit/OpenConext/EngineBlockFunctionalTestingBundle/Saml2/Recording/SamlXmlNormalizerTest.php b/tests/unit/OpenConext/EngineBlockFunctionalTestingBundle/Saml2/Recording/SamlXmlNormalizerTest.php
new file mode 100644
index 0000000000..2f0ceda1df
--- /dev/null
+++ b/tests/unit/OpenConext/EngineBlockFunctionalTestingBundle/Saml2/Recording/SamlXmlNormalizerTest.php
@@ -0,0 +1,202 @@
+
+ https://engine.example.org/metadata
+
+
+
+ %4$s
+
+
+ %4$s
+ MIIC
+CERT
+
+
+
+
+
+ jdoe
+
+
+
+
+XML;
+
+ public function testIgnoresNamespacePrefixesAndAttributeOrder(): void
+ {
+ $original = $this->response('_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'EB1111111111111111111111111111111111111111');
+ $reprefixed = str_replace(
+ ['samlp:', 'xmlns:samlp', 'saml:', 'xmlns:saml=', 'xs:', 'xmlns:xs'],
+ ['p:', 'xmlns:p', 'a:', 'xmlns:a=', 'xsd:', 'xmlns:xsd'],
+ $original
+ );
+ $reordered = str_replace(
+ 'Version="2.0" IssueInstant="2026-01-01T10:00:00Z" Destination="https://sp.example.org/acs"',
+ 'Destination="https://sp.example.org/acs" IssueInstant="2026-01-01T10:00:00Z" Version="2.0"',
+ $reprefixed
+ );
+
+ $this->assertSame($this->normalize($original), $this->normalize($reordered));
+ }
+
+ public function testMasksVolatileValuesButKeepsReferencesVisible(): void
+ {
+ $first = $this->normalize(
+ $this->response('_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'EB1111111111111111111111111111111111111111')
+ );
+ $second = $this->normalize(
+ $this->response(
+ '_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
+ 'EB2222222222222222222222222222222222222222',
+ '2027-05-05T05:05:05Z',
+ 'different'
+ )
+ );
+
+ $this->assertSame($first, $second);
+ $this->assertStringContainsString('@ID: ID-1', $first);
+ $this->assertStringContainsString('@InResponseTo: ID-2', $first);
+ $this->assertStringContainsString('@URI: #ID-1', $first);
+ $this->assertStringContainsString('@IssueInstant: ', $first);
+ $this->assertStringContainsString("ds:SignatureValue: ", $first);
+ $this->assertStringContainsString('ds:X509Certificate: MIICCERT', $first);
+ }
+
+ public function testNormalizesXsiTypeIndependentlyOfPrefix(): void
+ {
+ $dump = $this->normalize(
+ str_replace(['xs:', 'xmlns:xs'], ['xsd:', 'xmlns:xsd'], $this->response('_' . str_repeat('a', 40), 'EB' . str_repeat('1', 40)))
+ );
+
+ $this->assertStringContainsString('@xsi:type: xs:string', $dump);
+ }
+
+ public function testReportsSemanticDifferences(): void
+ {
+ $original = $this->response('_' . str_repeat('a', 40), 'EB' . str_repeat('1', 40));
+ $changed = str_replace(
+ 'urn:oasis:names:tc:SAML:2.0:status:Success',
+ 'urn:oasis:names:tc:SAML:2.0:status:Requester',
+ $original
+ );
+
+ $this->assertNotSame($this->normalize($original), $this->normalize($changed));
+ }
+
+ public function testNumbersIdentifiersAcrossMessagesOfOneInstance(): void
+ {
+ $normalizer = new SamlXmlNormalizer();
+ $request = '';
+
+ $normalizer->normalize($request);
+ $response = $normalizer->normalize(
+ $this->response('_' . str_repeat('a', 40), 'EB' . str_repeat('1', 40))
+ );
+
+ $this->assertStringContainsString('@InResponseTo: ID-1', $response);
+ $this->assertStringContainsString('@ID: ID-2', $response);
+ }
+
+ public function testMasksIdentifiersWithEngineBlockAndMockPrefixes(): void
+ {
+ $dump = $this->normalize(
+ ''
+ );
+
+ $this->assertStringContainsString('@ID: ID-1', $dump);
+ $this->assertStringContainsString('@InResponseTo: ID-2', $dump);
+ $this->assertStringContainsString('@Destination: https://sp.example.org/acs', $dump);
+ }
+
+ public function testMasksReferenceAttributesRegardlessOfIdentifierFormat(): void
+ {
+ $dump = $this->normalize(
+ ''
+ );
+
+ $this->assertStringContainsString('@ID: ID-1', $dump);
+ $this->assertStringContainsString('@InResponseTo: ID-2', $dump);
+ $this->assertStringContainsString('@Destination: https://sp.example.org/acs', $dump);
+ }
+
+ public function testKeepsReferenceBetweenRequestAndResponse(): void
+ {
+ $normalizer = new SamlXmlNormalizer();
+ $normalizer->normalize('');
+ $dump = $normalizer->normalize(
+ ''
+ );
+
+ $this->assertStringContainsString('@ID: ID-2', $dump);
+ $this->assertStringContainsString('@InResponseTo: ID-1', $dump);
+ }
+
+ public function testMasksTimeBasedMockIdentityProviderFixtures(): void
+ {
+ $dump = $this->normalize(
+ ''
+ . 'ETS-MOCK-IDP-1791897603'
+ . 'urn:collab:person:example.org:test179189752994056'
+ . ''
+ );
+
+ $this->assertStringContainsString('saml:NameID: ID-1', $dump);
+ $this->assertStringContainsString('saml:AttributeValue: urn:collab:person:example.org:ID-2', $dump);
+ }
+
+ public function testRejectsUnparsableXml(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+
+ (new SamlXmlNormalizer())->normalize('normalize($xml);
+ }
+
+ private function response(
+ string $id,
+ string $inResponseTo,
+ string $instant = '2026-01-01T10:00:00Z',
+ string $digest = 'c2lnbmF0dXJl',
+ ): string {
+ return sprintf(self::RESPONSE, $id, $inResponseTo, $instant, $digest, '_' . str_repeat('c', 40));
+ }
+}