diff --git a/STEPS.md b/STEPS.md index 03143ed7..b2e242c7 100644 --- a/STEPS.md +++ b/STEPS.md @@ -2944,6 +2944,132 @@ Then the XML should not use the namespace "http://example.com/nonexistent" +
+ @Then the response should match the following XSD schema: + +
+Assert that the response validates against an inline XSD schema +

+ +```gherkin +Then the response should match the following XSD schema: + """ + + + + + """ + +``` + +
+ +
+ @Then the response should match the XSD schema in the file :filename + +
+Assert that the response validates against an XSD schema from a file +

+ +```gherkin +Then the response should match the XSD schema in the file "xml_schema.xsd" + +``` + +
+ +
+ @Then the response should match the following DTD: + +
+Assert that the response validates against an inline DTD +

+ +```gherkin +Then the response should match the following DTD: + """ + + """ + +``` + +
+ +
+ @Then the response should match the DTD in the file :filename + +
+Assert that the response validates against a DTD from a file +

+ +```gherkin +Then the response should match the DTD in the file "xml_schema.dtd" + +``` + +
+ +
+ @Then the response should match the following RelaxNG schema: + +
+Assert that the response validates against an inline RelaxNG schema +

+ +```gherkin +Then the response should match the following RelaxNG schema: + """ + + + + """ + +``` + +
+ +
+ @Then the response should match the RelaxNG schema in the file :filename + +
+Assert that the response validates against a RelaxNG schema from a file +

+ +```gherkin +Then the response should match the RelaxNG schema in the file "xml_schema.rng" + +``` + +
+ +
+ @Then the response should be a valid RSS feed + +
+Assert that the response is a valid RSS 2.0 feed +

+ +```gherkin +Then the response should be a valid RSS feed + +``` + +
+ +
+ @Then the response should be a valid Atom feed + +
+Assert that the response is a valid Atom feed +

