initTestRepoGroup();
$this->importFileToTestRepo( self::IMAGES_DIR . '/greyscale-png.png', 'Test.png' );
$this->importFileToTestRepo( self::IMAGES_DIR . '/test.jpg', 'Icon.jpg' );
// Create a second version of Test.png
$this->importFileToTestRepo( self::IMAGES_DIR . '/greyscale-na-png.png', 'Test.png' );
$this->importFileToTestRepo( self::IMAGES_DIR . '/portrait-rotated.jpg', 'Icon.jpg' );
// Create a redirect
$title = Title::makeTitle( NS_FILE, 'Redirect_to_Test.png' );
$this->editPage( $title, '#REDIRECT [[File:Test.png]]' );
// Suppress the old version of Icon
$file = $this->getTestRepo()->newFile( 'Icon.jpg' );
$history = $file->getHistory();
$oldFile = $history[0];
$this->db->newUpdateQueryBuilder()
->table( 'oldimage' )
->set( [ 'oi_deleted' => 1 ] )
->where( [ 'oi_archive_name' => $oldFile->getArchiveName() ] )
->caller( __METHOD__ )
->execute();
}
public static function tearDownAfterClass(): void {
self::destroyTestRepo();
parent::tearDownAfterClass();
}
public function setUp(): void {
parent::setUp();
$this->overrideConfigValue( MainConfigNames::ThumbLimits, [ 16, 24 ] );
$this->installTestRepoGroup();
}
/**
* @param FauxRequest|string|array|null $request
*
* @return MockEnvironment
*/
private function makeEnvironment( $request ): MockEnvironment {
if ( !$request ) {
$request = new FauxRequest();
}
if ( is_string( $request ) ) {
$request = [ 'f' => $request, 'width' => self::$uniqueWidth++ ];
}
if ( is_array( $request ) ) {
$request = new FauxRequest( $request );
$request->setRequestURL( '/w/img.php' );
}
return new MockEnvironment( $request );
}
/**
* @param MockEnvironment|null $environment
* @param FauxRequest|RequestContext|string|array|null $request
*
* @return ThumbnailEntryPoint
*/
private function getEntryPoint(
MockEnvironment $environment = null,
$request = null
) {
if ( !$request && $environment ) {
$request = $environment->getFauxRequest();
}
if ( $request instanceof RequestContext ) {
$context = $request;
$request = $context->getRequest();
} else {
$context = new RequestContext();
$context->setRequest( $request );
$context->setUser( $this->getTestUser()->getUser() );
}
if ( !$environment ) {
$environment = $this->makeEnvironment( $request );
}
$context->setLanguage( 'qqx' );
$entryPoint = new ThumbnailEntryPoint(
$context,
$environment,
$this->getServiceContainer()
);
$entryPoint->enableOutputCapture();
return $entryPoint;
}
public function testNotFound() {
$env = $this->makeEnvironment( 'Missing_puppy.jpeg' );
$entryPoint = $this->getEntryPoint( $env );
$entryPoint->run();
$env->assertStatusCode( 404 );
$env->assertHeaderValue(
'text/html; charset=utf-8',
'Content-Type'
);
$output = $entryPoint->getCapturedOutput();
$this->assertStringContainsString(
'
Error generating thumbnail',
$output
);
}
public function testGenerateAndStreamThumbnail() {
$env = $this->makeEnvironment(
[
'f' => 'Test.png',
'width' => 12 // Must match the width in testStreamExistingThumbnail
]
);
$entryPoint = $this->getEntryPoint( $env );
$entryPoint->run();
$output = $entryPoint->getCapturedOutput();
// TODO: Assert Content-Type and Content-Length headers.
// Needs FileStreamer to use WebResponse.
$env->assertStatusCode( 200, $output );
$this->assertThumbnail(
[ 'magic' => self::PNG_MAGIC, 'width' => 12, ],
$output
);
return [ 'data' => $output, 'width' => 12 ];
}
/**
* @depends testGenerateAndStreamThumbnail
*/
public function testStreamExistingThumbnail() {
// Sabotage transformations, so this test will fail if we do not
// use the existing thumbnail generated by testGenerateAndStreamThumbnail.
$handler = $this->getMockBuilder( BitmapHandler::class )
->onlyMethods( [ 'doTransform' ] )
->getMock();
$handler->expects( $this->never() )->method( 'doTransform' );
$factory = $this->createNoOpMock( MediaHandlerFactory::class, [ 'getHandler' ] );
$factory->method( 'getHandler' )->willReturn( $handler );
$this->setService( 'MediaHandlerFactory', $factory );
$env = $this->makeEnvironment(
[
'f' => 'Test.png',
'width' => 12 // Must match the width in testGenerateAndStreamThumbnail
]
);
$entryPoint = $this->getEntryPoint( $env );
$entryPoint->run();
$output = $entryPoint->getCapturedOutput();
$env->assertStatusCode( 200 );
$this->assertThumbnail(
[ 'magic' => self::PNG_MAGIC, 'width' => 12, ],
$output
);
}
public function testNoThumbName() {
// Make sure no handler is set, so that File::generateThumbName() returns null
$factory = $this->createNoOpMock( MediaHandlerFactory::class, [ 'getHandler' ] );
$factory->method( 'getHandler' )->willReturn( false );
$this->setService( 'MediaHandlerFactory', $factory );
$env = $this->makeEnvironment(
[
'f' => 'Test.png',
'width' => self::$uniqueWidth++
]
);
$entryPoint = $this->getEntryPoint( $env );
$entryPoint->run();
$output = $entryPoint->getCapturedOutput();
$env->assertStatusCode( 400, $output );
}
public function testTransformError() {
// Mock transformations to return an error
$handler = $this->getMockBuilder( BitmapHandler::class )
->onlyMethods( [ 'doTransform' ] )
->getMock();
$transformOutput = new MediaTransformError( 'testing', 200, 100 );
$handler->method( 'doTransform' )->willReturn( $transformOutput );
$factory = $this->createNoOpMock( MediaHandlerFactory::class, [ 'getHandler' ] );
$factory->method( 'getHandler' )->willReturn( $handler );
$this->setService( 'MediaHandlerFactory', $factory );
$env = $this->makeEnvironment(
[
'f' => 'Test.png',
'width' => self::$uniqueWidth++
]
);
$entryPoint = $this->getEntryPoint( $env );
$entryPoint->run();
$output = $entryPoint->getCapturedOutput();
$env->assertStatusCode( 500, $output );
}
public function testContentDisposition() {
// TODO...
$this->markTestSkipped( 'Needs refactoring of HTTPFileStreamer to capture headers' );
$env = $this->makeEnvironment(
[
'f' => 'Test.png',
'width' => 12,
'download' => 1
]
);
$entryPoint = $this->getEntryPoint( $env );
$entryPoint->run();
$output = $entryPoint->getCapturedOutput();
$env->assertStatusCode( 200 );
$this->assertThumbnail( [ 'magic' => self::PNG_MAGIC, ], $output );
$env->assertHeaderValue( 'attachment', 'Content-Disposition' );
}
public static function provideThumbNameParam() {
yield [ '12px-Test.png' ];
yield [ 'page123456-12px-xyz' ];
yield [ '12px-xyz' ];
yield [ 'xyzzy', 400 ];
}
/**
* @dataProvider provideThumbNameParam
*/
public function testThumbNameParam( $thumbName, $expected = 200 ) {
$env = $this->makeEnvironment(
[
'f' => 'Test.png',
'thumbName' => $thumbName,
]
);
$entryPoint = $this->getEntryPoint( $env );
$entryPoint->run();
$output = $entryPoint->getCapturedOutput();
$env->assertStatusCode( $expected, $output );
if ( $expected < 300 ) {
$expectedProps = [ 'magic' => self::PNG_MAGIC ];
// get expected width
if ( preg_match( '/\b(\d+)px/', $thumbName, $matches ) ) {
$expectedProps['width'] = (int)$matches[1];
}
$this->assertThumbnail(
$expectedProps,
$output
);
}
}
public function testAccessDenied() {
// Make the wiki non-public
$groupPermissions = $this->getConfVar( MainConfigNames::GroupPermissions );
$groupPermissions['*']['read'] = false;
$this->overrideConfigValue(
'GroupPermissions',
$groupPermissions
);
// Make the user have no rights
$authority = new SimpleAuthority(
new UserIdentityValue( 7, 'Heather' ),
[]
);
$env = $this->makeEnvironment( 'Test.png' );
$context = $env->makeFauxContext();
$context->setAuthority( $authority );
$entryPoint = $this->getEntryPoint(
$env,
$context
);
$entryPoint->run();
$env->assertStatusCode( 403 );
$env->assertHeaderValue(
'text/html; charset=utf-8',
'Content-Type'
);
$output = $entryPoint->getCapturedOutput();
$this->assertStringContainsString(
'Error generating thumbnail',
$output
);
}
public function testAccessOnPrivateWiki() {
// Make the wiki non-public, so we don't use the short-circuit code
$groupPermissions = $this->getConfVar( MainConfigNames::GroupPermissions );
$groupPermissions['*']['read'] = false;
$this->overrideConfigValue(
'GroupPermissions',
$groupPermissions
);
// Make a user who is allowed to read
$authority = new SimpleAuthority(
new UserIdentityValue( 7, 'Heather' ),
[ 'read', 'renderfile', 'renderfile-nonstandard' ]
);
$env = $this->makeEnvironment( 'Test.png' );
$context = $env->makeFauxContext();
$context->setAuthority( $authority );
$entryPoint = $this->getEntryPoint(
$env,
$context
);
$entryPoint->run();
$output = $entryPoint->getCapturedOutput();
$env->assertStatusCode( 200 );
$this->assertThumbnail( [ 'magic' => self::PNG_MAGIC, ], $output );
}
public static function provideRateLimit() {
// NOTE: The 12px thumbnail will have been generated at this point.
// We force 16 and 24 to be standard sizes during setup.
// Once the thumbnail is generated, the rate limit is no longer
// triggered.
yield [ '16', '24', 'renderfile' ];
yield [ self::$uniqueWidth++, self::$uniqueWidth++, 'renderfile-nonstandard' ];
}
/**
* @dataProvider provideRateLimit
*/
public function testRateLimited( $width1, $width2, $limit ) {
// Set up rate limit config
$rateLimits = $this->getConfVar( MainConfigNames::RateLimits );
$rateLimits[$limit] = [
'ip' => [ 1, 60 ],
'newbie' => [ 1, 60 ],
'user' => [ 1, 60 ],
];
$this->overrideConfigValue( MainConfigNames::RateLimits, $rateLimits );
// First run should pass
$env = $this->makeEnvironment( [ 'f' => 'Test.png', 'width' => $width1 ] );
$entryPoint = $this->getEntryPoint( $env );
$entryPoint->run();
$entryPoint->getCapturedOutput();
$env->assertStatusCode( 200 );
// Second run should fail
$env = $this->makeEnvironment( [ 'f' => 'Test.png', 'width' => $width2 ] );
$entryPoint = $this->getEntryPoint( $env );
$entryPoint->run();
$entryPoint->getCapturedOutput();
$env->assertStatusCode( 429 );
$env->assertHeaderValue(
'text/html; charset=utf-8',
'Content-Type'
);
}
/**
* @depends testGenerateAndStreamThumbnail
*/
public function testStreamOldFile( array $latestThumbnailInfo ) {
$file = $this->getTestRepo()->newFile( 'Test.png' );
$history = $file->getHistory();
$oldFile = $history[0];
$env = $this->makeEnvironment(
[
'f' => $oldFile->getArchiveName(),
'width' => '12px', // use "px" suffix, just so we also cover that code path
'archived' => 1,
]
);
$entryPoint = $this->getEntryPoint( $env );
$entryPoint->run();
$output = $entryPoint->getCapturedOutput();
$env->assertStatusCode( 200 );
$this->assertNotSame(
$latestThumbnailInfo['data'],
$output,
'Thumbnail for the old version should not be the same as the ' .
'thumbnail for the latest version'
);
$this->assertThumbnail(
[ 'magic' => self::PNG_MAGIC, 'width' => 12, ],
$output
);
}
public function testOldDeletedFile() {
// Note that we manually set oi_deleted for this revision
// in addDBDataOnce().
$file = $this->getTestRepo()->newFile( 'Icon.jpg' );
$history = $file->getHistory();
$oldFile = $history[0];
$env = $this->makeEnvironment(
[
'f' => $oldFile->getArchiveName(),
'width' => '12px', // use "px" suffix, just so we also cover that code path
'archived' => 1,
]
);
$entryPoint = $this->getEntryPoint( $env );
$entryPoint->run();
$output = $entryPoint->getCapturedOutput();
$env->assertStatusCode( 404, $output );
}
/**
* @depends testGenerateAndStreamThumbnail
*/
public function testStreamOldFileRedirect( array $latestThumbnailInfo ) {
$file = $this->getTestRepo()->newFile( 'Test.png' );
$history = $file->getHistory();
$oldFile = $history[0];
// Try accessing the old revision using a redirected title
$archiveName = str_replace(
'Test.png',
'Redirect_to_Test.png',
$oldFile->getArchiveName()
);
$env = $this->makeEnvironment(
[
'f' => $archiveName,
'width' => 12,
'archived' => 1,
]
);
$entryPoint = $this->getEntryPoint( $env );
$entryPoint->run();
$output = $entryPoint->getCapturedOutput();
$response = $env->getFauxResponse();
$this->assertSame( 302, $response->getStatusCode() );
$expected = '/' . urlencode( $oldFile->getArchiveName() ) . '/12px-Test.png';
$this->assertStringEndsWith(
$expected,
$response->getHeader( 'Location' )
);
$this->assertSame( '', $output );
}
public function testStreamTempFile() {
$user = $this->getTestUser()->getUser();
$stash = new UploadStash( $this->getTestRepo(), $user );
$file = $stash->stashFile( self::IMAGES_DIR . '/adobergb.jpg' );
$env = $this->makeEnvironment(
[
'f' => $file->getName(),
'width' => 12,
'temp' => 'yes',
]
);
$entryPoint = $this->getEntryPoint( $env );
$entryPoint->run();
$output = $entryPoint->getCapturedOutput();
$env->assertStatusCode( 200 );
$this->assertThumbnail(
[ 'magic' => self::JPEG_MAGIC, 'width' => 12, ],
$output
);
}
public function testRedirect() {
$this->overrideConfigValue( MainConfigNames::VaryOnXFP, true );
$env = $this->makeEnvironment(
[
'f' => 'Redirect_to_Test.png',
'w' => 12
]
);
$entryPoint = $this->getEntryPoint( $env );
$entryPoint->run();
$output = $entryPoint->getCapturedOutput();
$response = $env->getFauxResponse();
$this->assertSame( 302, $response->getStatusCode() );
$this->assertStringEndsWith(
'/Test.png/12px-Test.png',
$response->getHeader( 'Location' )
);
$this->assertSame( '', $output );
$env->assertHeaderValue( 'X-Forwarded-Proto', 'Vary' );
}
public function testBadTitle() {
$env = $this->makeEnvironment( '_/_' );
$entryPoint = $this->getEntryPoint( $env );
$entryPoint->run();
$output = $entryPoint->getCapturedOutput();
$env->assertStatusCode( 404 );
$env->assertHeaderValue(
'text/html; charset=utf-8',
'Content-Type'
);
$this->assertStringContainsString(
'(badtitletext)',
$output
);
}
public static function provideOldFileWithBadTitle() {
yield 'invalid title' => [ '_/_' ];
yield 'valid title without timestamp' => [ 'Test.png' ];
yield 'invalid title with timestamp' => [ '20200101002233!_/_' ];
}
/**
* @dataProvider provideOldFileWithBadTitle
*/
public function testOldFileWithBadTitle( $badTitle ) {
$env = $this->makeEnvironment( [
'f' => $badTitle,
'width' => 12,
'archived' => 1
] );
$entryPoint = $this->getEntryPoint( $env );
$entryPoint->run();
$output = $entryPoint->getCapturedOutput();
$env->assertStatusCode( 404 );
$env->assertHeaderValue(
'text/html; charset=utf-8',
'Content-Type'
);
$this->assertStringContainsString(
'(badtitletext)',
$output
);
}
public function testTooMuchWidth() {
// Set the width larger than the size of the image
$env = $this->makeEnvironment( [ 'f' => 'Test.png', 'width' => 1200 ] );
$entryPoint = $this->getEntryPoint( $env );
$entryPoint->run();
$output = $entryPoint->getCapturedOutput();
$env->assertStatusCode( 400 );
$env->assertHeaderValue(
'text/html; charset=utf-8',
'Content-Type'
);
$this->assertStringContainsString(
'(thumbnail_error: ',
$output
);
$this->assertStringContainsString(
'bigger than the source',
$output
);
}
public function testDeletedFile() {
// Delete Icon.jpg
$icon = $this->getTestRepo()->newFile( 'Icon.jpg' );
$this->assertTrue( $icon->exists() );// sanity
$icon->deleteFile( 'testing', new UserIdentityValue( 0, 'Test' ) );
$env = $this->makeEnvironment( 'Icon.jpg' );
$entryPoint = $this->getEntryPoint( $env );
$entryPoint->run();
$output = $entryPoint->getCapturedOutput();
$env->assertStatusCode( 404 );
$env->assertHeaderValue(
'text/html; charset=utf-8',
'Content-Type'
);
$this->assertStringContainsString(
'Error generating thumbnail',
$output
);
}
public function testNotModified() {
$env = $this->makeEnvironment(
[
'f' => 'Test.png',
'width' => 12
]
);
$env->setServerInfo( 'HTTP_IF_MODIFIED_SINCE', '25250101001122' );
$entryPoint = $this->getEntryPoint( $env );
$entryPoint->run();
$output = $entryPoint->getCapturedOutput();
$response = $env->getFauxResponse();
$this->assertSame( 304, $response->getStatusCode() );
$this->assertSame( '', $output );
}
public function testProxy() {
$this->installTestRepoGroup( [ 'thumbProxyUrl' => 'https://images.acme.test/thumbnails/' ] );
$this->installMockHttp( 'PROXY RESPONSE' );
$env = $this->makeEnvironment(
[
'f' => 'Test.png',
'width' => self::$uniqueWidth++
]
);
$entryPoint = $this->getEntryPoint( $env );
$entryPoint->run();
$output = $entryPoint->getCapturedOutput();
$this->assertSame( 'PROXY RESPONSE', $output );
}
public static function provideRepoCouldNotStreamFile() {
// The width must match the one generated by testGenerateAndStreamThumbnail
// This error comes from FileBackend::doStreamFile.
yield 'existing thumbnail' => [ 12, 'xyzzy-error' ];
// TODO: also test the case where we fail to stream a newly created
// thumbnail. In that case, the expected error comes from
// MediaTransformOutput::streamFileWithStatus, not FileBackend::doStreamFile.
}
/**
* @dataProvider provideRepoCouldNotStreamFile
* @depends testGenerateAndStreamThumbnail
*/
public function testRepoCouldNotStreamFile( int $width, string $expectedError ) {
// Sabotage streaming in file backend
$backend = $this->createFileBackend( [
'overrides' => [
'doStreamFile' => Status::newFatal( 'xyzzy-error' )
]
] );
$this->installTestRepoGroup(
[ 'backend' => $backend ]
);
// TODO: figure out how to provoke an error in
// MediaTransformOutput::streamFileWithStatus.
// The below causes an error to be triggered too early.
// Since MediaTransformOutput uses StreamFile directly, we have to also
// sabotage transformations in the handler to return a ThumbnailImage
// with no path. This is unfortunately brittle to implementation changes.
$handler = $this->getMockBuilder( BitmapHandler::class )
->onlyMethods( [ 'doTransform' ] )
->getMock();
$file = $this->getTestRepo()->newFile( 'Test.png' );
$params = [ 'width' => $width, 'height' => $width ];
$handler->method( 'doTransform' )->willReturn(
new ThumbnailImage( $file, '', false, $params )
);
$factory = $this->createNoOpMock( MediaHandlerFactory::class, [ 'getHandler' ] );
$factory->method( 'getHandler' )->willReturn( $handler );
$this->setService( 'MediaHandlerFactory', $factory );
$env = $this->makeEnvironment(
[
'f' => 'Test.png',
'width' => $width
]
);
$entryPoint = $this->getEntryPoint( $env );
$entryPoint->run();
$output = $entryPoint->getCapturedOutput();
$env->assertStatusCode( 500, $output );
$env->assertHeaderValue(
'text/html; charset=utf-8',
'Content-Type'
);
// TODO: check the log for the specific error.
$this->assertStringContainsString( 'Could not stream the file', $output );
}
/**
* @param array $props
* @param string $output binary data
*/
private function assertThumbnail( array $props, string $output ): void {
if ( isset( $props['magic'] ) ) {
$this->assertStringStartsWith(
$props['magic'],
$output,
'Magic number should match'
);
}
if ( isset( $props['width'] ) && function_exists( 'getimagesizefromstring' ) ) {
[ $width, ] = getimagesizefromstring( $output );
$this->assertSame(
$props['width'],
$width
);
}
}
}