1<?php 2 3declare( strict_types = 1 ); 4 5use MediaWiki\FileBackend\FSFile\TempFSFileFactory; 6use PHPUnit\Framework\MockObject\MockObject; 7use Psr\Log\NullLogger; 8use Wikimedia\ScopedCallback; 9use Wikimedia\TestingAccessWrapper; 10 11/** 12 * @coversDefaultClass FileBackend 13 */ 14class FileBackendTest extends MediaWikiUnitTestCase { 15 /** 16 * createMock() stubs out all methods, which isn't desirable for testing an abstract base class, 17 * since we often want to test that the base class calls certain methods that the derived class 18 * is meant to override. getMockBuilder() can be set to override only certain methods, but then 19 * you have to manually specify all abstract methods or else it doesn't work. 20 * getMockForAbstractClass() automatically fills in stubs for the abstract methods, but by 21 * default doesn't allow overriding any other methods. So we have to write our own. 22 * 23 * @param string|array ...$args Zero or more of the following: 24 * - A nonempty associative array, interpreted as $config to be passed to the constructor. The 25 * 'name' and 'domainId' will be given default values if not present. 26 * - A nonempty indexed array or a string, interpreted as a list of methods to override. 27 * - An empty array, which is ignored. 28 * @return FileBackend|MockObject A mock with no methods overridden except those specified in 29 * $methodsToMock, and all abstract methods. 30 */ 31 private function newMockFileBackend( ...$args ) : FileBackend { 32 $methodsToMock = []; 33 $config = []; 34 foreach ( $args as $arg ) { 35 if ( is_string( $arg ) ) { 36 $methodsToMock = [ $arg ]; 37 } elseif ( is_array( $arg ) ) { 38 if ( isset( $arg[0] ) ) { 39 $methodsToMock = $arg; 40 } elseif ( $arg ) { 41 $config = $arg; 42 } 43 } else { 44 throw new InvalidArgumentException( 45 'Arguments must be strings or nonempty arrays' ); 46 } 47 } 48 49 $config += [ 'name' => 'test_name' ]; 50 if ( !array_key_exists( 'wikiId', $config ) ) { 51 $config += [ 'domainId' => '' ]; 52 } 53 54 return $this->getMockBuilder( FileBackend::class ) 55 ->setConstructorArgs( [ $config ] ) 56 ->setMethods( $methodsToMock ) 57 ->getMockForAbstractClass(); 58 } 59 60 /** 61 * @covers ::__construct 62 * @dataProvider provideConstruct_validName 63 * @param mixed $name 64 */ 65 public function testConstruct_validName( $name ) : void { 66 $this->newMockFileBackend( [ 'name' => $name ] ); 67 68 // No exception 69 $this->assertTrue( true ); 70 } 71 72 public static function provideConstruct_validName() : array { 73 return [ 74 'True' => [ true ], 75 'Positive integer' => [ 7 ], 76 'Zero integer' => [ 0 ], 77 'Zero float' => [ 0.0 ], 78 'Negative integer' => [ -7 ], 79 'Negative float' => [ -7.0 ], 80 '255 chars is allowed' => [ str_repeat( 'a', 255 ) ], 81 ]; 82 } 83 84 /** 85 * @covers ::__construct 86 * @dataProvider provideConstruct_invalidName 87 * @param mixed $name 88 */ 89 public function testConstruct_invalidName( $name ) : void { 90 $this->expectException( InvalidArgumentException::class ); 91 $this->expectExceptionMessage( "Backend name '$name' is invalid." ); 92 93 $this->newMockFileBackend( [ 'name' => $name, 'domainId' => false ] ); 94 } 95 96 public static function provideConstruct_invalidName() : array { 97 return [ 98 'Empty string' => [ '' ], 99 '256 chars is too long' => [ str_repeat( 'a', 256 ) ], 100 '!' => [ '!' ], 101 'With space' => [ 'a b' ], 102 'False' => [ false ], 103 'Null' => [ null ], 104 'Positive float' => [ 13.402 ], 105 'Negative float' => [ -13.402 ], 106 ]; 107 } 108 109 /** 110 * @covers ::__construct 111 */ 112 public function testConstruct_noName() : void { 113 $this->expectException( InvalidArgumentException::class ); 114 $this->expectExceptionMessage( 'Backend name not specified' ); 115 116 $this->getMockBuilder( FileBackend::class ) 117 ->setConstructorArgs( [ [] ] ) 118 ->getMock(); 119 } 120 121 /** 122 * @covers ::__construct 123 * @dataProvider provideConstruct_validDomainId 124 * @param string $domainId 125 */ 126 public function testConstruct_validDomainId( string $domainId ) : void { 127 $this->newMockFileBackend( [ 'domainId' => $domainId ] ); 128 129 // No exception 130 $this->assertTrue( true ); 131 } 132 133 /** 134 * @covers ::__construct 135 * @dataProvider provideConstruct_validDomainId 136 * @param string $wikiId 137 */ 138 public function testConstruct_validWikiId( string $wikiId ) : void { 139 $this->newMockFileBackend( [ 'wikiId' => $wikiId ] ); 140 141 // No exception 142 $this->assertTrue( true ); 143 } 144 145 public static function provideConstruct_validDomainId() : array { 146 return [ 147 'Empty string' => [ '' ], 148 '1000 chars' => [ str_repeat( 'a', 1000 ) ], 149 'Null character' => [ "\0" ], 150 'Invalid UTF-8' => [ "\xff" ], 151 ]; 152 } 153 154 /** 155 * @covers ::__construct 156 * @dataProvider provideConstruct_invalidDomainId 157 * @param mixed $domainId 158 */ 159 public function testConstruct_invalidDomainId( $domainId ) : void { 160 $this->expectException( InvalidArgumentException::class ); 161 $this->expectExceptionMessage( "Backend domain ID not provided for 'test_name'." ); 162 163 $this->newMockFileBackend( [ 'domainId' => $domainId ] ); 164 } 165 166 public static function provideConstruct_invalidDomainId() : array { 167 return [ 168 // We don't include null because that will fall back to wikiId 169 'False' => [ false ], 170 'True' => [ true ], 171 'Integer' => [ 7 ], 172 'Function' => [ function () { 173 } ], 174 'Float' => [ -13.402 ], 175 'Object' => [ (object)[] ], 176 'Array' => [ [] ], 177 ]; 178 } 179 180 /** 181 * @covers ::__construct 182 * @dataProvider provideConstruct_invalidWikiId 183 * @param mixed $wikiId 184 */ 185 public function testConstruct_invalidWikiId( $wikiId ) : void { 186 $this->expectException( InvalidArgumentException::class ); 187 $this->expectExceptionMessage( "Backend domain ID not provided for 'test_name'." ); 188 189 $this->newMockFileBackend( [ 'wikiId' => $wikiId ] ); 190 } 191 192 public static function provideConstruct_invalidWikiId() : array { 193 return [ 194 'Null' => [ null ], 195 ] + self::provideConstruct_invalidDomainId(); 196 } 197 198 /** 199 * @covers ::__construct 200 */ 201 public function testConstruct_noDomainId() : void { 202 $this->expectException( InvalidArgumentException::class ); 203 $this->expectExceptionMessage( "Backend domain ID not provided for 'test_name'" ); 204 205 $this->getMockBuilder( FileBackend::class ) 206 ->setConstructorArgs( [ [ 'name' => 'test_name' ] ] ) 207 ->getMock(); 208 } 209 210 /** 211 * @covers ::__construct 212 * @dataProvider provideConstruct_properties 213 * @param string $property 214 * @param mixed $expected 215 * @param array $config Can also include the key 'inexact' to tell us to not check equality 216 * strictly. 217 */ 218 public function testConstruct_properties( 219 string $property, $expected, array $config = [] 220 ) : void { 221 $backend = $this->newMockFileBackend( $config ); 222 223 if ( $expected instanceof Closure ) { 224 $expected = $expected( $backend ); 225 } 226 227 $assertMethod = isset( $config['inexact'] ) ? 'assertEquals' : 'assertSame'; 228 unset( $config['inexact'] ); 229 230 // We need to test this for the sake of subclasses that actually use the property. There 231 // doesn't seem to be any better way to do it. It shouldn't be tested in the subclasses, 232 // because we're testing the behavior of this class' constructor. We could make our own 233 // subclass, but we'd have to stub 26 abstract methods. 234 $this->$assertMethod( $expected, 235 TestingAccessWrapper::newFromObject( $backend )->$property ); 236 } 237 238 public static function provideConstruct_properties() : array { 239 $tmpFileFactory = new TempFSFileFactory( 'some_unique_path' ); 240 241 return [ 242 'parallelize default value' => [ 'parallelize', 'off' ], 243 'parallelize null' => [ 'parallelize', 'off', [ 'parallelize' => null ] ], 244 'parallelize cast to string' => [ 'parallelize', '1', [ 'parallelize' => true ] ], 245 'parallelize case-preserving' => 246 [ 'parallelize', 'iMpLiCiT', [ 'parallelize' => 'iMpLiCiT' ] ], 247 248 'concurrency default value' => [ 'concurrency', 50 ], 249 'concurrency null' => [ 'concurrency', 50, [ 'concurrency' => null ] ], 250 'concurrency cast to int' => [ 'concurrency', 51, [ 'concurrency' => '51x' ] ], 251 252 'obResetFunc default value' => [ 'obResetFunc', 253 // I'd've thought the return type should be 'callable', but apparently protected 254 // methods aren't callable. 255 function ( FileBackend $backend ) : array { 256 return [ $backend, 'resetOutputBuffer' ]; 257 } ], 258 'obResetFunc null' => [ 'obResetFunc', 259 function ( FileBackend $backend ) : array { 260 return [ $backend, 'resetOutputBuffer' ]; 261 } ], 262 'obResetFunc set' => [ 'obResetFunc', 'wfSomeImaginaryFunction', 263 [ 'obResetFunc' => 'wfSomeImaginaryFunction' ] ], 264 265 'streamMimeFunc default value' => [ 'streamMimeFunc', null ], 266 'streamMimeFunc set' => [ 'streamMimeFunc', 'smf', [ 'streamMimeFunc' => 'smf' ] ], 267 268 'profiler default value' => [ 'profiler', null ], 269 'profiler not callable' => [ 'profiler', null, [ 'profiler' => '!' ] ], 270 271 'logger default value' => [ 'logger', new Psr\Log\NullLogger, [ 'inexact' => true ] ], 272 'logger set' => [ 'logger', 'abcd', [ 'logger' => 'abcd' ] ], 273 274 'statusWrapper default value' => [ 'statusWrapper', null ], 275 276 'tmpFileFactory default value' => 277 [ 'tmpFileFactory', new TempFSFileFactory, [ 'inexact' => true ] ], 278 'tmpDirectory null' => [ 'tmpFileFactory', new TempFSFileFactory, 279 [ 'tmpDirectory' => null, 'inexact' => true ] ], 280 'tmpDirectory set' => [ 'tmpFileFactory', new TempFSFileFactory( 'dir' ), 281 [ 'tmpDirectory' => 'dir', 'inexact' => true ] ], 282 'tmpFileFactory null' => [ 'tmpFileFactory', new TempFSFileFactory, 283 [ 'tmpFileFactory' => null, 'inexact' => true ] ], 284 'tmpFileFactory set' => [ 'tmpFileFactory', $tmpFileFactory, 285 [ 'tmpFileFactory' => $tmpFileFactory ] ], 286 'tmpDirectory and tmpFileFactory set' => [ 287 'tmpFileFactory', 288 new TempFSFileFactory( 'dir' ), 289 [ 'tmpDirectory' => 'dir', 'tmpFileFactory' => $tmpFileFactory, 'inexact' => true ], 290 ], 291 'tmpDirectory null and tmpFileFactory set' => [ 'tmpFileFactory', $tmpFileFactory, 292 [ 'tmpDirectory' => null, 'tmpFileFactory' => $tmpFileFactory ] ], 293 ]; 294 } 295 296 /** 297 * @covers ::setLogger 298 */ 299 public function testSetLogger() : void { 300 $backend = $this->newMockFileBackend(); 301 $logger = new NullLogger; 302 // See comment in testConstruct_properties about use of TestingAccessWrapper. 303 $this->assertNotSame( $logger, TestingAccessWrapper::newFromObject( $backend )->logger ); 304 $backend->setLogger( $logger ); 305 $this->assertSame( $logger, TestingAccessWrapper::newFromObject( $backend )->logger ); 306 } 307 308 /** 309 * @covers ::__construct 310 * @covers ::getName 311 */ 312 public function testGetName() : void { 313 $backend = $this->newMockFileBackend(); 314 $this->assertSame( 'test_name', $backend->getName() ); 315 } 316 317 /** 318 * @covers ::__construct 319 * @covers ::getDomainId 320 * @dataProvider provideGetDomainId 321 * @param array $config 322 */ 323 public function testGetDomainId( array $config ) : void { 324 $backend = $this->newMockFileBackend( $config ); 325 $this->assertSame( 'test_domain', $backend->getDomainId() ); 326 } 327 328 /** 329 * @covers ::__construct 330 * @covers ::getWikiId 331 * @dataProvider provideGetDomainId 332 * @param array $config 333 */ 334 public function testGetWikiId( array $config ) : void { 335 $backend = $this->newMockFileBackend( $config ); 336 $this->assertSame( 'test_domain', $backend->getWikiId() ); 337 } 338 339 public static function provideGetDomainId() : array { 340 return [ 341 'Only domainId' => [ [ 'domainId' => 'test_domain' ] ], 342 'Only wikiId' => [ [ 'wikiId' => 'test_domain' ] ], 343 'null domainId' => [ [ 'domainId' => null, 'wikiId' => 'test_domain' ] ], 344 'wikiId is ignored if domainId is present' => 345 [ [ 'domainId' => 'test_domain', 'wikiId' => 'other_domain' ] ], 346 ]; 347 } 348 349 /** 350 * @covers ::__construct 351 * @covers ::isReadOnly 352 * @covers ::getReadOnlyReason 353 */ 354 public function testIsReadOnly_default() : void { 355 $backend = $this->newMockFileBackend(); 356 $this->assertFalse( $backend->isReadOnly() ); 357 $this->assertFalse( $backend->getReadOnlyReason() ); 358 } 359 360 /** 361 * @covers ::__construct 362 * @covers ::isReadOnly 363 * @covers ::getReadOnlyReason 364 */ 365 public function testIsReadOnly() : void { 366 $backend = $this->newMockFileBackend( [ 'readOnly' => '.' ] ); 367 $this->assertTrue( $backend->isReadOnly() ); 368 $this->assertSame( '.', $backend->getReadOnlyReason() ); 369 } 370 371 /** 372 * @covers ::getFeatures 373 */ 374 public function testGetFeatures() : void { 375 $backend = $this->newMockFileBackend(); 376 $this->assertSame( FileBackend::ATTR_UNICODE_PATHS, $backend->getFeatures() ); 377 } 378 379 /** 380 * @covers ::hasFeatures 381 * @dataProvider provideHasFeatures 382 * @param bool $expected 383 * @param int $testedFeatures 384 * @param int $actualFeatures 385 */ 386 public function testHasFeatures( 387 bool $expected, int $actualFeatures, int $testedFeatures 388 ) : void { 389 $backend = $this->createMock( FileBackend::class ); 390 $backend->method( 'getFeatures' )->willReturn( $actualFeatures ); 391 392 $this->assertSame( $expected, $backend->hasFeatures( $testedFeatures ) ); 393 } 394 395 public static function provideHasFeatures() : array { 396 return [ 397 'Nothing has nothing' => [ true, 0, 0 ], 398 "Nothing doesn't have something" => [ false, 0, 1 ], 399 'Something has nothing' => [ true, 1, 0 ], 400 'Something has itself' => [ true, 1, 1 ], 401 "Something doesn't have something else" => [ false, 0b01, 0b10 ], 402 "Something doesn't have itself and something else" => [ false, 0b01, 0b11 ], 403 'Two things have the first one' => [ true, 0b11, 0b01 ], 404 'Two things have the second one' => [ true, 0b11, 0b10 ], 405 'Two things have both' => [ true, 0b11, 0b11 ], 406 "Two things don't have a third" => [ false, 0b11, 0b100 ], 407 ]; 408 } 409 410 /** 411 * @covers ::doOperations 412 * @covers ::doOperation 413 * @covers ::doQuickOperations 414 * @covers ::doQuickOperation 415 * @covers ::prepare 416 * @covers ::secure 417 * @covers ::publish 418 * @covers ::clean 419 * @covers ::newStatus 420 * @dataProvider provideReadOnly 421 * @param string $method 422 */ 423 public function testReadOnly( string $method ) : void { 424 $backend = $this->newMockFileBackend( [ 'readOnly' => '.' ] ); 425 $status = $backend->$method( [] ); 426 $this->assertSame( [ [ 427 'type' => 'error', 428 'message' => 'backend-fail-readonly', 429 'params' => [ 'test_name', '.' ], 430 ] ], $status->getErrors() ); 431 $this->assertFalse( $status->isOK() ); 432 } 433 434 public static function provideReadOnly() : array { 435 return [ 436 'doOperations' => [ 'doOperations', 'doOperationsInternal', [ [ [] ] ] ], 437 'doOperation' => [ 'doOperation', 'doOperationsInternal', [ [ 'op' => '' ] ] ], 438 'doQuickOperations' => [ 'doQuickOperations', 'doQuickOperationsInternal', [ [ [] ] ] ], 439 'doQuickOperation' => [ 440 'doQuickOperation', 441 'doQuickOperationsInternal', 442 [ [ 'op' => '' ] ] 443 ], 444 'prepare' => [ 'prepare', 'doPrepare' ], 445 'secure' => [ 'secure', 'doSecure' ], 446 'publish' => [ 'publish', 'doPublish' ], 447 'clean' => [ 'clean', 'doClean' ], 448 ]; 449 } 450 451 /** 452 * @covers ::doOperations 453 * @covers ::doOperation 454 * @covers ::doQuickOperations 455 * @covers ::doQuickOperation 456 * @covers ::prepare 457 * @covers ::secure 458 * @covers ::publish 459 * @covers ::clean 460 * @covers ::newStatus 461 * @dataProvider provideReadOnly 462 * @param string $method Method to call 463 * @param string $internalMethod Internal method the call will be forwarded to 464 * @param array $args To be passed to $method before a final argument of 465 * [ 'bypassReadOnly' => true ] 466 */ 467 public function testDoOperations_bypassReadOnly( 468 string $method, string $internalMethod, array $args = [] 469 ) : void { 470 $backend = $this->newMockFileBackend( [ 'readOnly' => '.' ], $internalMethod ); 471 $backend->expects( $this->once() )->method( $internalMethod ) 472 ->willReturn( StatusValue::newGood( 'myvalue' ) ); 473 474 $status = $backend->$method( ...array_merge( $args, [ [ 'bypassReadOnly' => true ] ] ) ); 475 476 $this->assertTrue( $status->isOK() ); 477 $this->assertSame( [], $status->getErrors() ); 478 $this->assertSame( 'myvalue', $status->getValue() ); 479 } 480 481 /** 482 * @covers ::doOperations 483 * @covers ::doQuickOperations 484 * @covers ::newStatus 485 * @dataProvider provideDoMultipleOperations 486 * @param string $method 487 */ 488 public function testDoOperations_noOp( string $method ) : void { 489 $backend = $this->newMockFileBackend( 490 [ 'doOperationsInternal', 'doQuickOperationsInternal' ] ); 491 $backend->expects( $this->never() )->method( 'doOperationsInternal' ); 492 $backend->expects( $this->never() )->method( 'doQuickOperationsInternal' ); 493 494 $status = $backend->$method( [] ); 495 $this->assertTrue( $status->isOK() ); 496 $this->assertSame( [], $status->getErrors() ); 497 } 498 499 public static function provideDoMultipleOperations() : array { 500 return [ 501 'doOperations' => [ 'doOperations' ], 502 'doQuickOperations' => [ 'doQuickOperations' ], 503 ]; 504 } 505 506 /** 507 * @covers ::doOperations 508 * @covers ::doOperation 509 * @dataProvider provideDoOperations 510 * @param string $method 'doOperation' or 'doOperations' 511 */ 512 public function testDoOperations_nonLockingNoForce( string $method ) : void { 513 $backend = $this->newMockFileBackend( [ 'doOperationsInternal' ] ); 514 $backend->expects( $this->once() )->method( 'doOperationsInternal' ) 515 ->with( [ [] ], [] ); 516 $backend->$method( $method === 'doOperation' ? [] : [ [] ], [ 'nonLocking' => true ] ); 517 } 518 519 public static function provideDoOperations() : array { 520 return [ 521 'doOperations' => [ 'doOperations' ], 522 'doOperation' => [ 'doOperation' ], 523 ]; 524 } 525 526 /** 527 * @covers ::doOperations 528 * @covers ::doOperation 529 * @dataProvider provideDoOperations 530 * @param string $method 'doOperation' or 'doOperations' 531 */ 532 public function testDoOperations_nonLockingForce( string $method ) : void { 533 $backend = $this->newMockFileBackend( [ 'doOperationsInternal' ] ); 534 $backend->expects( $this->once() )->method( 'doOperationsInternal' ) 535 ->with( [ [] ], [ 'nonLocking' => true, 'force' => true ] ); 536 $backend->$method( 537 $method === 'doOperation' ? [] : [ [] ], 538 [ 'nonLocking' => true, 'force' => true ] 539 ); 540 } 541 542 // XXX Can't test newScopedIgnoreUserAbort() because it's a no-op in CLI 543 544 /** 545 * @covers ::create 546 * @covers ::store 547 * @covers ::copy 548 * @covers ::move 549 * @covers ::delete 550 * @covers ::describe 551 * @covers ::quickCreate 552 * @covers ::quickStore 553 * @covers ::quickCopy 554 * @covers ::quickMove 555 * @covers ::quickDelete 556 * @covers ::quickDescribe 557 * @dataProvider provideAction 558 * @param string $prefix '' or 'quick' 559 * @param string $action 560 */ 561 public function testAction( string $prefix, string $action ) : void { 562 $backend = $this->newMockFileBackend( 'do' . ucfirst( $prefix ) . 'OperationsInternal' ); 563 $expectedOp = [ 'op' => $action, 'foo' => 'bar' ]; 564 if ( $prefix === 'quick' ) { 565 $expectedOp['overwrite'] = true; 566 } 567 $backend->expects( $this->once() ) 568 ->method( 'do' . ucfirst( $prefix ) . 'OperationsInternal' ) 569 ->with( [ $expectedOp ], [ 'baz' => 'quuz' ] ) 570 ->willReturn( StatusValue::newGood( 'myvalue' ) ); 571 572 $method = $prefix ? $prefix . ucfirst( $action ) : $action; 573 $status = $backend->$method( [ 'op' => 'ignored', 'foo' => 'bar' ], [ 'baz' => 'quuz' ] ); 574 575 $this->assertTrue( $status->isOK() ); 576 $this->assertSame( 'myvalue', $status->getValue() ); 577 } 578 579 public static function provideAction() : array { 580 $ret = []; 581 foreach ( [ '', 'quick' ] as $prefix ) { 582 foreach ( [ 'create', 'store', 'copy', 'move', 'delete', 'describe' ] as $action ) { 583 $key = $prefix ? $prefix . ucfirst( $action ) : $action; 584 $ret[$key] = [ $prefix, $action ]; 585 } 586 } 587 return $ret; 588 } 589 590 /** 591 * @covers ::prepare 592 * @covers ::secure 593 * @covers ::publish 594 * @covers ::clean 595 * @dataProvider provideForwardToDo 596 * @param string $method 597 */ 598 public function testForwardToDo( string $method ) : void { 599 $backend = $this->newMockFileBackend( 'do' . ucfirst( $method ) ); 600 $backend->expects( $this->once() )->method( 'do' . ucfirst( $method ) ) 601 ->with( [ 'foo' => 'bar' ] ) 602 ->willReturn( StatusValue::newGood( 'myvalue' ) ); 603 604 $status = $backend->$method( [ 'foo' => 'bar' ] ); 605 606 $this->assertTrue( $status->isOK() ); 607 $this->assertSame( [], $status->getErrors() ); 608 $this->assertSame( 'myvalue', $status->getValue() ); 609 } 610 611 public static function provideForwardToDo() : array { 612 return [ 613 'prepare' => [ 'prepare' ], 614 'secure' => [ 'secure' ], 615 'publish' => [ 'publish' ], 616 'clean' => [ 'clean' ], 617 ]; 618 } 619 620 /** 621 * @covers ::getFileContents 622 * @covers ::getLocalReference 623 * @covers ::getLocalCopy 624 * @dataProvider provideForwardToMulti 625 * @param string $method 626 */ 627 public function testForwardToMulti( string $method ) : void { 628 $backend = $this->newMockFileBackend( "{$method}Multi" ); 629 $backend->expects( $this->once() )->method( "{$method}Multi" ) 630 ->with( [ 'srcs' => [ 'mysrc' ], 'foo' => 'bar', 'src' => 'mysrc' ] ) 631 ->willReturn( [ 'mysrc' => 'mycontents' ] ); 632 633 $result = $backend->$method( [ 'srcs' => 'ignored', 'foo' => 'bar', 'src' => 'mysrc' ] ); 634 635 $this->assertSame( 'mycontents', $result ); 636 } 637 638 public static function provideForwardToMulti() : array { 639 return [ 640 'getFileContents' => [ 'getFileContents' ], 641 'getLocalReference' => [ 'getLocalReference' ], 642 'getLocalCopy' => [ 'getLocalCopy' ], 643 ]; 644 } 645 646 /** 647 * @covers ::getTopDirectoryList 648 * @covers ::getTopFileList 649 * @dataProvider provideForwardFromTop 650 * @param string $methodSuffix 651 */ 652 public function testForwardFromTop( string $methodSuffix ) : void { 653 $backend = $this->newMockFileBackend( "get$methodSuffix" ); 654 $backend->expects( $this->once() )->method( "get$methodSuffix" ) 655 ->with( [ 'topOnly' => true, 'foo' => 'bar' ] ) 656 ->willReturn( [ 'something' ] ); 657 658 $method = "getTop$methodSuffix"; 659 $result = $backend->$method( [ 'topOnly' => 'ignored', 'foo' => 'bar' ] ); 660 661 $this->assertSame( [ 'something' ], $result ); 662 } 663 664 public static function provideForwardFromTop() : array { 665 return [ 666 'getTopDirectoryList' => [ 'DirectoryList' ], 667 'getTopFileList' => [ 'FileList' ], 668 ]; 669 } 670 671 /** 672 * @covers ::__construct 673 * @covers ::lockFiles 674 * @covers ::unlockFiles 675 * @dataProvider provideLockUnlockFiles 676 * @param string $method 677 * @param int $timeout Only relevant for lockFiles 678 */ 679 public function testLockUnlockFiles( string $method, ?int $timeout = null ) : void { 680 $args = [ [ 'mwstore://a/b/', 'mwstore://c/d//e' ], LockManager::LOCK_SH ]; 681 682 $mockLm = $this->getMockBuilder( LockManager::class ) 683 ->disableOriginalConstructor() 684 ->setMethods( [ 'do' . ucfirst( $method ) . 'ByType', 'doLock', 'doUnlock' ] ) 685 ->getMock(); 686 // XXX PHPUnit can't override final methods (T231419) 687 //$mockLm->expects( $this->once() )->method( $method ) 688 // ->with( ...array_merge( $args, [ $timeout ?? 0 ] ) ) 689 // ->willReturn( StatusValue::newGood( 'myvalue' ) ); 690 //$mockLm->expects( $this->never() )->method( $this->anythingBut( $method ) ); 691 $mockLm->expects( $this->once() )->method( 'do' . ucfirst( $method ) . 'ByType' ) 692 ->with( [ LockManager::LOCK_SH => [ 'mwstore://a/b', 'mwstore://c/d/e' ] ] ) 693 ->willReturn( StatusValue::newGood( 'myvalue' ) ); 694 695 $backend = $this->newMockFileBackend( [ 'lockManager' => $mockLm ] ); 696 $backendMethod = "{$method}Files"; 697 698 $status = $backend->$backendMethod( ...array_merge( $args, (array)$timeout ) ); 699 700 $this->assertTrue( $status->isOK() ); 701 $this->assertSame( [], $status->getErrors() ); 702 $this->assertSame( 'myvalue', $status->getValue() ); 703 } 704 705 public static function provideLockUnlockFiles() : array { 706 return [ 707 [ 'lock' ], 708 [ 'lock', 731 ], 709 [ 'unlock' ], 710 ]; 711 } 712 713 /** 714 * @covers ::getScopedFileLocks 715 * @dataProvider provideGetScopedFileLocks 716 * @param array $paths 717 * @param int|string $type 718 * @param array $expectedPathsByType Expected to be passed to the LockManager 719 * @param StatusValue $lockStatus Returned from doLockByType() 720 * @param StatusValue|null $unlockStatus Returned from doUnlockByType() (if locking succeeded) 721 */ 722 public function testGetScopedFileLocks( 723 array $paths, $type, array $expectedPathsByType, StatusValue $lockStatus, 724 ?StatusValue $unlockStatus = null 725 ) : void { 726 $mockLm = $this->getMockBuilder( LockManager::class ) 727 ->disableOriginalConstructor() 728 ->setMethods( [ 'doLockByType', 'doUnlockByType', 'doLock', 'doUnlock' ] ) 729 ->getMock(); 730 $mockLm->expects( $this->once() )->method( 'doLockByType' ) 731 ->with( $expectedPathsByType ) 732 ->willReturn( $lockStatus ); 733 $mockLm->expects( $this->exactly( $unlockStatus ? 1 : 0 ) )->method( 'doUnlockByType' ) 734 ->with( $expectedPathsByType ) 735 ->willReturn( $unlockStatus ); 736 737 $backend = $this->newMockFileBackend( [ 'lockManager' => $mockLm ] ); 738 739 $status = StatusValue::newGood( 'myvalue' ); 740 $scopedLock = $backend->getScopedFileLocks( $paths, $type, $status ); 741 742 $this->assertSame( 'myvalue', $status->getValue() ); 743 $this->assertSame( $lockStatus->isOK(), $status->isOK() ); 744 $this->assertSame( $lockStatus->getErrors(), $status->getErrors() ); 745 746 if ( !$lockStatus->isOK() ) { 747 $this->assertNull( $scopedLock ); 748 return; 749 } 750 751 $this->assertInstanceOf( ScopedLock::class, $scopedLock ); 752 unset( $scopedLock ); 753 754 $this->assertSame( 'myvalue', $status->getValue() ); 755 $this->assertSame( $lockStatus->isOK(), $status->isOK() ); 756 $this->assertSame( array_merge( $lockStatus->getErrors(), $unlockStatus->getErrors() ), 757 $status->getErrors() ); 758 } 759 760 public static function provideGetScopedFileLocks() : array { 761 return [ 762 'Simple successful shared lock' => [ 763 [ 'mwstore://a/b/' ], LockManager::LOCK_SH, 764 [ LockManager::LOCK_SH => [ 'mwstore://a/b' ] ], 765 StatusValue::newGood( 'value2' ), StatusValue::newGood( 'value3' ), 766 ], 767 'Mixed lock' => [ 768 [ LockManager::LOCK_SH => [ 'mwstore://a/b/' ], 769 LockManager::LOCK_EX => [ 'mwstore://c/d//e' ] ], 'mixed', 770 [ LockManager::LOCK_SH => [ 'mwstore://a/b' ], 771 LockManager::LOCK_EX => [ 'mwstore://c/d/e' ] ], 772 StatusValue::newGood(), StatusValue::newGood(), 773 ], 774 'Mixed with only shared locks' => [ 775 [ LockManager::LOCK_SH => [ 'mwstore://a/b/', 'mwstore://c/d//e' ] ], 'mixed', 776 [ LockManager::LOCK_SH => [ 'mwstore://a/b', 'mwstore://c/d/e' ] ], 777 StatusValue::newGood(), StatusValue::newGood(), 778 ], 779 'Locking error' => [ 780 [ 'mwstore://a/b/' ], LockManager::LOCK_EX, 781 [ LockManager::LOCK_EX => [ 'mwstore://a/b' ] ], 782 StatusValue::newFatal( 'XXX' ), 783 ], 784 'Unlocking error' => [ 785 [ 'mwstore://a/b/', 'mwstore://c/d//e' ], LockManager::LOCK_EX, 786 [ LockManager::LOCK_EX => [ 'mwstore://a/b', 'mwstore://c/d/e' ] ], 787 StatusValue::newGood(), StatusValue::newFatal( 'XXXX' ), 788 ], 789 ]; 790 } 791 792 /** 793 * @covers ::__construct 794 * @covers ::getRootStoragePath 795 * @dataProvider provideConstruct_validName 796 * @param mixed $name 797 */ 798 public function testGetRootStoragePath( $name ) : void { 799 $backend = $this->newMockFileBackend( [ 'name' => $name ] ); 800 $this->assertSame( "mwstore://$name", $backend->getRootStoragePath() ); 801 } 802 803 /** 804 * @covers ::__construct 805 * @covers ::getContainerStoragePath 806 * @dataProvider provideConstruct_validName 807 * @param mixed $name 808 */ 809 public function testGetContainerStoragePath( $name ) : void { 810 $backend = $this->newMockFileBackend( [ 'name' => $name ] ); 811 $this->assertSame( "mwstore://$name/mycontainer", 812 $backend->getContainerStoragePath( 'mycontainer' ) ); 813 } 814 815 /** 816 * @covers ::__construct 817 * @covers ::getJournal 818 */ 819 public function testGetFileJournal_default() : void { 820 $backend = $this->newMockFileBackend(); 821 $this->assertEquals( new NullFileJournal, $backend->getJournal() ); 822 } 823 824 /** 825 * @covers ::__construct 826 * @covers ::getJournal 827 */ 828 public function testGetJournal() : void { 829 $mockJournal = $this->createNoOpMock( FileJournal::class ); 830 $backend = $this->newMockFileBackend( [ 'fileJournal' => $mockJournal ] ); 831 $this->assertSame( $mockJournal, $backend->getJournal() ); 832 } 833 834 /** 835 * @covers ::isStoragePath 836 * @dataProvider provideIsStoragePath 837 * @param string $path 838 * @param bool $expected 839 */ 840 public function testIsStoragePath( string $path, bool $expected ) : void { 841 $this->assertSame( $expected, FileBackend::isStoragePath( $path ) ); 842 } 843 844 public static function provideIsStoragePath() : array { 845 $paths = [ 846 'mwstore://' => true, 847 'mwstore://backend' => true, 848 'mwstore://backend/container' => true, 849 'mwstore://backend/container/' => true, 850 'mwstore://backend/container/path' => true, 851 'mwstore://backend//container/' => true, 852 'mwstore://backend//container//' => true, 853 'mwstore://backend//container//path' => true, 854 'mwstore:///' => true, 855 'mwstore:/' => false, 856 'mwstore:' => false, 857 ]; 858 $ret = []; 859 foreach ( $paths as $path => $expected ) { 860 $ret[$path] = [ $path, $expected ]; 861 } 862 return $ret; 863 } 864 865 /** 866 * @covers ::splitStoragePath 867 * @dataProvider provideSplitStoragePath 868 * @param string $path 869 * @param array $expected 870 */ 871 public function testSplitStoragePath( string $path, array $expected ) : void { 872 $this->assertSame( $expected, FileBackend::splitStoragePath( $path ) ); 873 } 874 875 public static function provideSplitStoragePath() : array { 876 $paths = [ 877 'mwstore://backend/container' => [ 'backend', 'container', '' ], 878 'mwstore://backend/container/' => [ 'backend', 'container', '' ], 879 'mwstore://backend/container/path' => [ 'backend', 'container', 'path' ], 880 'mwstore://backend/container//path' => [ 'backend', 'container', '/path' ], 881 'mwstore://backend//container/path' => [ null, null, null ], 882 'mwstore://backend//container' => [ null, null, null ], 883 'mwstore://backend//container//path' => [ null, null, null ], 884 'mwstore://' => [ null, null, null ], 885 'mwstore://backend' => [ null, null, null ], 886 'mwstore:///' => [ null, null, null ], 887 'mwstore:/' => [ null, null, null ], 888 'mwstore:' => [ null, null, null ], 889 ]; 890 $ret = []; 891 foreach ( $paths as $path => $expected ) { 892 $ret[$path] = [ $path, $expected ]; 893 } 894 return $ret; 895 } 896 897 /** 898 * @covers ::normalizeStoragePath 899 * @dataProvider provideNormalizeStoragePath 900 * @param string $path 901 * @param string|null $expected 902 */ 903 public function testNormalizeStoragePath( string $path, ?string $expected ) : void { 904 $this->assertSame( $expected, FileBackend::normalizeStoragePath( $path ) ); 905 } 906 907 public static function provideNormalizeStoragePath() : array { 908 $paths = [ 909 'mwstore://backend/container' => 'mwstore://backend/container', 910 'mwstore://backend/container/' => 'mwstore://backend/container', 911 'mwstore://backend/container/path' => 'mwstore://backend/container/path', 912 'mwstore://backend/container//path' => 'mwstore://backend/container/path', 913 'mwstore://backend/container///path' => 'mwstore://backend/container/path', 914 'mwstore://backend/container///path//to///obj' => 915 'mwstore://backend/container/path/to/obj', 916 'mwstore://' => null, 917 'mwstore://backend' => null, 918 'mwstore://backend//container' => null, 919 'mwstore://backend//container/path' => null, 920 'mwstore://backend//container//path' => null, 921 'mwstore:///' => null, 922 'mwstore:/' => null, 923 'mwstore:' => null, 924 ]; 925 $ret = []; 926 foreach ( $paths as $path => $expected ) { 927 $ret[$path] = [ $path, $expected ]; 928 } 929 return $ret; 930 } 931 932 /** 933 * @covers ::parentStoragePath 934 * @dataProvider provideParentStoragePath 935 * @param string $path 936 * @param string|null $expected 937 */ 938 public function testParentStoragePath( string $path, ?string $expected ) : void { 939 $this->assertSame( $expected, FileBackend::parentStoragePath( $path ) ); 940 } 941 942 public static function provideParentStoragePath() : array { 943 $paths = [ 944 'mwstore://backend/container/path/to/obj' => 'mwstore://backend/container/path/to', 945 'mwstore://backend/container/path/to' => 'mwstore://backend/container/path', 946 'mwstore://backend/container/path' => 'mwstore://backend/container', 947 'mwstore://backend/container' => null, 948 'mwstore://backend/container/path/to/obj/' => 'mwstore://backend/container/path/to', 949 'mwstore://backend/container/path/to/' => 'mwstore://backend/container/path', 950 'mwstore://backend/container/path/' => 'mwstore://backend/container', 951 'mwstore://backend/container/' => null, 952 ]; 953 $ret = []; 954 foreach ( $paths as $path => $expected ) { 955 $ret[$path] = [ $path, $expected ]; 956 } 957 return $ret; 958 } 959 960 /** 961 * @covers ::extensionFromPath 962 * @dataProvider provideExtensionFromPath 963 * @param array $args 964 * @param string $expected 965 */ 966 public function testExtensionFromPath( array $args, string $expected ) : void { 967 $this->assertSame( $expected, FileBackend::extensionFromPath( ...$args ) ); 968 } 969 970 public static function provideExtensionFromPath() : array { 971 $paths = [ 972 'mwstore://backend/container/path.Txt' => 'Txt', 973 'mwstore://backend/container/path.svg.pNG' => 'pNG', 974 'mwstore://backend/container/path' => '', 975 'mwstore://backend/container/path.' => '', 976 ]; 977 $ret = []; 978 foreach ( $paths as $path => $expected ) { 979 $ret[$path] = [ [ $path ], strtolower( $expected ) ]; 980 $ret["$path (lowercase)"] = [ [ $path, 'lowercase' ], strtolower( $expected ) ]; 981 $ret["$path (uppercase)"] = [ [ $path, 'uppercase' ], strtoupper( $expected ) ]; 982 $ret["$path (rawcase)"] = [ [ $path, 'rawcase' ], $expected ]; 983 } 984 return $ret; 985 } 986 987 /** 988 * @covers ::isPathTraversalFree 989 * @covers ::normalizeContainerPath 990 * @dataProvider provideIsPathTraversalFree 991 * @param string $path 992 * @param bool $expected 993 */ 994 public function testIsPathTraversalFree( string $path, bool $expected ) : void { 995 $this->assertSame( $expected, FileBackend::isPathTraversalFree( $path ) ); 996 } 997 998 public static function provideIsPathTraversalFree() : array { 999 $traversalFree = [ 1000 'a\\b', 1001 'a//b', 1002 '/a', 1003 '\\a//b/', 1004 ]; 1005 1006 $hasTraversal = []; 1007 1008 $strippedPrefixes = [ '', '/', '//', '///', '\\', '\\\\', '\\\\\\' ]; 1009 $unstrippedPrefixes = [ '.', 'a', 'a/', '/a', ' ', "\0" ]; 1010 $suffixes = [ '', '.', 'a', '/', '/a', ' ', "\0" ]; 1011 1012 foreach ( [ '.', '..' ] as $basePath ) { 1013 foreach ( $strippedPrefixes as $prefix ) { 1014 foreach ( $suffixes as $suffix ) { 1015 if ( $suffix === '' ) { 1016 $hasTraversal[] = "$prefix$basePath"; 1017 } else { 1018 $traversalFree[] = "$prefix$basePath$suffix"; 1019 } 1020 } 1021 } 1022 foreach ( $unstrippedPrefixes as $prefix ) { 1023 foreach ( $suffixes as $suffix ) { 1024 $traversalFree[] = "$prefix$basePath$suffix"; 1025 } 1026 } 1027 } 1028 1029 foreach ( [ './', '.\\', '../', '..\\' ] as $basePath ) { 1030 foreach ( $strippedPrefixes as $prefix ) { 1031 foreach ( $suffixes as $suffix ) { 1032 $hasTraversal[] = "$prefix$basePath$suffix"; 1033 } 1034 } 1035 foreach ( $unstrippedPrefixes as $prefix ) { 1036 foreach ( $suffixes as $suffix ) { 1037 $traversalFree[] = "$prefix$basePath$suffix"; 1038 } 1039 } 1040 } 1041 1042 foreach ( 1043 [ '/./', '\\./', '/.\\', '\\.\\', '/../', '\\../', '/..\\', '\\..\\' ] as $basePath 1044 ) { 1045 foreach ( array_merge( $strippedPrefixes, $unstrippedPrefixes ) as $prefix ) { 1046 foreach ( $suffixes as $suffix ) { 1047 $hasTraversal[] = "$prefix$basePath$suffix"; 1048 } 1049 } 1050 } 1051 1052 // Some things might be traversal-free vis-a-vis one base path but a traversal for another 1053 $traversalFree = array_diff( $traversalFree, $hasTraversal ); 1054 1055 $ret = []; 1056 foreach ( $traversalFree as $path ) { 1057 $ret[$path] = [ $path, true ]; 1058 } 1059 foreach ( $hasTraversal as $path ) { 1060 $ret[$path] = [ $path, false ]; 1061 } 1062 return $ret; 1063 } 1064 1065 /** 1066 * @covers ::makeContentDisposition 1067 * @dataProvider provideMakeContentDisposition 1068 * @param array $args 1069 * @param string $expected 1070 */ 1071 public function testMakeContentDisposition( array $args, string $expected ) 1072 : void { 1073 $this->assertSame( $expected, FileBackend::makeContentDisposition( ...$args ) ); 1074 } 1075 1076 public static function provideMakeContentDisposition() : array { 1077 $tests = [ 1078 [ [ 'inline' ], 'inline' ], 1079 [ [ 'inLINE' ], 'inline' ], 1080 [ [ 'inLINE', '' ], 'inline' ], 1081 [ [ 'attachment' ], 'attachment' ], 1082 [ [ 'atTACHment' ], 'attachment' ], 1083 [ [ 'atTACHment', '' ], 'attachment' ], 1084 1085 [ [ 'inline', 'filename.txt' ], "inline;filename*=UTF-8''filename.txt" ], 1086 [ [ 'attachment', 'filename.txt' ], "attachment;filename*=UTF-8''filename.txt" ], 1087 1088 [ [ 'inline', 'path/filename!!!' ], "inline;filename*=UTF-8''filename%21%21%21" ], 1089 ]; 1090 $ret = []; 1091 foreach ( $tests as [ $args, $expected ] ) { 1092 $ret[implode( ', ', $args )] = [ $args, $expected ]; 1093 } 1094 return $ret; 1095 } 1096 1097 /** 1098 * @covers ::makeContentDisposition 1099 * @dataProvider provideMakeContentDisposition_invalid 1100 * @param string ...$args 1101 */ 1102 public function testMakeContentDisposition_invalid( string ...$args ) : void { 1103 $this->expectException( InvalidArgumentException::class ); 1104 $this->expectExceptionMessage( "Invalid Content-Disposition type '{$args[0]}'." ); 1105 1106 FileBackend::makeContentDisposition( ...$args ); 1107 } 1108 1109 public static function provideMakeContentDisposition_invalid() : array { 1110 return [ 1111 [ 'foo' ], 1112 [ 'foo', '' ], 1113 [ 'foo', 'bar' ], 1114 [ ' inline' ], 1115 [ 'inline ' ], 1116 ]; 1117 } 1118 1119 /** 1120 * @covers ::doOperations 1121 * @covers ::doOperation 1122 * @covers ::resolveFSFileObjects 1123 * @dataProvider provideDoOperations 1124 * @param string $method 'doOperation' or 'doOperations' 1125 */ 1126 public function testResolveFSFileObjects( string $method ) : void { 1127 $tmpFile = ( new TempFSFileFactory )->newTempFSFile( 'a' ); 1128 1129 $backend = $this->newMockFileBackend( 'doOperationsInternal' ); 1130 $backend->expects( $this->once() )->method( 'doOperationsInternal' ) 1131 ->with( [ [ 'src' => $tmpFile->getPath(), 'srcRef' => $tmpFile ] ] ) 1132 ->willReturn( StatusValue::newGood() ); 1133 1134 $op = [ 'src' => $tmpFile ]; 1135 if ( $method === 'doOperations' ) { 1136 $op = [ $op ]; 1137 } 1138 $status = $backend->$method( $op ); 1139 1140 $this->assertTrue( $status->isOK() ); 1141 $this->assertSame( [], $status->getErrors() ); 1142 } 1143 1144 /** 1145 * @covers ::doOperations 1146 * @covers ::doOperation 1147 * @covers ::resolveFSFileObjects 1148 * @dataProvider provideDoOperations 1149 * @param string $method 'doOperation' or 'doOperations' 1150 */ 1151 public function testResolveFSFileObjects_preservesTempFiles( string $method ) : void { 1152 $tmpFile = ( new TempFSFileFactory )->newTempFSFile( 'a' ); 1153 $path = $tmpFile->getPath(); 1154 1155 $backend = $this->newMockFileBackend(); 1156 1157 $op = [ 'src' => $tmpFile ]; 1158 if ( $method === 'doOperations' ) { 1159 $op = [ $op ]; 1160 } 1161 $status = $backend->$method( $op ); 1162 1163 $this->assertTrue( file_exists( $path ) ); 1164 } 1165 1166 /** 1167 * @covers ::__construct 1168 * @covers ::wrapStatus 1169 */ 1170 public function testWrapStatus() : void { 1171 $expectedSv = StatusValue::newGood( 'myvalue' ); 1172 $backend = $this->newMockFileBackend( [ 'statusWrapper' => 1173 function ( StatusValue $sv ) use ( $expectedSv ) : StatusValue { 1174 $this->assertEquals( StatusValue::newGood(), $sv ); 1175 return $expectedSv; 1176 } 1177 ] ); 1178 $this->assertSame( $expectedSv, $backend->doOperations( [] ) ); 1179 } 1180 1181 /** 1182 * @covers ::scopedProfileSection 1183 */ 1184 public function testScopedProfileSection() : void { 1185 $scopedCallback = new ScopedCallback( function () { 1186 } ); 1187 $backend = $this->newMockFileBackend( [ 'profiler' => 1188 function ( string $section ) use ( $scopedCallback ) : ScopedCallback { 1189 $this->assertSame( 'mysection', $section ); 1190 return $scopedCallback; 1191 } 1192 ] ); 1193 // See comment in testConstruct_properties about use of TestingAccessWrapper. 1194 $this->assertSame( $scopedCallback, 1195 TestingAccessWrapper::newFromObject( $backend )->scopedProfileSection( 'mysection' ) ); 1196 } 1197} 1198