diff --git a/src/GraphDatabasePlugins/Neo4j/EntryPoints/ParserFunction/CypherRawParserFunction.php b/src/GraphDatabasePlugins/Neo4j/EntryPoints/ParserFunction/CypherRawParserFunction.php index 89b72ec49..4dbc436b0 100644 --- a/src/GraphDatabasePlugins/Neo4j/EntryPoints/ParserFunction/CypherRawParserFunction.php +++ b/src/GraphDatabasePlugins/Neo4j/EntryPoints/ParserFunction/CypherRawParserFunction.php @@ -19,7 +19,10 @@ public function __construct( ) { } - public function handle( Parser $parser, string $cypherQuery ): string { + /** + * @return array{0: string, noparse: true, isHTML: true} + */ + public function handle( Parser $parser, string $cypherQuery ): array { try { $result = $this->queryService->execute( new Neo4jQueryRequest( @@ -42,12 +45,31 @@ public function handle( Parser $parser, string $cypherQuery ): string { return $this->formatResult( $json ); } - private function formatResult( string $json ): string { - return '
' . htmlspecialchars( $json ) . '
'; + /** + * @return array{0: string, noparse: true, isHTML: true} + */ + private function formatResult( string $json ): array { + return $this->asHtml( '
' . htmlspecialchars( $json ) . '
' ); } - private function formatError( string $message ): string { - return '
' . htmlspecialchars( $message ) . '
'; + /** + * @return array{0: string, noparse: true, isHTML: true} + */ + private function formatError( string $message ): array { + return $this->asHtml( '
' . htmlspecialchars( $message ) . '
' ); + } + + /** + * Hands the HTML to the parser as HTML rather than wikitext. Without this the text is parsed as + * wikitext, which autolinks any URL a returned value happens to hold: the trailing quote of the + * URL is swallowed into the link and percent-encoded, leaving invalid JSON with anchors in it. + * Error detail needs the same treatment, as the store's message echoes the offending query, + * which can itself contain a URL. + * + * @return array{0: string, noparse: true, isHTML: true} + */ + private function asHtml( string $html ): array { + return [ $html, 'noparse' => true, 'isHTML' => true ]; } } diff --git a/src/GraphDatabasePlugins/Neo4j/Neo4jPlugin.php b/src/GraphDatabasePlugins/Neo4j/Neo4jPlugin.php index 8297306f5..dcda6e167 100644 --- a/src/GraphDatabasePlugins/Neo4j/Neo4jPlugin.php +++ b/src/GraphDatabasePlugins/Neo4j/Neo4jPlugin.php @@ -84,7 +84,7 @@ public function registerParserFunctions( Parser $parser ): void { $parser->setFunctionHook( 'cypher_raw', - static function ( Parser $parser, string $cypherQuery ) use ( $queryService ): string { + static function ( Parser $parser, string $cypherQuery ) use ( $queryService ): array { return ( new CypherRawParserFunction( $queryService ) )->handle( $parser, $cypherQuery ); } ); diff --git a/tests/phpunit/GraphDatabasePlugins/Neo4j/EntryPoints/ParserFunction/CypherRawParserFunctionParsingTest.php b/tests/phpunit/GraphDatabasePlugins/Neo4j/EntryPoints/ParserFunction/CypherRawParserFunctionParsingTest.php new file mode 100644 index 000000000..5c6917eac --- /dev/null +++ b/tests/phpunit/GraphDatabasePlugins/Neo4j/EntryPoints/ParserFunction/CypherRawParserFunctionParsingTest.php @@ -0,0 +1,78 @@ +getServiceContainer()->getParserFactory()->create(); + $queryService = $this->queryServiceReturningWebsite(); + + $parser->setFunctionHook( + 'cypher_raw', + static function ( Parser $parser, string $cypherQuery ) use ( $queryService ): string|array { + return ( new CypherRawParserFunction( $queryService ) )->handle( $parser, $cypherQuery ); + } + ); + + return $parser->parse( + '{{#cypher_raw: MATCH (m:Museum) RETURN m.Website AS site }}', + Title::makeTitle( NS_MAIN, 'CypherRawParsingTest' ), + ParserOptions::newFromAnon() + )->getText(); + } + + private function queryServiceReturningWebsite(): Neo4jQueryService { + // SummarizedResult takes its summary by reference, so it needs a variable. + $summary = null; + + $queryEngine = $this->createMock( Neo4jReadQueryEngine::class ); + $queryEngine->method( 'runReadQuery' )->willReturn( + new SummarizedResult( $summary, [ new CypherMap( [ 'site' => self::WEBSITE ] ) ] ) + ); + + return new Neo4jQueryService( + $queryEngine, + new KeywordCypherQueryValidator(), + new Neo4jResultNormalizer(), + ); + } + + public function testUrlValueSurvivesParsingUnchanged(): void { + $html = html_entity_decode( $this->parseCypherRaw() ); + + $this->assertStringContainsString( '"site": "' . self::WEBSITE . '"', $html ); + } + + public function testUrlsAreNotTurnedIntoLinks(): void { + $html = $this->parseCypherRaw(); + + $this->assertStringNotContainsString( 'assertStringNotContainsString( '%22', $html ); + } + +} diff --git a/tests/phpunit/GraphDatabasePlugins/Neo4j/EntryPoints/ParserFunction/CypherRawParserFunctionTest.php b/tests/phpunit/GraphDatabasePlugins/Neo4j/EntryPoints/ParserFunction/CypherRawParserFunctionTest.php index 9be38ec2b..55a5a499a 100644 --- a/tests/phpunit/GraphDatabasePlugins/Neo4j/EntryPoints/ParserFunction/CypherRawParserFunctionTest.php +++ b/tests/phpunit/GraphDatabasePlugins/Neo4j/EntryPoints/ParserFunction/CypherRawParserFunctionTest.php @@ -88,12 +88,21 @@ private function createParserFunction( ); } + /** + * The HTML the parser function hands back, from either its result or its error shape. + * + * @param array{0: string, noparse: true, isHTML: true} $result + */ + private function html( array $result ): string { + return $result[0]; + } + public function testEmptyQueryShowsLocalizedError(): void { $parserFunction = $this->createParserFunction( $this->createDummyQueryEngine() ); $result = $parserFunction->handle( $this->createParser(), '' ); - $this->assertStringContainsString( '[neowiki-cypher-error-empty-query]', $result ); + $this->assertStringContainsString( '[neowiki-cypher-error-empty-query]', $this->html( $result ) ); } public function testWriteQueryShowsLocalizedError(): void { @@ -101,7 +110,7 @@ public function testWriteQueryShowsLocalizedError(): void { $result = $parserFunction->handle( $this->createParser(), "CREATE (n:Person {name: 'Alice'})" ); - $this->assertStringContainsString( '[neowiki-cypher-error-write-query]', $result ); + $this->assertStringContainsString( '[neowiki-cypher-error-write-query]', $this->html( $result ) ); } public function testEngineFailureShowsLocalizedBackendError(): void { @@ -111,7 +120,7 @@ public function testEngineFailureShowsLocalizedBackendError(): void { $result = $parserFunction->handle( $this->createParser(), 'MATCH (n) RETURN n' ); - $this->assertStringContainsString( '[neowiki-cypher-error-backend-unavailable]', $result ); + $this->assertStringContainsString( '[neowiki-cypher-error-backend-unavailable]', $this->html( $result ) ); } public function testSyntaxErrorForwardsNeo4jDetailToLocalizedMessage(): void { @@ -125,8 +134,8 @@ public function testSyntaxErrorForwardsNeo4jDetailToLocalizedMessage(): void { $result = $parserFunction->handle( $this->createParser(), 'MATCH (n) RETURN x' ); - $this->assertStringContainsString( '[neowiki-cypher-error-syntax|', $result ); - $this->assertStringContainsString( 'Invalid input near RETURN', $result ); + $this->assertStringContainsString( '[neowiki-cypher-error-syntax|', $this->html( $result ) ); + $this->assertStringContainsString( 'Invalid input near RETURN', $this->html( $result ) ); } public function testValidatorFailureShowsLocalizedBackendError(): void { @@ -140,7 +149,7 @@ public function queryIsAllowed( string $cypher ): bool { $result = $parserFunction->handle( $this->createParser(), 'MATCH (n) RETURN n' ); - $this->assertStringContainsString( '[neowiki-cypher-error-backend-unavailable]', $result ); + $this->assertStringContainsString( '[neowiki-cypher-error-backend-unavailable]', $this->html( $result ) ); } public function testValidReadQueryReturnsFormattedResult(): void { @@ -153,9 +162,9 @@ public function testValidReadQueryReturnsFormattedResult(): void { $result = $parserFunction->handle( $this->createParser(), 'MATCH (n:Person) RETURN n' ); - $this->assertStringContainsString( '
', $result );
-		$this->assertStringContainsString( 'Alice', $result );
-		$this->assertStringContainsString( 'Bob', $result );
+		$this->assertStringContainsString( '
', $this->html( $result ) );
+		$this->assertStringContainsString( 'Alice', $this->html( $result ) );
+		$this->assertStringContainsString( 'Bob', $this->html( $result ) );
 	}
 
 	public function testTrimWhitespaceFromQuery(): void {
@@ -163,7 +172,7 @@ public function testTrimWhitespaceFromQuery(): void {
 
 		$result = $parserFunction->handle( $this->createParser(), '  MATCH (n) RETURN n  ' );
 
-		$this->assertStringContainsString( '
', $result );
+		$this->assertStringContainsString( '
', $this->html( $result ) );
 	}
 
 	public function testOutputIsHTMLEscaped(): void {
@@ -175,8 +184,42 @@ public function testOutputIsHTMLEscaped(): void {
 
 		$result = $parserFunction->handle( $this->createParser(), 'MATCH (n) RETURN n' );
 
-		$this->assertStringNotContainsString( '