@@ -186,7 +186,7 @@ public function testNormalizationFromAttributeNode(): void
186186 $ this ->assertInstanceOf (\DOMElement::class, $ elt );
187187
188188 $ attr = $ elt ->getAttributeNodeNS ('urn:bar ' , 'attr ' );
189- $ this -> assertInstanceOf ( \DOMAttr::class, $ attr);
189+ /** @var \DOMAttr $attr */
190190
191191 // getXPath should normalize from DOMAttr to the element and ensure 'bar' is registered.
192192 $ xp = XPath::getXPath ($ attr );
@@ -305,8 +305,26 @@ public static function xmlVariantsProviderForTopLevelSlatePerson(): array
305305 $ base = dirname (__FILE__ , 3 ) . '/tests/resources/xml ' ;
306306
307307 return [
308- "Register Subtree Prefixes " => [$ base . '/success_response_a.xml ' ],
309- "Register Ancestor Namespaces " => [$ base . '/success_response_b.xml ' ],
308+ "Ancestor-declared 'slate'; top-level person AFTER attributes " => [
309+ $ base . '/success_response_a.xml ' ,
310+ false ,
311+ false ,
312+ ],
313+ "Ancestor-declared 'slate'; top-level person BEFORE attributes " => [
314+ $ base . '/success_response_b.xml ' ,
315+ false ,
316+ false ,
317+ ],
318+ "Descendant-only 'slate'; no ancestor binding (fails without autoregister) " => [
319+ $ base . '/success_response_c.xml ' ,
320+ false ,
321+ true ,
322+ ],
323+ "Descendant-only 'slate'; no ancestor binding (succeeds with autoregister) " => [
324+ $ base . '/success_response_c.xml ' ,
325+ true ,
326+ false ,
327+ ],
310328 ];
311329 }
312330
@@ -316,21 +334,102 @@ public static function xmlVariantsProviderForTopLevelSlatePerson(): array
316334 * cas:attributes in the document, even when the slate prefix is only declared on the element itself.
317335 */
318336 #[DataProvider('xmlVariantsProviderForTopLevelSlatePerson ' )]
319- public function testAbsoluteXPathFindsTopLevelSlatePerson (string $ filePath ): void
320- {
337+ public function testAbsoluteXPathFindsTopLevelSlatePerson (
338+ string $ filePath ,
339+ bool $ autoregister ,
340+ bool $ shouldFail ,
341+ ): void {
321342 $ doc = DOMDocumentFactory::fromFile ($ filePath );
322343
323344 $ fooNs = 'https://example.org/foo ' ;
324- /** @var \DOMElement|null $authn */
325- $ authn = $ doc ->getElementsByTagNameNS ($ fooNs , 'authenticationSuccess ' )->item (0 );
326- $ this ->assertNotNull ($ authn , 'authenticationSuccess element not found ' );
345+ /** @var \DOMElement|null $attributesNode */
346+ $ attributesNode = $ doc ->getElementsByTagNameNS ($ fooNs , 'attributes ' )->item (0 );
347+ $ this ->assertNotNull ($ attributesNode , 'Attributes element not found ' );
327348
328- $ xp = XPath::getXPath ($ authn );
349+ $ xp = XPath::getXPath ($ attributesNode , $ autoregister );
329350 $ query = '/foo:serviceResponse/foo:authenticationSuccess/slate:person ' ;
330351
331- $ nodes = XPath::xpQuery ($ authn , $ query , $ xp );
352+ if ($ shouldFail ) {
353+ libxml_use_internal_errors (true );
354+ try {
355+ $ this ->expectException (\SimpleSAML \Assert \AssertionFailedException::class);
356+ $ this ->expectExceptionMessage ('Malformed XPath query or invalid contextNode provided. ' );
357+ XPath::xpQuery ($ attributesNode , $ query , $ xp );
358+ } finally {
359+ $ errors = libxml_get_errors ();
360+ $ this ->assertNotEmpty ($ errors );
361+ $ this ->assertSame ("Undefined namespace prefix \n" , $ errors [0 ]->message );
362+ libxml_clear_errors ();
363+ libxml_use_internal_errors (false );
364+ }
365+ return ;
366+ }
332367
333- $ this ->assertSame (1 , count ($ nodes ), 'Expected exactly one top-level slate:person ' );
368+ $ nodes = XPath::xpQuery ($ attributesNode , $ query , $ xp );
369+ $ this ->assertCount (1 , $ nodes );
334370 $ this ->assertSame ('12345_top ' , trim ($ nodes [0 ]->textContent ));
335371 }
372+
373+
374+ public function testFindElementFindsDirectChildUnprefixed (): void
375+ {
376+ $ doc = new DOMDocument ();
377+ $ doc ->loadXML ('<root><target>t</target><other/></root> ' );
378+
379+ $ root = $ doc ->documentElement ;
380+ $ this ->assertInstanceOf (DOMElement::class, $ root );
381+
382+ $ found = XPath::findElement ($ root , 'target ' );
383+ $ this ->assertInstanceOf (DOMElement::class, $ found );
384+ $ this ->assertSame ('target ' , $ found ->localName );
385+ $ this ->assertSame ('t ' , $ found ->textContent );
386+ }
387+
388+
389+ public function testFindElementFindsDirectChildWithPrefixWhenNsOnRoot (): void
390+ {
391+ $ xml = <<<'XML'
392+ <?xml version="1.0" encoding="UTF-8"?>
393+ <root xmlns:foo="https://example.org/foo">
394+ <foo:item>ok</foo:item>
395+ </root>
396+ XML;
397+ $ doc = new DOMDocument ();
398+ $ doc ->loadXML ($ xml );
399+
400+ $ root = $ doc ->documentElement ;
401+ $ this ->assertInstanceOf (DOMElement::class, $ root );
402+
403+ // Namespace is declared on root, so getXPath($doc) used by findElement knows 'foo'
404+ $ found = XPath::findElement ($ root , 'foo:item ' );
405+ $ this ->assertInstanceOf (DOMElement::class, $ found );
406+ $ this ->assertSame ('item ' , $ found ->localName );
407+ $ this ->assertSame ('https://example.org/foo ' , $ found ->namespaceURI );
408+ $ this ->assertSame ('ok ' , $ found ->textContent );
409+ }
410+
411+
412+ public function testFindElementReturnsFalseWhenNotFoundAndDoesNotDescend (): void
413+ {
414+ // 'target' is a grandchild; findElement should only match direct children via './name'
415+ $ doc = new DOMDocument ();
416+ $ doc ->loadXML ('<root><container><target/></container></root> ' );
417+
418+ $ root = $ doc ->documentElement ;
419+ $ this ->assertInstanceOf (DOMElement::class, $ root );
420+
421+ $ found = XPath::findElement ($ root , 'target ' );
422+ $ this ->assertFalse ($ found , 'Should return false for non-direct child ' );
423+ }
424+
425+
426+ public function testFindElementThrowsIfNoOwnerDocument (): void
427+ {
428+ // A standalone DOMElement (not created by a DOMDocument) has no ownerDocument
429+ $ ref = new \DOMElement ('container ' );
430+
431+ $ this ->expectException (\RuntimeException::class);
432+ $ this ->expectExceptionMessage ('Cannot search, no DOMDocument available ' );
433+ XPath::findElement ($ ref , 'anything ' );
434+ }
336435}
0 commit comments