Skip to content
Draft
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
21 changes: 15 additions & 6 deletions src/EntryPoints/ViewParserFunction.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ public function handle( Parser $parser, string ...$args ): string|array {
$parsed = $this->parseArgs( $parser, $args );

if ( is_string( $parsed ) ) {
return $parsed;
return $this->asHtml( $parsed );
}

[ $explicitSubjectId, $layoutName ] = $parsed;
Expand All @@ -36,11 +36,7 @@ public function handle( Parser $parser, string ...$args ): string|array {
return '';
}

return [
ViewHtmlBuilder::viewPlaceholderHtml( $resolvedSubjectId, $layoutName ),
'noparse' => true,
'isHTML' => true,
];
return $this->asHtml( ViewHtmlBuilder::viewPlaceholderHtml( $resolvedSubjectId, $layoutName ) );
}

/**
Expand Down Expand Up @@ -146,4 +142,17 @@ private function resolveMainSubjectId( Parser $parser ): ?string {
return $subject?->getId()->text;
}

/**
* Hands the HTML to the parser as HTML rather than wikitext. Without this the text is parsed as
* wikitext, which autolinks any URL it happens to hold: the URL is swallowed into a link and its
* trailing quote percent-encoded, corrupting the error box. The error messages echo the offending
* user-supplied argument, which can itself carry a URL, so they need this treatment. The
* subject placeholder is armoured the same way.
*
* @return array{0: string, noparse: true, isHTML: true}
*/
private function asHtml( string $html ): array {
return [ $html, 'noparse' => true, 'isHTML' => true ];
}

}
56 changes: 56 additions & 0 deletions tests/phpunit/EntryPoints/ViewParserFunctionParsingTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php

declare( strict_types = 1 );

namespace ProfessionalWiki\NeoWiki\Tests\EntryPoints;

use MediaWiki\Parser\Parser;
use MediaWiki\Parser\ParserOptions;
use MediaWiki\Title\Title;
use MediaWikiIntegrationTestCase;
use ProfessionalWiki\NeoWiki\EntryPoints\ViewParserFunction;
use ProfessionalWiki\NeoWiki\Tests\TestDoubles\InMemorySubjectContentRepository;

/**
* 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\EntryPoints\ViewParserFunction
* @group Database
*/
class ViewParserFunctionParsingTest extends MediaWikiIntegrationTestCase {

private const string URL = 'https://example.com/x';

private function parseView(): string {
$parser = $this->getServiceContainer()->getParserFactory()->create();

$parser->setFunctionHook(
'view',
static function ( Parser $parser, string ...$args ): string|array {
return ( new ViewParserFunction( new InMemorySubjectContentRepository() ) )->handle( $parser, ...$args );
}
);

return $parser->parse(
'{{#view: Foo | "' . self::URL . '" }}',
Title::makeTitle( NS_MAIN, 'ViewParsingTest' ),
ParserOptions::newFromAnon()
)->getText();
}

public function testErrorUrlSurvivesParsingUnchanged(): void {
$html = html_entity_decode( $this->parseView() );

$this->assertStringContainsString( '"' . self::URL . '"', $html );
}

public function testErrorUrlIsNotTurnedIntoLink(): void {
$html = $this->parseView();

$this->assertStringNotContainsString( '<a ', $html );
$this->assertStringNotContainsString( '%22', $html );
}

}
29 changes: 24 additions & 5 deletions tests/phpunit/EntryPoints/ViewParserFunctionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,14 @@ public function testRendersErrorOnArgWithEmptyName(): void {
$this->assertRendersError( $result, 'neowiki-view-error-unknown-arg', '=Finances' );
}

public function testErrorIsArmouredAgainstWikitextTransformation(): void {
$result = $this->callView( self::EXPLICIT_SUBJECT_ID, 'https://example.com' );

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

private function callView( string ...$args ): string|array {
return ( new ViewParserFunction( $this->repositoryWithMainSubject() ) )
->handle( $this->createMockParser(), ...$args );
Expand Down Expand Up @@ -165,7 +173,7 @@ private function assertRendersSubject( string|array $result, string $subjectId,
$this->assertTrue( $result['isHTML'] );
$this->assertTrue( $result['noparse'] );

$html = $result[0];
$html = $this->html( $result );
$this->assertStringContainsString( 'data-mw-neowiki-subject-id="' . $subjectId . '"', $html );

if ( $layoutName === null ) {
Expand All @@ -179,13 +187,24 @@ private function assertRendersSubject( string|array $result, string $subjectId,
* @param string|array{0: string, noparse: true, isHTML: true} $result
*/
private function assertRendersError( string|array $result, string $messageKey, ?string $insertion = null ): void {
$this->assertIsString( $result, 'Expected an error HTML string; got a placeholder array.' );
$this->assertStringContainsString( 'class="error"', $result );
$this->assertStringContainsString( $messageKey, $result );
$this->assertIsArray( $result, 'Expected an armoured error array; got an error string or empty string.' );
$html = $this->html( $result );

$this->assertStringContainsString( 'class="error"', $html );
$this->assertStringContainsString( $messageKey, $html );

if ( $insertion !== null ) {
$this->assertStringContainsString( $insertion, $result );
$this->assertStringContainsString( $insertion, $html );
}
}

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

}
Loading