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