+ +```gherkin +Then the response should be a valid Atom feed + +``` + +
+ ## Drupal\BlockTrait diff --git a/src/XmlTrait.php b/src/XmlTrait.php index 0cb4c0c0..4c8a2c15 100644 --- a/src/XmlTrait.php +++ b/src/XmlTrait.php @@ -81,21 +81,7 @@ public function xmlAfterScenario(): void { */ #[Given('the response content from the file :filename')] public function xmlSetResponseContentFromFile(string $filename): void { - $files_path = rtrim((string) $this->getMinkParameter('files_path'), '/'); - $file_path = $files_path . '/' . $filename; - - if (!file_exists($file_path)) { - throw new \RuntimeException(sprintf('The file "%s" does not exist.', $file_path)); - } - - $content = file_get_contents($file_path); - if ($content === FALSE) { - // @codeCoverageIgnoreStart - throw new \RuntimeException(sprintf('Failed to read the file "%s".', $file_path)); - // @codeCoverageIgnoreEnd - } - - $this->xmlTestContent = $content; + $this->xmlTestContent = $this->xmlReadFile($filename); $this->xmlDocument = NULL; $this->xmlXpath = NULL; $this->xmlContentHash = NULL; @@ -551,6 +537,130 @@ public function xmlAssertNamespaceNotExists(string $namespace): void { } } + /** + * Assert that the response validates against an inline XSD schema. + * + * @code + * Then the response should match the following XSD schema: + * """ + * + * + * + * + * """ + * @endcode + */ + #[Then('the response should match the following XSD schema:')] + public function xmlAssertMatchesXsd(PyStringNode $schema): void { + $this->xmlValidateXsd($schema->getRaw()); + } + + /** + * Assert that the response validates against an XSD schema from a file. + * + * @code + * Then the response should match the XSD schema in the file "xml_schema.xsd" + * @endcode + */ + #[Then('the response should match the XSD schema in the file :filename')] + public function xmlAssertMatchesXsdFromFile(string $filename): void { + $this->xmlValidateXsd($this->xmlReadFile($filename)); + } + + /** + * Assert that the response validates against an inline DTD. + * + * DTDs are namespace-unaware: to validate a namespaced document, the DTD must + * declare the prefixed element names and the `xmlns` attributes. + * + * @code + * Then the response should match the following DTD: + * """ + * + * """ + * @endcode + */ + #[Then('the response should match the following DTD:')] + public function xmlAssertMatchesDtd(PyStringNode $dtd): void { + $this->xmlValidateDtd($dtd->getRaw()); + } + + /** + * Assert that the response validates against a DTD from a file. + * + * DTDs are namespace-unaware: to validate a namespaced document, the DTD must + * declare the prefixed element names and the `xmlns` attributes. + * + * @code + * Then the response should match the DTD in the file "xml_schema.dtd" + * @endcode + */ + #[Then('the response should match the DTD in the file :filename')] + public function xmlAssertMatchesDtdFromFile(string $filename): void { + $this->xmlValidateDtd($this->xmlReadFile($filename)); + } + + /** + * Assert that the response validates against an inline RelaxNG schema. + * + * @code + * Then the response should match the following RelaxNG schema: + * """ + * + * + * + * """ + * @endcode + */ + #[Then('the response should match the following RelaxNG schema:')] + public function xmlAssertMatchesRelaxNg(PyStringNode $schema): void { + $this->xmlValidateRelaxNg($schema->getRaw()); + } + + /** + * Assert that the response validates against a RelaxNG schema from a file. + * + * @code + * Then the response should match the RelaxNG schema in the file "xml_schema.rng" + * @endcode + */ + #[Then('the response should match the RelaxNG schema in the file :filename')] + public function xmlAssertMatchesRelaxNgFromFile(string $filename): void { + $this->xmlValidateRelaxNg($this->xmlReadFile($filename)); + } + + /** + * Assert that the response is a valid RSS 2.0 feed. + * + * Checks the required RSS 2.0 structure: an `rss` root with a `version` of + * `2.0`, a single `channel` with `title`, `link` and `description`, and an + * `item` with at least a `title` or a `description`. + * + * @code + * Then the response should be a valid RSS feed + * @endcode + */ + #[Then('the response should be a valid RSS feed')] + public function xmlAssertValidRssFeed(): void { + $this->xmlValidateRssFeed(); + } + + /** + * Assert that the response is a valid Atom feed. + * + * Checks the required Atom structure: a `feed` root in the Atom namespace + * with `id`, `title` and `updated`, and each `entry` with `id`, `title` + * and `updated`. + * + * @code + * Then the response should be a valid Atom feed + * @endcode + */ + #[Then('the response should be a valid Atom feed')] + public function xmlAssertValidAtomFeed(): void { + $this->xmlValidateAtomFeed(); + } + /** * Print the last XML response. * @@ -683,4 +793,210 @@ protected function xmlFormatErrors(array $errors): string { return implode('; ', $messages); } + /** + * Read a fixture file's contents. + * + * @param string $filename + * The fixture file name relative to the Mink files path. + * + * @return string + * The file contents. + */ + protected function xmlReadFile(string $filename): string { + $files_path = rtrim((string) $this->getMinkParameter('files_path'), '/'); + $file_path = $files_path . '/' . $filename; + + if (!file_exists($file_path)) { + throw new \RuntimeException(sprintf('The file "%s" does not exist.', $file_path)); + } + + $content = file_get_contents($file_path); + if ($content === FALSE) { + // @codeCoverageIgnoreStart + throw new \RuntimeException(sprintf('Failed to read the file "%s".', $file_path)); + // @codeCoverageIgnoreEnd + } + + return $content; + } + + /** + * Validate the response against an XSD schema. + * + * @param string $schema + * The XSD schema source. + */ + protected function xmlValidateXsd(string $schema): void { + $this->xmlEnsureDocument(); + + libxml_clear_errors(); + $valid = @$this->xmlDocument->schemaValidateSource($schema); + $errors = libxml_get_errors(); + libxml_clear_errors(); + + if (!$valid) { + throw new ExpectationException(sprintf('The response does not match the XSD schema: %s', $this->xmlFormatErrors($errors)), $this->getSession()->getDriver()); + } + } + + /** + * Validate the response against a RelaxNG schema. + * + * @param string $schema + * The RelaxNG schema source. + */ + protected function xmlValidateRelaxNg(string $schema): void { + $this->xmlEnsureDocument(); + + libxml_clear_errors(); + $valid = @$this->xmlDocument->relaxNGValidateSource($schema); + $errors = libxml_get_errors(); + libxml_clear_errors(); + + if (!$valid) { + throw new ExpectationException(sprintf('The response does not match the RelaxNG schema: %s', $this->xmlFormatErrors($errors)), $this->getSession()->getDriver()); + } + } + + /** + * Validate the response against a DTD. + * + * The DTD is embedded as an internal subset and the response reloaded with + * validation enabled, so a DTD from a file and an inline DTD share one code + * path without exposing external-entity loading. + * + * DTDs are namespace-unaware, so a namespaced response is validated verbatim + * and its `xmlns` attributes must be declared in the DTD, matching native DTD + * validation semantics. + * + * @param string $dtd + * The DTD source (element, attribute and entity declarations). + */ + protected function xmlValidateDtd(string $dtd): void { + $this->xmlEnsureDocument(); + + $root = $this->xmlDocument->documentElement; + if (!$root instanceof \DOMElement) { + // @codeCoverageIgnoreStart + throw new ExpectationException('The response has no root element to validate against the DTD.', $this->getSession()->getDriver()); + // @codeCoverageIgnoreEnd + } + + $body = $this->xmlDocument->saveXML($root); + if ($body === FALSE) { + // @codeCoverageIgnoreStart + throw new ExpectationException('Failed to serialise the response for DTD validation.', $this->getSession()->getDriver()); + // @codeCoverageIgnoreEnd + } + + $combined = sprintf("\n\n%s", $root->nodeName, $dtd, $body); + + $document = new \DOMDocument(); + libxml_clear_errors(); + $loaded = @$document->loadXML($combined, LIBXML_DTDVALID); + $errors = libxml_get_errors(); + libxml_clear_errors(); + + if (!$loaded || $errors !== []) { + throw new ExpectationException(sprintf('The response does not match the DTD: %s', $this->xmlFormatErrors($errors)), $this->getSession()->getDriver()); + } + } + + /** + * Validate the response as an RSS 2.0 feed. + */ + protected function xmlValidateRssFeed(): void { + $this->xmlEnsureDocument(); + + $root = $this->xmlDocument->documentElement; + if (!$root instanceof \DOMElement || $root->localName !== 'rss') { + throw new ExpectationException('The response is not a valid RSS feed: the root element must be "rss".', $this->getSession()->getDriver()); + } + + if ($root->getAttribute('version') !== '2.0') { + throw new ExpectationException('The response is not a valid RSS feed: the "rss" element must have a "version" attribute of "2.0".', $this->getSession()->getDriver()); + } + + $channels = $this->xmlDirectChildElements($root, 'channel'); + if (count($channels) !== 1) { + throw new ExpectationException('The response is not a valid RSS feed: the "rss" element must contain exactly one "channel" element.', $this->getSession()->getDriver()); + } + + $channel = $channels[0]; + + foreach (['title', 'link', 'description'] as $required) { + if ($this->xmlDirectChildElements($channel, $required) === []) { + throw new ExpectationException(sprintf('The response is not a valid RSS feed: the "channel" element is missing the required "%s" element.', $required), $this->getSession()->getDriver()); + } + } + + foreach ($this->xmlDirectChildElements($channel, 'item') as $item) { + $has_title = $this->xmlDirectChildElements($item, 'title') !== []; + $has_description = $this->xmlDirectChildElements($item, 'description') !== []; + + if (!$has_title && !$has_description) { + throw new ExpectationException('The response is not a valid RSS feed: each "item" element must contain a "title" or a "description" element.', $this->getSession()->getDriver()); + } + } + } + + /** + * Validate the response as an Atom feed. + */ + protected function xmlValidateAtomFeed(): void { + $this->xmlEnsureDocument(); + + $namespace = 'http://www.w3.org/2005/Atom'; + + $root = $this->xmlDocument->documentElement; + if (!$root instanceof \DOMElement || $root->localName !== 'feed' || $root->namespaceURI !== $namespace) { + throw new ExpectationException('The response is not a valid Atom feed: the root element must be "feed" in the Atom namespace.', $this->getSession()->getDriver()); + } + + foreach (['id', 'title', 'updated'] as $required) { + if ($this->xmlDirectChildElements($root, $required, $namespace) === []) { + throw new ExpectationException(sprintf('The response is not a valid Atom feed: the "feed" element is missing the required "%s" element.', $required), $this->getSession()->getDriver()); + } + } + + foreach ($this->xmlDirectChildElements($root, 'entry', $namespace) as $entry) { + foreach (['id', 'title', 'updated'] as $required) { + if ($this->xmlDirectChildElements($entry, $required, $namespace) === []) { + throw new ExpectationException(sprintf('The response is not a valid Atom feed: an "entry" element is missing the required "%s" element.', $required), $this->getSession()->getDriver()); + } + } + } + } + + /** + * Get direct child elements matching a local name and optional namespace. + * + * @param \DOMNode $parent + * The parent node. + * @param string $name + * The local element name to match. + * @param string|null $namespace + * The namespace URI to match, or NULL to match elements in any namespace. + * + * @return array + * The matching direct child elements. + */ + protected function xmlDirectChildElements(\DOMNode $parent, string $name, ?string $namespace = NULL): array { + $matches = []; + + foreach ($parent->childNodes as $child) { + if (!$child instanceof \DOMElement || $child->localName !== $name) { + continue; + } + + if ($namespace !== NULL && $child->namespaceURI !== $namespace) { + continue; + } + + $matches[] = $child; + } + + return $matches; + } + } diff --git a/tests/behat/features/xml.feature b/tests/behat/features/xml.feature index 46f6cce6..259be5b4 100644 --- a/tests/behat/features/xml.feature +++ b/tests/behat/features/xml.feature @@ -647,3 +647,264 @@ Feature: Check that XmlTrait works """ Unable to access the response before visiting a page """ + + Scenario: Assert "Then the response should match the following XSD schema:" works + Given the response content is the following: + """ + World + """ + Then the response should match the following XSD schema: + """ + + + + + + + + + + + """ + + Scenario: Assert "Then the response should match the XSD schema in the file :filename" works + When I go to "/sites/default/files/xml_valid.xml" + Then the response should match the XSD schema in the file "xml_schema.xsd" + + @trait:XmlTrait + Scenario: Assert that negative assertion for "Then the response should match the XSD schema in the file :filename" fails with an error + Given some behat configuration + And scenario steps: + """ + When I go to "/sites/default/files/xml_simple.xml" + Then the response should match the XSD schema in the file "xml_schema.xsd" + """ + When I run "behat --no-colors" + Then it should fail with an error: + """ + The response does not match the XSD schema + """ + + @trait:XmlTrait + Scenario: Assert that "Then the response should match the XSD schema in the file :filename" fails with an exception for missing file + Given some behat configuration + And scenario steps: + """ + When I go to "/sites/default/files/xml_valid.xml" + Then the response should match the XSD schema in the file "nonexistent.xsd" + """ + When I run "behat --no-colors" + Then it should fail with an exception: + """ + does not exist + """ + + Scenario: Assert "Then the response should match the following DTD:" works + Given the response content is the following: + """ + Hello + """ + Then the response should match the following DTD: + """ + + """ + + Scenario: Assert "Then the response should match the DTD in the file :filename" works + When I go to "/sites/default/files/xml_valid.xml" + Then the response should match the DTD in the file "xml_schema.dtd" + + @trait:XmlTrait + Scenario: Assert that negative assertion for "Then the response should match the DTD in the file :filename" fails with an error + Given some behat configuration + And scenario steps: + """ + When I go to "/sites/default/files/xml_simple.xml" + Then the response should match the DTD in the file "xml_schema.dtd" + """ + When I run "behat --no-colors" + Then it should fail with an error: + """ + The response does not match the DTD + """ + + Scenario: Assert "Then the response should match the following RelaxNG schema:" works + Given the response content is the following: + """ + Hello + """ + Then the response should match the following RelaxNG schema: + """ + + + + """ + + Scenario: Assert "Then the response should match the RelaxNG schema in the file :filename" works + When I go to "/sites/default/files/xml_valid.xml" + Then the response should match the RelaxNG schema in the file "xml_schema.rng" + + @trait:XmlTrait + Scenario: Assert that negative assertion for "Then the response should match the RelaxNG schema in the file :filename" fails with an error + Given some behat configuration + And scenario steps: + """ + When I go to "/sites/default/files/xml_simple.xml" + Then the response should match the RelaxNG schema in the file "xml_schema.rng" + """ + When I run "behat --no-colors" + Then it should fail with an error: + """ + The response does not match the RelaxNG schema + """ + + Scenario: Assert "Then the response should be a valid RSS feed" works + When I go to "/sites/default/files/rss_valid.xml" + Then the response should be a valid RSS feed + + @trait:XmlTrait + Scenario: Assert that negative assertion for "Then the response should be a valid RSS feed" fails with an error for a non-rss root + Given some behat configuration + And scenario steps: + """ + When I go to "/sites/default/files/xml_valid.xml" + And the response content is the following: + ''' + + ''' + Then the response should be a valid RSS feed + """ + When I run "behat --no-colors" + Then it should fail with an error: + """ + the root element must be "rss" + """ + + @trait:XmlTrait + Scenario: Assert that negative assertion for "Then the response should be a valid RSS feed" fails with an error for a wrong version + Given some behat configuration + And scenario steps: + """ + When I go to "/sites/default/files/xml_valid.xml" + And the response content is the following: + ''' + tld + ''' + Then the response should be a valid RSS feed + """ + When I run "behat --no-colors" + Then it should fail with an error: + """ + must have a "version" attribute of "2.0" + """ + + @trait:XmlTrait + Scenario: Assert that negative assertion for "Then the response should be a valid RSS feed" fails with an error for a missing channel + Given some behat configuration + And scenario steps: + """ + When I go to "/sites/default/files/xml_valid.xml" + And the response content is the following: + ''' + + ''' + Then the response should be a valid RSS feed + """ + When I run "behat --no-colors" + Then it should fail with an error: + """ + must contain exactly one "channel" element + """ + + @trait:XmlTrait + Scenario: Assert that negative assertion for "Then the response should be a valid RSS feed" fails with an error for a missing required channel element + Given some behat configuration + And scenario steps: + """ + When I go to "/sites/default/files/xml_valid.xml" + And the response content is the following: + ''' + tl + ''' + Then the response should be a valid RSS feed + """ + When I run "behat --no-colors" + Then it should fail with an error: + """ + is missing the required "description" element + """ + + @trait:XmlTrait + Scenario: Assert that negative assertion for "Then the response should be a valid RSS feed" fails with an error for an item without a title or description + Given some behat configuration + And scenario steps: + """ + When I go to "/sites/default/files/xml_valid.xml" + And the response content is the following: + ''' + tldx + ''' + Then the response should be a valid RSS feed + """ + When I run "behat --no-colors" + Then it should fail with an error: + """ + each "item" element must contain a "title" or a "description" + """ + + Scenario: Assert "Then the response should be a valid Atom feed" works + When I go to "/sites/default/files/atom_valid.xml" + Then the response should be a valid Atom feed + + @trait:XmlTrait + Scenario: Assert that negative assertion for "Then the response should be a valid Atom feed" fails with an error for a non-atom root + Given some behat configuration + And scenario steps: + """ + When I go to "/sites/default/files/xml_valid.xml" + And the response content is the following: + ''' + xtu + ''' + Then the response should be a valid Atom feed + """ + When I run "behat --no-colors" + Then it should fail with an error: + """ + the root element must be "feed" in the Atom namespace + """ + + @trait:XmlTrait + Scenario: Assert that negative assertion for "Then the response should be a valid Atom feed" fails with an error for a missing required feed element + Given some behat configuration + And scenario steps: + """ + When I go to "/sites/default/files/xml_valid.xml" + And the response content is the following: + ''' + xt + ''' + Then the response should be a valid Atom feed + """ + When I run "behat --no-colors" + Then it should fail with an error: + """ + the "feed" element is missing the required "updated" element + """ + + @trait:XmlTrait + Scenario: Assert that negative assertion for "Then the response should be a valid Atom feed" fails with an error for an entry missing a required element + Given some behat configuration + And scenario steps: + """ + When I go to "/sites/default/files/xml_valid.xml" + And the response content is the following: + ''' + xtueet2024 + ''' + Then the response should be a valid Atom feed + """ + When I run "behat --no-colors" + Then it should fail with an error: + """ + an "entry" element is missing the required "updated" element + """ diff --git a/tests/behat/fixtures/atom_valid.xml b/tests/behat/fixtures/atom_valid.xml new file mode 100644 index 00000000..657e4a68 --- /dev/null +++ b/tests/behat/fixtures/atom_valid.xml @@ -0,0 +1,16 @@ + + + urn:uuid:example-feed + Example Feed + 2024-01-01T00:00:00Z + + urn:uuid:example-entry-1 + First entry + 2024-01-01T00:00:00Z + + + urn:uuid:example-entry-2 + Second entry + 2024-01-02T00:00:00Z + + diff --git a/tests/behat/fixtures/rss_valid.xml b/tests/behat/fixtures/rss_valid.xml new file mode 100644 index 00000000..606e05c5 --- /dev/null +++ b/tests/behat/fixtures/rss_valid.xml @@ -0,0 +1,17 @@ + + + + Example Feed + https://example.com/ + An example RSS 2.0 feed. + + First item + https://example.com/first + The first item description. + + + Second item + https://example.com/second + + + diff --git a/tests/behat/fixtures/xml_schema.dtd b/tests/behat/fixtures/xml_schema.dtd new file mode 100644 index 00000000..aba7ee1d --- /dev/null +++ b/tests/behat/fixtures/xml_schema.dtd @@ -0,0 +1,9 @@ + + + + + + + diff --git a/tests/behat/fixtures/xml_schema.rng b/tests/behat/fixtures/xml_schema.rng new file mode 100644 index 00000000..ce980868 --- /dev/null +++ b/tests/behat/fixtures/xml_schema.rng @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/behat/fixtures/xml_schema.xsd b/tests/behat/fixtures/xml_schema.xsd new file mode 100644 index 00000000..6ba85b3b --- /dev/null +++ b/tests/behat/fixtures/xml_schema.xsd @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + +