From 68ccbca2a36969badfe865a4f7944a94552ceb04 Mon Sep 17 00:00:00 2001 From: Kay Joosten Date: Tue, 13 Oct 2026 15:26:41 +0200 Subject: [PATCH] Add SAML traffic recorder to compare EngineBlock output between commits # Why is this change needed? Prior to this change, there was no way to see whether a library upgrade changed the SAML messages EngineBlock sends. The Behat suite only asserts selected fields, so a change in a message that no step checks goes unnoticed. The staged migration to simplesamlphp/saml2 v7 rewrites the code that builds and signs messages, so a wire level safety net is needed first. # How does it address the issue? This change adds test-only tooling that is only loaded in the ci and test environments: - a kernel response subscriber that records every SAMLRequest and SAMLResponse that leaves EngineBlock or one of the mocks, for both HTTP-Redirect and HTTP-POST - a normaliser that dumps a message as a prefix independent text tree and masks volatile values (timestamps, identifiers, signature and digest values, time based mock fixtures) while keeping references between messages visible - the engineblock:saml:normalize-recording command to normalise a recording - a Behat context that switches recording on when EB_SAML_RECORD_DIR is set. Behat reaches the application over HTTP, so the setting is handed over through a marker file in /tmp/eb-fixtures Nothing is recorded unless EB_SAML_RECORD_DIR is set. To compare two commits, run the default Behat suite on each with a different directory, normalise both recordings and run diff -r on the results. Two full runs on identical code give an empty diff over 904 messages in 257 scenarios. Changing the Comparison of the Stepup request leaves all 25 Stepup scenarios green, but the recording diff flags 20 of them. # Links https://github.com/OpenConext/OpenConext-engineblock/issues/2136 --- config/services_ci.yaml | 4 + .../Command/NormalizeSamlRecordingCommand.php | 92 +++++++ .../Features/Context/SamlRecordingContext.php | 71 ++++++ .../Saml2/Recording/SamlTrafficRecorder.php | 199 +++++++++++++++ .../Saml2/Recording/SamlXmlNormalizer.php | 240 ++++++++++++++++++ tests/behat.yml | 1 + .../Recording/SamlTrafficRecorderTest.php | 148 +++++++++++ .../Saml2/Recording/SamlXmlNormalizerTest.php | 202 +++++++++++++++ 8 files changed, 957 insertions(+) create mode 100644 src/OpenConext/EngineBlockFunctionalTestingBundle/Command/NormalizeSamlRecordingCommand.php create mode 100644 src/OpenConext/EngineBlockFunctionalTestingBundle/Features/Context/SamlRecordingContext.php create mode 100644 src/OpenConext/EngineBlockFunctionalTestingBundle/Saml2/Recording/SamlTrafficRecorder.php create mode 100644 src/OpenConext/EngineBlockFunctionalTestingBundle/Saml2/Recording/SamlXmlNormalizer.php create mode 100644 tests/unit/OpenConext/EngineBlockFunctionalTestingBundle/Saml2/Recording/SamlTrafficRecorderTest.php create mode 100644 tests/unit/OpenConext/EngineBlockFunctionalTestingBundle/Saml2/Recording/SamlXmlNormalizerTest.php 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)); + } +}