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