Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -42,12 +45,31 @@ public function handle( Parser $parser, string $cypherQuery ): string {
return $this->formatResult( $json );
}

private function formatResult( string $json ): string {
return '<div class="mw-neowiki-cypher-result"><pre>' . htmlspecialchars( $json ) . '</pre></div>';
/**
* @return array{0: string, noparse: true, isHTML: true}
*/
private function formatResult( string $json ): array {
return $this->asHtml( '<div class="mw-neowiki-cypher-result"><pre>' . htmlspecialchars( $json ) . '</pre></div>' );
}

private function formatError( string $message ): string {
return '<div class="error">' . htmlspecialchars( $message ) . '</div>';
/**
* @return array{0: string, noparse: true, isHTML: true}
*/
private function formatError( string $message ): array {
return $this->asHtml( '<div class="error">' . htmlspecialchars( $message ) . '</div>' );
}

/**
* 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 ];
}

}
2 changes: 1 addition & 1 deletion src/GraphDatabasePlugins/Neo4j/Neo4jPlugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
}
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<?php

declare( strict_types = 1 );

namespace ProfessionalWiki\NeoWiki\Tests\GraphDatabasePlugins\Neo4j\EntryPoints\ParserFunction;

use Laudis\Neo4j\Databags\SummarizedResult;
use Laudis\Neo4j\Types\CypherMap;
use MediaWiki\Parser\Parser;
use MediaWiki\Parser\ParserOptions;
use MediaWiki\Title\Title;
use MediaWikiIntegrationTestCase;
use ProfessionalWiki\NeoWiki\GraphDatabasePlugins\Neo4j\Application\KeywordCypherQueryValidator;
use ProfessionalWiki\NeoWiki\GraphDatabasePlugins\Neo4j\Application\Neo4jQueryService;
use ProfessionalWiki\NeoWiki\GraphDatabasePlugins\Neo4j\Application\Neo4jReadQueryEngine;
use ProfessionalWiki\NeoWiki\GraphDatabasePlugins\Neo4j\EntryPoints\ParserFunction\CypherRawParserFunction;
use ProfessionalWiki\NeoWiki\GraphDatabasePlugins\Neo4j\Persistence\Neo4jResultNormalizer;

/**
* What a reader ends up with, rather than what the parser function returns: the corruption this
* guards against happens in MediaWiki's parser, after the function has handed its text back, so it
* is invisible to a test that only inspects the return value.
*
* @covers \ProfessionalWiki\NeoWiki\GraphDatabasePlugins\Neo4j\EntryPoints\ParserFunction\CypherRawParserFunction
* @group Database
*/
class CypherRawParserFunctionParsingTest extends MediaWikiIntegrationTestCase {

private const string WEBSITE = 'https://www.rijksmuseum.nl';

private function parseCypherRaw(): string {
$parser = $this->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( '<a ', $html );
$this->assertStringNotContainsString( '%22', $html );
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -88,20 +88,29 @@ 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 {
$parserFunction = $this->createParserFunction( $this->createDummyQueryEngine() );

$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 {
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -153,17 +162,17 @@ public function testValidReadQueryReturnsFormattedResult(): void {

$result = $parserFunction->handle( $this->createParser(), 'MATCH (n:Person) RETURN n' );

$this->assertStringContainsString( '<div class="mw-neowiki-cypher-result"><pre>', $result );
$this->assertStringContainsString( 'Alice', $result );
$this->assertStringContainsString( 'Bob', $result );
$this->assertStringContainsString( '<div class="mw-neowiki-cypher-result"><pre>', $this->html( $result ) );
$this->assertStringContainsString( 'Alice', $this->html( $result ) );
$this->assertStringContainsString( 'Bob', $this->html( $result ) );
}

public function testTrimWhitespaceFromQuery(): void {
$parserFunction = $this->createParserFunction( $this->createQueryEngineWithData( [] ) );

$result = $parserFunction->handle( $this->createParser(), ' MATCH (n) RETURN n ' );

$this->assertStringContainsString( '<div class="mw-neowiki-cypher-result"><pre>', $result );
$this->assertStringContainsString( '<div class="mw-neowiki-cypher-result"><pre>', $this->html( $result ) );
}

public function testOutputIsHTMLEscaped(): void {
Expand All @@ -175,8 +184,42 @@ public function testOutputIsHTMLEscaped(): void {

$result = $parserFunction->handle( $this->createParser(), 'MATCH (n) RETURN n' );

$this->assertStringNotContainsString( '<script>alert', $result );
$this->assertStringContainsString( '&lt;script&gt;', $result );
$this->assertStringNotContainsString( '<script>alert', $this->html( $result ) );
$this->assertStringContainsString( '&lt;script&gt;', $this->html( $result ) );
}

public function testResultIsArmouredAgainstWikitextTransformation(): void {
$parserFunction = $this->createParserFunction(
$this->createQueryEngineWithData( [ [ 'site' => 'https://example.com' ] ] )
);

$result = $parserFunction->handle( $this->createParser(), 'MATCH (n) RETURN n.site AS site' );

$this->assertIsArray( $result, 'Expected an isHTML array so the parser leaves the JSON alone.' );
$this->assertTrue( $result['isHTML'] );
$this->assertTrue( $result['noparse'] );
}

public function testErrorIsArmouredAgainstWikitextTransformation(): void {
$neo4jException = new Neo4jException( [
Neo4jError::fromMessageAndCode(
'Neo.ClientError.Statement.SyntaxError',
"Invalid input near 'https://example.com'"
),
] );

$parserFunction = $this->createParserFunction(
$this->createQueryEngineWithException( $neo4jException )
);

$result = $parserFunction->handle(
$this->createParser(),
"MATCH (n) WHERE n.site = 'https://example.com' RETURN n"
);

$this->assertIsArray( $result, 'Error detail echoes the query, which can carry a URL, so it needs the same armour.' );
$this->assertTrue( $result['isHTML'] );
$this->assertTrue( $result['noparse'] );
}

}
Loading