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