]> scripts.mit.edu Git - autoinstallsdev/mediawiki.git/blob - tests/phpunit/includes/config/EtcdConfigTest.php
MediaWiki 1.30.2
[autoinstallsdev/mediawiki.git] / tests / phpunit / includes / config / EtcdConfigTest.php
1 <?php
2
3 use Wikimedia\TestingAccessWrapper;
4
5 class EtcConfigTest extends PHPUnit_Framework_TestCase {
6
7         private function createConfigMock( array $options = [] ) {
8                 return $this->getMockBuilder( EtcdConfig::class )
9                         ->setConstructorArgs( [ $options + [
10                                 'host' => 'etcd-tcp.example.net',
11                                 'directory' => '/',
12                                 'timeout' => 0.1,
13                         ] ] )
14                         ->setMethods( [ 'fetchAllFromEtcd' ] )
15                         ->getMock();
16         }
17
18         private function createSimpleConfigMock( array $config ) {
19                 $mock = $this->createConfigMock();
20                 $mock->expects( $this->once() )->method( 'fetchAllFromEtcd' )
21                         ->willReturn( [
22                                 $config,
23                                 null, // error
24                                 false // retry?
25                         ] );
26                 return $mock;
27         }
28
29         /**
30          * @covers EtcdConfig::has
31          */
32         public function testHasKnown() {
33                 $config = $this->createSimpleConfigMock( [
34                         'known' => 'value'
35                 ] );
36                 $this->assertSame( true, $config->has( 'known' ) );
37         }
38
39         /**
40          * @covers EtcdConfig::__construct
41          * @covers EtcdConfig::get
42          */
43         public function testGetKnown() {
44                 $config = $this->createSimpleConfigMock( [
45                         'known' => 'value'
46                 ] );
47                 $this->assertSame( 'value', $config->get( 'known' ) );
48         }
49
50         /**
51          * @covers EtcdConfig::has
52          */
53         public function testHasUnknown() {
54                 $config = $this->createSimpleConfigMock( [
55                         'known' => 'value'
56                 ] );
57                 $this->assertSame( false, $config->has( 'unknown' ) );
58         }
59
60         /**
61          * @covers EtcdConfig::get
62          */
63         public function testGetUnknown() {
64                 $config = $this->createSimpleConfigMock( [
65                         'known' => 'value'
66                 ] );
67                 $this->setExpectedException( ConfigException::class );
68                 $config->get( 'unknown' );
69         }
70
71         /**
72          * @covers EtcdConfig::__construct
73          */
74         public function testConstructCacheObj() {
75                 $cache = $this->getMockBuilder( HashBagOStuff::class )
76                         ->setMethods( [ 'get' ] )
77                         ->getMock();
78                 $cache->expects( $this->once() )->method( 'get' )
79                         ->willReturn( [
80                                 'config' => [ 'known' => 'from-cache' ],
81                                 'expires' => INF,
82                         ] );
83                 $config = $this->createConfigMock( [ 'cache' => $cache ] );
84
85                 $this->assertSame( 'from-cache', $config->get( 'known' ) );
86         }
87
88         /**
89          * @covers EtcdConfig::__construct
90          */
91         public function testConstructCacheSpec() {
92                 $config = $this->createConfigMock( [ 'cache' => [
93                         'class' => HashBagOStuff::class
94                 ] ] );
95                 $config->expects( $this->once() )->method( 'fetchAllFromEtcd' )
96                         ->willReturn( [
97                                 [ 'known' => 'from-fetch' ],
98                                 null, // error
99                                 false // retry?
100                         ] );
101
102                 $this->assertSame( 'from-fetch', $config->get( 'known' ) );
103         }
104
105         /**
106          * Test matrix
107          *
108          * - [x] Cache miss
109          *       Result: Fetched value
110          *       > cache miss | gets lock | backend succeeds
111          *
112          * - [x] Cache miss with backend error
113          *       Result: ConfigException
114          *       > cache miss | gets lock | backend error (no retry)
115          *
116          * - [x] Cache hit after retry
117          *       Result: Cached value (populated by process holding lock)
118          *       > cache miss | no lock | cache retry
119          *
120          * - [x] Cache hit
121          *       Result: Cached value
122          *       > cache hit
123          *
124          * - [x] Process cache hit
125          *       Result: Cached value
126          *       > process cache hit
127          *
128          * - [x] Cache expired
129          *       Result: Fetched value
130          *       > cache expired | gets lock | backend succeeds
131          *
132          * - [x] Cache expired with backend failure
133          *       Result: Cached value (stale)
134          *       > cache expired | gets lock | backend fails (allows retry)
135          *
136          * - [x] Cache expired and no lock
137          *       Result: Cached value (stale)
138          *       > cache expired | no lock
139          *
140          * Other notable scenarios:
141          *
142          * - [ ] Cache miss with backend retry
143          *       Result: Fetched value
144          *       > cache expired | gets lock | backend failure (allows retry)
145          */
146
147         /**
148          * @covers EtcdConfig::load
149          */
150         public function testLoadCacheMiss() {
151                 // Create cache mock
152                 $cache = $this->getMockBuilder( HashBagOStuff::class )
153                         ->setMethods( [ 'get', 'lock' ] )
154                         ->getMock();
155                 // .. misses cache
156                 $cache->expects( $this->once() )->method( 'get' )
157                         ->willReturn( false );
158                 // .. gets lock
159                 $cache->expects( $this->once() )->method( 'lock' )
160                         ->willReturn( true );
161
162                 // Create config mock
163                 $mock = $this->createConfigMock( [
164                         'cache' => $cache,
165                 ] );
166                 $mock->expects( $this->once() )->method( 'fetchAllFromEtcd' )
167                         ->willReturn( [ [ 'known' => 'from-fetch' ], null, false ] );
168
169                 $this->assertSame( 'from-fetch', $mock->get( 'known' ) );
170         }
171
172         /**
173          * @covers EtcdConfig::load
174          */
175         public function testLoadCacheMissBackendError() {
176                 // Create cache mock
177                 $cache = $this->getMockBuilder( HashBagOStuff::class )
178                         ->setMethods( [ 'get', 'lock' ] )
179                         ->getMock();
180                 // .. misses cache
181                 $cache->expects( $this->once() )->method( 'get' )
182                         ->willReturn( false );
183                 // .. gets lock
184                 $cache->expects( $this->once() )->method( 'lock' )
185                         ->willReturn( true );
186
187                 // Create config mock
188                 $mock = $this->createConfigMock( [
189                         'cache' => $cache,
190                 ] );
191                 $mock->expects( $this->once() )->method( 'fetchAllFromEtcd' )
192                         ->willReturn( [ null, 'Fake error', false ] );
193
194                 $this->setExpectedException( ConfigException::class );
195                 $mock->get( 'key' );
196         }
197
198         /**
199          * @covers EtcdConfig::load
200          */
201         public function testLoadCacheMissWithoutLock() {
202                 // Create cache mock
203                 $cache = $this->getMockBuilder( HashBagOStuff::class )
204                         ->setMethods( [ 'get', 'lock' ] )
205                         ->getMock();
206                 $cache->expects( $this->exactly( 2 ) )->method( 'get' )
207                         ->will( $this->onConsecutiveCalls(
208                                 // .. misses cache first time
209                                 false,
210                                 // .. hits cache on retry
211                                 [
212                                         'config' => [ 'known' => 'from-cache' ],
213                                         'expires' => INF,
214                                 ]
215                         ) );
216                 // .. misses lock
217                 $cache->expects( $this->once() )->method( 'lock' )
218                         ->willReturn( false );
219
220                 // Create config mock
221                 $mock = $this->createConfigMock( [
222                         'cache' => $cache,
223                 ] );
224                 $mock->expects( $this->never() )->method( 'fetchAllFromEtcd' );
225
226                 $this->assertSame( 'from-cache', $mock->get( 'known' ) );
227         }
228
229         /**
230          * @covers EtcdConfig::load
231          */
232         public function testLoadCacheHit() {
233                 // Create cache mock
234                 $cache = $this->getMockBuilder( HashBagOStuff::class )
235                         ->setMethods( [ 'get', 'lock' ] )
236                         ->getMock();
237                 $cache->expects( $this->once() )->method( 'get' )
238                         // .. hits cache
239                         ->willReturn( [
240                                 'config' => [ 'known' => 'from-cache' ],
241                                 'expires' => INF,
242                         ] );
243                 $cache->expects( $this->never() )->method( 'lock' );
244
245                 // Create config mock
246                 $mock = $this->createConfigMock( [
247                         'cache' => $cache,
248                 ] );
249                 $mock->expects( $this->never() )->method( 'fetchAllFromEtcd' );
250
251                 $this->assertSame( 'from-cache', $mock->get( 'known' ) );
252         }
253
254         /**
255          * @covers EtcdConfig::load
256          */
257         public function testLoadProcessCacheHit() {
258                 // Create cache mock
259                 $cache = $this->getMockBuilder( HashBagOStuff::class )
260                         ->setMethods( [ 'get', 'lock' ] )
261                         ->getMock();
262                 $cache->expects( $this->once() )->method( 'get' )
263                         // .. hits cache
264                         ->willReturn( [
265                                 'config' => [ 'known' => 'from-cache' ],
266                                 'expires' => INF,
267                         ] );
268                 $cache->expects( $this->never() )->method( 'lock' );
269
270                 // Create config mock
271                 $mock = $this->createConfigMock( [
272                         'cache' => $cache,
273                 ] );
274                 $mock->expects( $this->never() )->method( 'fetchAllFromEtcd' );
275
276                 $this->assertSame( 'from-cache', $mock->get( 'known' ), 'Cache hit' );
277                 $this->assertSame( 'from-cache', $mock->get( 'known' ), 'Process cache hit' );
278         }
279
280         /**
281          * @covers EtcdConfig::load
282          */
283         public function testLoadCacheExpiredLockFetchSucceeded() {
284                 // Create cache mock
285                 $cache = $this->getMockBuilder( HashBagOStuff::class )
286                         ->setMethods( [ 'get', 'lock' ] )
287                         ->getMock();
288                 $cache->expects( $this->once() )->method( 'get' )->willReturn(
289                         // .. stale cache
290                         [
291                                 'config' => [ 'known' => 'from-cache-expired' ],
292                                 'expires' => -INF,
293                         ]
294                 );
295                 // .. gets lock
296                 $cache->expects( $this->once() )->method( 'lock' )
297                         ->willReturn( true );
298
299                 // Create config mock
300                 $mock = $this->createConfigMock( [
301                         'cache' => $cache,
302                 ] );
303                 $mock->expects( $this->once() )->method( 'fetchAllFromEtcd' )
304                         ->willReturn( [ [ 'known' => 'from-fetch' ], null, false ] );
305
306                 $this->assertSame( 'from-fetch', $mock->get( 'known' ) );
307         }
308
309         /**
310          * @covers EtcdConfig::load
311          */
312         public function testLoadCacheExpiredLockFetchFails() {
313                 // Create cache mock
314                 $cache = $this->getMockBuilder( HashBagOStuff::class )
315                         ->setMethods( [ 'get', 'lock' ] )
316                         ->getMock();
317                 $cache->expects( $this->once() )->method( 'get' )->willReturn(
318                         // .. stale cache
319                         [
320                                 'config' => [ 'known' => 'from-cache-expired' ],
321                                 'expires' => -INF,
322                         ]
323                 );
324                 // .. gets lock
325                 $cache->expects( $this->once() )->method( 'lock' )
326                         ->willReturn( true );
327
328                 // Create config mock
329                 $mock = $this->createConfigMock( [
330                         'cache' => $cache,
331                 ] );
332                 $mock->expects( $this->once() )->method( 'fetchAllFromEtcd' )
333                         ->willReturn( [ null, 'Fake failure', true ] );
334
335                 $this->assertSame( 'from-cache-expired', $mock->get( 'known' ) );
336         }
337
338         /**
339          * @covers EtcdConfig::load
340          */
341         public function testLoadCacheExpiredNoLock() {
342                 // Create cache mock
343                 $cache = $this->getMockBuilder( HashBagOStuff::class )
344                         ->setMethods( [ 'get', 'lock' ] )
345                         ->getMock();
346                 $cache->expects( $this->once() )->method( 'get' )
347                         // .. hits cache (expired value)
348                         ->willReturn( [
349                                 'config' => [ 'known' => 'from-cache-expired' ],
350                                 'expires' => -INF,
351                         ] );
352                 // .. misses lock
353                 $cache->expects( $this->once() )->method( 'lock' )
354                         ->willReturn( false );
355
356                 // Create config mock
357                 $mock = $this->createConfigMock( [
358                         'cache' => $cache,
359                 ] );
360                 $mock->expects( $this->never() )->method( 'fetchAllFromEtcd' );
361
362                 $this->assertSame( 'from-cache-expired', $mock->get( 'known' ) );
363         }
364
365         public static function provideFetchFromServer() {
366                 return [
367                         '200 OK - Success' => [
368                                 'http' => [
369                                         'code' => 200,
370                                         'reason' => 'OK',
371                                         'headers' => [],
372                                         'body' => json_encode( [ 'node' => [ 'nodes' => [
373                                                 [
374                                                         'key' => '/example/foo',
375                                                         'value' => json_encode( [ 'val' => true ] )
376                                                 ],
377                                         ] ] ] ),
378                                         'error' => '',
379                                 ],
380                                 'expect' => [
381                                         [ 'foo' => true ], // data
382                                         null,
383                                         false // retry
384                                 ],
385                         ],
386                         '200 OK - Empty dir' => [
387                                 'http' => [
388                                         'code' => 200,
389                                         'reason' => 'OK',
390                                         'headers' => [],
391                                         'body' => json_encode( [ 'node' => [ 'nodes' => [
392                                                 [
393                                                         'key' => '/example/foo',
394                                                         'value' => json_encode( [ 'val' => true ] )
395                                                 ],
396                                                 [
397                                                         'key' => '/example/sub',
398                                                         'dir' => true,
399                                                         'nodes' => [],
400                                                 ],
401                                                 [
402                                                         'key' => '/example/bar',
403                                                         'value' => json_encode( [ 'val' => false ] )
404                                                 ],
405                                         ] ] ] ),
406                                         'error' => '',
407                                 ],
408                                 'expect' => [
409                                         [ 'foo' => true, 'bar' => false ], // data
410                                         null,
411                                         false // retry
412                                 ],
413                         ],
414                         '200 OK - Recursive' => [
415                                 'http' => [
416                                         'code' => 200,
417                                         'reason' => 'OK',
418                                         'headers' => [],
419                                         'body' => json_encode( [ 'node' => [ 'nodes' => [
420                                                 [
421                                                         'key' => '/example/a',
422                                                         'dir' => true,
423                                                         'nodes' => [
424                                                                 [
425                                                                         'key' => 'b',
426                                                                         'value' => json_encode( [ 'val' => true ] ),
427                                                                 ],
428                                                                 [
429                                                                         'key' => 'c',
430                                                                         'value' => json_encode( [ 'val' => false ] ),
431                                                                 ],
432                                                         ],
433                                                 ],
434                                         ] ] ] ),
435                                         'error' => '',
436                                 ],
437                                 'expect' => [
438                                         [ 'a/b' => true, 'a/c' => false ], // data
439                                         null,
440                                         false // retry
441                                 ],
442                         ],
443                         '200 OK - Missing nodes at second level' => [
444                                 'http' => [
445                                         'code' => 200,
446                                         'reason' => 'OK',
447                                         'headers' => [],
448                                         'body' => json_encode( [ 'node' => [ 'nodes' => [
449                                                 [
450                                                         'key' => '/example/a',
451                                                         'dir' => true,
452                                                 ],
453                                         ] ] ] ),
454                                         'error' => '',
455                                 ],
456                                 'expect' => [
457                                         null,
458                                         "Unexpected JSON response in dir 'a'; missing 'nodes' list.",
459                                         false // retry
460                                 ],
461                         ],
462                         '200 OK - Correctly encoded garbage response' => [
463                                 'http' => [
464                                         'code' => 200,
465                                         'reason' => 'OK',
466                                         'headers' => [],
467                                         'body' => json_encode( [ 'foo' => 'bar' ] ),
468                                         'error' => '',
469                                 ],
470                                 'expect' => [
471                                         null,
472                                         "Unexpected JSON response: Missing or invalid node at top level.",
473                                         false // retry
474                                 ],
475                         ],
476                         '200 OK - Bad value' => [
477                                 'http' => [
478                                         'code' => 200,
479                                         'reason' => 'OK',
480                                         'headers' => [],
481                                         'body' => json_encode( [ 'node' => [ 'nodes' => [
482                                                 [
483                                                         'key' => '/example/foo',
484                                                         'value' => ';"broken{value'
485                                                 ]
486                                         ] ] ] ),
487                                         'error' => '',
488                                 ],
489                                 'expect' => [
490                                         null, // data
491                                         "Failed to parse value for 'foo'.",
492                                         false // retry
493                                 ],
494                         ],
495                         '200 OK - Empty node list' => [
496                                 'http' => [
497                                         'code' => 200,
498                                         'reason' => 'OK',
499                                         'headers' => [],
500                                         'body' => '{"node":{"nodes":[]}}',
501                                         'error' => '',
502                                 ],
503                                 'expect' => [
504                                         [], // data
505                                         null,
506                                         false // retry
507                                 ],
508                         ],
509                         '200 OK - Invalid JSON' => [
510                                 'http' => [
511                                         'code' => 200,
512                                         'reason' => 'OK',
513                                         'headers' => [ 'content-length' => 0 ],
514                                         'body' => '',
515                                         'error' => '(curl error: no status set)',
516                                 ],
517                                 'expect' => [
518                                         null, // data
519                                         "Error unserializing JSON response.",
520                                         false // retry
521                                 ],
522                         ],
523                         '404 Not Found' => [
524                                 'http' => [
525                                         'code' => 404,
526                                         'reason' => 'Not Found',
527                                         'headers' => [ 'content-length' => 0 ],
528                                         'body' => '',
529                                         'error' => '',
530                                 ],
531                                 'expect' => [
532                                         null, // data
533                                         'HTTP 404 (Not Found)',
534                                         false // retry
535                                 ],
536                         ],
537                         '400 Bad Request - custom error' => [
538                                 'http' => [
539                                         'code' => 400,
540                                         'reason' => 'Bad Request',
541                                         'headers' => [ 'content-length' => 0 ],
542                                         'body' => '',
543                                         'error' => 'No good reason',
544                                 ],
545                                 'expect' => [
546                                         null, // data
547                                         'No good reason',
548                                         true // retry
549                                 ],
550                         ],
551                 ];
552         }
553
554         /**
555          * @covers EtcdConfig::fetchAllFromEtcdServer
556          * @covers EtcdConfig::unserialize
557          * @covers EtcdConfig::parseResponse
558          * @covers EtcdConfig::parseDirectory
559          * @covers EtcdConfigParseError
560          * @dataProvider provideFetchFromServer
561          */
562         public function testFetchFromServer( array $httpResponse, array $expected ) {
563                 $http = $this->getMockBuilder( MultiHttpClient::class )
564                         ->disableOriginalConstructor()
565                         ->getMock();
566                 $http->expects( $this->once() )->method( 'run' )
567                         ->willReturn( array_values( $httpResponse ) );
568
569                 $conf = $this->getMockBuilder( EtcdConfig::class )
570                         ->disableOriginalConstructor()
571                         ->getMock();
572                 // Access for protected member and method
573                 $conf = TestingAccessWrapper::newFromObject( $conf );
574                 $conf->http = $http;
575
576                 $this->assertSame(
577                         $expected,
578                         $conf->fetchAllFromEtcdServer( 'etcd-tcp.example.net' )
579                 );
580         }
581 }