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