Integration tests for FileBackendGroup
[lhc/web/wiklou.git] / tests / phpunit / unit / includes / filebackend / FileBackendGroupTestTrait.php
1 <?php
2
3 use MediaWiki\FileBackend\FSFile\TempFSFileFactory;
4 use MediaWiki\FileBackend\LockManager\LockManagerGroupFactory;
5 use MediaWiki\Logger\LoggerFactory;
6
7 /**
8 * Code shared by the FileBackendGroup integration and unit tests. They need merely provide a
9 * suitable newObj() method and everything else works magically.
10 */
11 trait FileBackendGroupTestTrait {
12 /**
13 * @param array $options Dictionary to use as a source for ServiceOptions before defaults, plus
14 * the following options are available to override other arguments:
15 * * 'configuredROMode'
16 * * 'lmgFactory'
17 * * 'mimeAnalyzer'
18 * * 'tmpFileFactory'
19 */
20 abstract protected function newObj( array $options = [] ) : FileBackendGroup;
21
22 /**
23 * @param string $domain Expected argument that LockManagerGroupFactory::getLockManagerGroup
24 * will receive
25 */
26 abstract protected function getLockManagerGroupFactory( $domain )
27 : LockManagerGroupFactory;
28
29 /**
30 * @return string As from wfWikiID()
31 */
32 abstract protected static function getWikiID();
33
34 /** @var BagOStuff */
35 private $srvCache;
36
37 /** @var WANObjectCache */
38 private $wanCache;
39
40 /** @var LockManagerGroupFactory */
41 private $lmgFactory;
42
43 /** @var TempFSFileFactory */
44 private $tmpFileFactory;
45
46 private static function getDefaultLocalFileRepo() {
47 return [
48 'class' => LocalRepo::class,
49 'name' => 'local',
50 'directory' => 'upload-dir',
51 'thumbDir' => 'thumb/',
52 'transcodedDir' => 'transcoded/',
53 'fileMode' => 0664,
54 'scriptDirUrl' => 'script-path/',
55 'url' => 'upload-path/',
56 'hashLevels' => 2,
57 'thumbScriptUrl' => false,
58 'transformVia404' => false,
59 'deletedDir' => 'deleted/',
60 'deletedHashLevels' => 3,
61 'backend' => 'local-backend',
62 ];
63 }
64
65 private static function getDefaultOptions() {
66 return [
67 'DirectoryMode' => 0775,
68 'FileBackends' => [],
69 'ForeignFileRepos' => [],
70 'LocalFileRepo' => self::getDefaultLocalFileRepo(),
71 'wikiId' => self::getWikiID(),
72 ];
73 }
74
75 /**
76 * @covers ::__construct
77 */
78 public function testConstructor_overrideImplicitBackend() {
79 $obj = $this->newObj( [ 'FileBackends' =>
80 [ [ 'name' => 'local-backend', 'class' => '', 'lockManager' => 'fsLockManager' ] ]
81 ] );
82 $this->assertSame( '', $obj->config( 'local-backend' )['class'] );
83 }
84
85 /**
86 * @covers ::__construct
87 */
88 public function testConstructor_backendObject() {
89 // 'backend' being an object makes that repo from configuration ignored
90 // XXX This is not documented in DefaultSettings.php, does it do anything useful?
91 $obj = $this->newObj( [ 'ForeignFileRepos' => [ [ 'backend' => new stdclass ] ] ] );
92 $this->assertSame( FSFileBackend::class, $obj->config( 'local-backend' )['class'] );
93 }
94
95 /**
96 * @dataProvider provideRegister_domainId
97 * @param string $key Key to check in return value of config()
98 * @param string|callable $expected Expected value of config()[$key], or callable returning it
99 * @param array $extraBackendsOptions To add to the FileBackends entry passed to newObj()
100 * @param array $otherExtraOptions To add to the array passed to newObj() (e.g., services)
101 * @covers ::register
102 */
103 public function testRegister(
104 $key, $expected, array $extraBackendsOptions = [], array $otherExtraOptions = []
105 ) {
106 if ( $expected instanceof Closure ) {
107 // Lame hack to get around providers being called too early
108 $expected = $expected();
109 }
110 if ( $key === 'domainId' ) {
111 // This will change the expected LMG name too
112 $otherExtraOptions['lmgFactory'] = $this->getLockManagerGroupFactory( $expected );
113 }
114 $obj = $this->newObj( $otherExtraOptions + [
115 'FileBackends' => [
116 $extraBackendsOptions + [
117 'name' => 'myname', 'class' => '', 'lockManager' => 'fsLockManager'
118 ]
119 ],
120 ] );
121 $this->assertSame( $expected, $obj->config( 'myname' )[$key] );
122 }
123
124 public static function provideRegister_domainId() {
125 return [
126 'domainId with neither wikiId nor domainId set' => [
127 'domainId',
128 function () {
129 return self::getWikiID();
130 },
131 ],
132 'domainId with wikiId set but no domainId' =>
133 [ 'domainId', 'id0', [ 'wikiId' => 'id0' ] ],
134 'domainId with wikiId and domainId set' =>
135 [ 'domainId', 'dom1', [ 'wikiId' => 'id0', 'domainId' => 'dom1' ] ],
136 'readOnly without readOnly set' => [ 'readOnly', false ],
137 'readOnly with readOnly set to string' =>
138 [ 'readOnly', 'cuz', [ 'readOnly' => 'cuz' ] ],
139 'readOnly without readOnly set but with string in passed object' => [
140 'readOnly',
141 'cuz',
142 [],
143 [ 'configuredROMode' => new ConfiguredReadOnlyMode( 'cuz' ) ],
144 ],
145 'readOnly with readOnly set to false but string in passed object' => [
146 'readOnly',
147 false,
148 [ 'readOnly' => false ],
149 [ 'configuredROMode' => new ConfiguredReadOnlyMode( 'cuz' ) ],
150 ],
151 ];
152 }
153
154 /**
155 * @dataProvider provideRegister_exception
156 * @param array $fileBackends Value of FileBackends to pass to constructor
157 * @param string $class Expected exception class
158 * @param string $msg Expected exception message
159 * @covers ::__construct
160 * @covers ::register
161 */
162 public function testRegister_exception( $fileBackends, $class, $msg ) {
163 $this->setExpectedException( $class, $msg );
164 $this->newObj( [ 'FileBackends' => $fileBackends ] );
165 }
166
167 public static function provideRegister_exception() {
168 return [
169 'Nameless' => [
170 [ [] ], InvalidArgumentException::class, "Cannot register a backend with no name."
171 ],
172 'Duplicate' => [
173 [ [ 'name' => 'dupe', 'class' => '' ], [ 'name' => 'dupe' ] ],
174 LogicException::class,
175 "Backend with name 'dupe' already registered.",
176 ],
177 'Classless' => [
178 [ [ 'name' => 'classless' ] ],
179 InvalidArgumentException::class,
180 "Backend with name 'classless' has no class.",
181 ],
182 ];
183 }
184
185 /**
186 * @covers ::__construct
187 * @covers ::config
188 * @covers ::get
189 */
190 public function testGet() {
191 $backend = $this->newObj()->get( 'local-backend' );
192 $this->assertTrue( $backend instanceof FSFileBackend );
193 }
194
195 /**
196 * @covers ::get
197 */
198 public function testGetUnrecognized() {
199 $this->setExpectedException( InvalidArgumentException::class,
200 "No backend defined with the name 'unrecognized'." );
201 $this->newObj()->get( 'unrecognized' );
202 }
203
204 /**
205 * @covers ::__construct
206 * @covers ::config
207 */
208 public function testConfig() {
209 $obj = $this->newObj();
210 $config = $obj->config( 'local-backend' );
211
212 // XXX How to actually test that a profiler is loaded?
213 $this->assertNull( $config['profiler']( 'x' ) );
214 // Equality comparison doesn't work for closures, so just set to null
215 $config['profiler'] = null;
216
217 $this->assertEquals( [
218 'mimeCallback' => [ $obj, 'guessMimeInternal' ],
219 'obResetFunc' => 'wfResetOutputBuffers',
220 'streamMimeFunc' => [ StreamFile::class, 'contentTypeFromPath' ],
221 'tmpFileFactory' => $this->tmpFileFactory,
222 'statusWrapper' => [ Status::class, 'wrap' ],
223 'wanCache' => $this->wanCache,
224 'srvCache' => $this->srvCache,
225 'logger' => LoggerFactory::getInstance( 'FileOperation' ),
226 // This was set to null above in $config, it's not really null
227 'profiler' => null,
228 'name' => 'local-backend',
229 'containerPaths' => [
230 'local-public' => 'upload-dir',
231 'local-thumb' => 'thumb/',
232 'local-transcoded' => 'transcoded/',
233 'local-deleted' => 'deleted/',
234 'local-temp' => 'upload-dir/temp',
235 ],
236 'fileMode' => 0664,
237 'directoryMode' => 0775,
238 'domainId' => self::getWikiID(),
239 'readOnly' => false,
240 'class' => FSFileBackend::class,
241 'lockManager' =>
242 $this->lmgFactory->getLockManagerGroup( self::getWikiID() )->get( 'fsLockManager' ),
243 'fileJournal' =>
244 FileJournal::factory( [ 'class' => NullFileJournal::class ], 'local-backend' ),
245 ], $config );
246
247 // For config values that are objects, check object identity.
248 $this->assertSame( [ $obj, 'guessMimeInternal' ], $config['mimeCallback'] );
249 $this->assertSame( $this->tmpFileFactory, $config['tmpFileFactory'] );
250 $this->assertSame( $this->wanCache, $config['wanCache'] );
251 $this->assertSame( $this->srvCache, $config['srvCache'] );
252 }
253
254 /**
255 * @dataProvider provideConfig_default
256 * @param string $expected Expected default value
257 * @param string $inputName Name to set to null in LocalFileRepo setting
258 * @param string|array $key Key to check in array returned by config(), or array [ 'key1',
259 * 'key2' ] for nested key
260 * @covers ::__construct
261 * @covers ::config
262 */
263 public function testConfig_defaultNull( $expected, $inputName, $key ) {
264 $config = self::getDefaultLocalFileRepo();
265 $config[$inputName] = null;
266
267 $result = $this->newObj( [ 'LocalFileRepo' => $config ] )->config( 'local-backend' );
268
269 $actual = is_string( $key ) ? $result[$key] : $result[$key[0]][$key[1]];
270
271 $this->assertSame( $expected, $actual );
272 }
273
274 /**
275 * @dataProvider provideConfig_default
276 * @param string $expected Expected default value
277 * @param string $inputName Name to unset in LocalFileRepo setting
278 * @param string|array $key Key to check in array returned by config(), or array [ 'key1',
279 * 'key2' ] for nested key
280 * @covers ::__construct
281 * @covers ::config
282 */
283 public function testConfig_defaultUnset( $expected, $inputName, $key ) {
284 $config = self::getDefaultLocalFileRepo();
285 unset( $config[$inputName] );
286
287 $result = $this->newObj( [ 'LocalFileRepo' => $config ] )->config( 'local-backend' );
288
289 $actual = is_string( $key ) ? $result[$key] : $result[$key[0]][$key[1]];
290
291 $this->assertSame( $expected, $actual );
292 }
293
294 public static function provideConfig_default() {
295 return [
296 'deletedDir' => [ false, 'deletedDir', [ 'containerPaths', 'local-deleted' ] ],
297 'thumbDir' => [ 'upload-dir/thumb', 'thumbDir', [ 'containerPaths', 'local-thumb' ] ],
298 'transcodedDir' => [
299 'upload-dir/transcoded', 'transcodedDir', [ 'containerPaths', 'local-transcoded' ]
300 ],
301 'fileMode' => [ 0644, 'fileMode', 'fileMode' ],
302 ];
303 }
304
305 /**
306 * @covers ::config
307 */
308 public function testConfig_fileJournal() {
309 $mockJournal = $this->createMock( FileJournal::class );
310 $mockJournal->expects( $this->never() )->method( $this->anything() );
311
312 $obj = $this->newObj( [ 'FileBackends' => [ [
313 'name' => 'name',
314 'class' => '',
315 'lockManager' => 'fsLockManager',
316 'fileJournal' => [ 'factory' =>
317 function () use ( $mockJournal ) {
318 return $mockJournal;
319 }
320 ],
321 ] ] ] );
322
323 $this->assertSame( $mockJournal, $obj->config( 'name' )['fileJournal'] );
324 }
325
326 /**
327 * @covers ::config
328 */
329 public function testConfigUnrecognized() {
330 $this->setExpectedException( InvalidArgumentException::class,
331 "No backend defined with the name 'unrecognized'." );
332 $this->newObj()->config( 'unrecognized' );
333 }
334
335 /**
336 * @dataProvider provideBackendFromPath
337 * @covers ::backendFromPath
338 * @param string|null $expected Name of backend that will be returned from 'get', or null
339 * @param string $storagePath
340 */
341 public function testBackendFromPath( $expected = null, $storagePath ) {
342 $obj = $this->newObj( [ 'FileBackends' => [
343 [ 'name' => '', 'class' => stdclass::class, 'lockManager' => 'fsLockManager' ],
344 [ 'name' => 'a', 'class' => stdclass::class, 'lockManager' => 'fsLockManager' ],
345 [ 'name' => 'b', 'class' => stdclass::class, 'lockManager' => 'fsLockManager' ],
346 ] ] );
347 $this->assertSame(
348 $expected === null ? null : $obj->get( $expected ),
349 $obj->backendFromPath( $storagePath )
350 );
351 }
352
353 public static function provideBackendFromPath() {
354 return [
355 'Empty string' => [ null, '' ],
356 'mwstore://' => [ null, 'mwstore://' ],
357 'mwstore://a' => [ null, 'mwstore://a' ],
358 'mwstore:///' => [ null, 'mwstore:///' ],
359 'mwstore://a/' => [ null, 'mwstore://a/' ],
360 'mwstore://a//' => [ null, 'mwstore://a//' ],
361 'mwstore://a/b' => [ 'a', 'mwstore://a/b' ],
362 'mwstore://a/b/' => [ 'a', 'mwstore://a/b/' ],
363 'mwstore://a/b////' => [ 'a', 'mwstore://a/b////' ],
364 'mwstore://a/b/c' => [ 'a', 'mwstore://a/b/c' ],
365 'mwstore://a/b/c/d' => [ 'a', 'mwstore://a/b/c/d' ],
366 'mwstore://b/b' => [ 'b', 'mwstore://b/b' ],
367 'mwstore://c/b' => [ null, 'mwstore://c/b' ],
368 ];
369 }
370
371 /**
372 * @dataProvider provideGuessMimeInternal
373 * @covers ::guessMimeInternal
374 * @param string $storagePath
375 * @param string|null $content
376 * @param string|null $fsPath
377 * @param string|null $expectedExtensionType Expected return of
378 * MimeAnalyzer::guessTypesForExtension
379 * @param string|null $expectedGuessedMimeType Expected return value of
380 * MimeAnalyzer::guessMimeType (null if expected not to be called)
381 */
382 public function testGuessMimeInternal(
383 $storagePath,
384 $content,
385 $fsPath,
386 $expectedExtensionType,
387 $expectedGuessedMimeType
388 ) {
389 $mimeAnalyzer = $this->createMock( MimeAnalyzer::class );
390 $mimeAnalyzer->expects( $this->once() )->method( 'guessTypesForExtension' )
391 ->willReturn( $expectedExtensionType );
392 $tmpFileFactory = $this->createMock( TempFSFileFactory::class );
393
394 if ( !$expectedExtensionType && $fsPath ) {
395 $tmpFileFactory->expects( $this->never() )->method( 'newTempFSFile' );
396 $mimeAnalyzer->expects( $this->once() )->method( 'guessMimeType' )
397 ->with( $fsPath, false )->willReturn( $expectedGuessedMimeType );
398 } elseif ( !$expectedExtensionType && strlen( $content ) ) {
399 // XXX What should we do about the file creation here? Really we should mock
400 // file_put_contents() somehow. It's not very nice to ignore the value of
401 // $wgTmpDirectory.
402 $tmpFile = ( new TempFSFileFactory() )->newTempFSFile( 'mime_', '' );
403
404 $tmpFileFactory->expects( $this->once() )->method( 'newTempFSFile' )
405 ->with( 'mime_', '' )->willReturn( $tmpFile );
406 $mimeAnalyzer->expects( $this->once() )->method( 'guessMimeType' )
407 ->with( $tmpFile->getPath(), false )->willReturn( $expectedGuessedMimeType );
408 } else {
409 $tmpFileFactory->expects( $this->never() )->method( 'newTempFSFile' );
410 $mimeAnalyzer->expects( $this->never() )->method( 'guessMimeType' );
411 }
412
413 $mimeAnalyzer->expects( $this->never() )
414 ->method( $this->anythingBut( 'guessTypesForExtension', 'guessMimeType' ) );
415 $tmpFileFactory->expects( $this->never() )
416 ->method( $this->anythingBut( 'newTempFSFile' ) );
417
418 $obj = $this->newObj( [
419 'mimeAnalyzer' => $mimeAnalyzer,
420 'tmpFileFactory' => $tmpFileFactory,
421 ] );
422
423 $this->assertSame( $expectedExtensionType ?? $expectedGuessedMimeType ?? 'unknown/unknown',
424 $obj->guessMimeInternal( $storagePath, $content, $fsPath ) );
425 }
426
427 public static function provideGuessMimeInternal() {
428 return [
429 'With extension' =>
430 [ 'foo.txt', null, null, 'text/plain', null ],
431 'No extension' =>
432 [ 'foo', null, null, null, null ],
433 'Empty content, with extension' =>
434 [ 'foo.txt', '', null, 'text/plain', null ],
435 'Empty content, no extension' =>
436 [ 'foo', '', null, null, null ],
437 'Non-empty content, with extension' =>
438 [ 'foo.txt', '<b>foo</b>', null, 'text/plain', null ],
439 'Non-empty content, no extension' =>
440 [ 'foo', '<b>foo</b>', null, null, 'text/html' ],
441 'Empty path, with extension' =>
442 [ 'foo.txt', null, '', 'text/plain', null ],
443 'Empty path, no extension' =>
444 [ 'foo', null, '', null, null ],
445 'Non-empty path, with extension' =>
446 [ 'foo.txt', null, '/bogus/path', 'text/plain', null ],
447 'Non-empty path, no extension' =>
448 [ 'foo', null, '/bogus/path', null, 'text/html' ],
449 'Empty path and content, with extension' =>
450 [ 'foo.txt', '', '', 'text/plain', null ],
451 'Empty path and content, no extension' =>
452 [ 'foo', '', '', null, null ],
453 'Non-empty path and content, with extension' =>
454 [ 'foo.txt', '<b>foo</b>', '/bogus/path', 'text/plain', null ],
455 'Non-empty path and content, no extension' =>
456 [ 'foo', '<b>foo</b>', '/bogus/path', null, 'image/jpeg' ],
457 ];
458 }
459 }