1<?php
2
3namespace MediaWiki\Session;
4
5use MediaWikiIntegrationTestCase;
6use Psr\Log\LoggerInterface;
7use Psr\Log\LogLevel;
8use User;
9use Wikimedia\TestingAccessWrapper;
10
11/**
12 * @group Session
13 * @group Database
14 * @covers MediaWiki\Session\SessionManager
15 */
16class SessionManagerTest extends MediaWikiIntegrationTestCase {
17
18	/** @var \HashConfig */
19	private $config;
20
21	/** @var \TestLogger */
22	private $logger;
23
24	/** @var TestBagOStuff */
25	private $store;
26
27	protected function getManager() {
28		\ObjectCache::$instances['testSessionStore'] = new TestBagOStuff();
29		$this->config = new \HashConfig( [
30			'LanguageCode' => 'en',
31			'SessionCacheType' => 'testSessionStore',
32			'ObjectCacheSessionExpiry' => 100,
33			'SessionProviders' => [
34				[ 'class' => \DummySessionProvider::class ],
35			]
36		] );
37		$this->logger = new \TestLogger( false, static function ( $m ) {
38			return ( strpos( $m, 'SessionBackend ' ) === 0
39				|| strpos( $m, 'SessionManager using store ' ) === 0
40				// These were added for T264793 and behave somewhat erratically, not worth testing
41				|| strpos( $m, 'Failed to load session, unpersisting' ) === 0
42				|| preg_match( '/^(Persisting|Unpersisting) session (for|due to)/', $m )
43			) ? null : $m;
44		} );
45		$this->store = new TestBagOStuff();
46
47		return new SessionManager( [
48			'config' => $this->config,
49			'logger' => $this->logger,
50			'store' => $this->store,
51		] );
52	}
53
54	protected function objectCacheDef( $object ) {
55		return [ 'factory' => static function () use ( $object ) {
56			return $object;
57		} ];
58	}
59
60	public function testSingleton() {
61		$reset = TestUtils::setSessionManagerSingleton( null );
62
63		$singleton = SessionManager::singleton();
64		$this->assertInstanceOf( SessionManager::class, $singleton );
65		$this->assertSame( $singleton, SessionManager::singleton() );
66	}
67
68	public function testGetGlobalSession() {
69		$context = \RequestContext::getMain();
70
71		if ( !PHPSessionHandler::isInstalled() ) {
72			PHPSessionHandler::install( SessionManager::singleton() );
73		}
74		$rProp = new \ReflectionProperty( PHPSessionHandler::class, 'instance' );
75		$rProp->setAccessible( true );
76		$handler = TestingAccessWrapper::newFromObject( $rProp->getValue() );
77		$oldEnable = $handler->enable;
78		$reset[] = new \Wikimedia\ScopedCallback( static function () use ( $handler, $oldEnable ) {
79			if ( $handler->enable ) {
80				session_write_close();
81			}
82			$handler->enable = $oldEnable;
83		} );
84		$reset[] = TestUtils::setSessionManagerSingleton( $this->getManager() );
85
86		$handler->enable = true;
87		$request = new \FauxRequest();
88		$context->setRequest( $request );
89		$id = $request->getSession()->getId();
90
91		session_write_close();
92		session_id( '' );
93		$session = SessionManager::getGlobalSession();
94		$this->assertSame( $id, $session->getId() );
95
96		session_id( 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' );
97		$session = SessionManager::getGlobalSession();
98		$this->assertSame( 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', $session->getId() );
99		$this->assertSame( 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', $request->getSession()->getId() );
100
101		session_write_close();
102		$handler->enable = false;
103		$request = new \FauxRequest();
104		$context->setRequest( $request );
105		$id = $request->getSession()->getId();
106
107		session_id( '' );
108		$session = SessionManager::getGlobalSession();
109		$this->assertSame( $id, $session->getId() );
110
111		session_id( 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' );
112		$session = SessionManager::getGlobalSession();
113		$this->assertSame( $id, $session->getId() );
114		$this->assertSame( $id, $request->getSession()->getId() );
115	}
116
117	public function testConstructor() {
118		$manager = TestingAccessWrapper::newFromObject( $this->getManager() );
119		$this->assertSame( $this->config, $manager->config );
120		$this->assertSame( $this->logger, $manager->logger );
121		$this->assertSame( $this->store, $manager->store );
122
123		$manager = TestingAccessWrapper::newFromObject( new SessionManager() );
124		$this->assertSame( \RequestContext::getMain()->getConfig(), $manager->config );
125
126		$manager = TestingAccessWrapper::newFromObject( new SessionManager( [
127			'config' => $this->config,
128		] ) );
129		$this->assertSame( \ObjectCache::$instances['testSessionStore'], $manager->store );
130
131		foreach ( [
132			'config' => '$options[\'config\'] must be an instance of Config',
133			'logger' => '$options[\'logger\'] must be an instance of LoggerInterface',
134			'store' => '$options[\'store\'] must be an instance of BagOStuff',
135		] as $key => $error ) {
136			try {
137				new SessionManager( [ $key => new \stdClass ] );
138				$this->fail( 'Expected exception not thrown' );
139			} catch ( \InvalidArgumentException $ex ) {
140				$this->assertSame( $error, $ex->getMessage() );
141			}
142		}
143	}
144
145	public function testGetSessionForRequest() {
146		$manager = $this->getManager();
147		$request = new \FauxRequest();
148		$request->unpersist1 = false;
149		$request->unpersist2 = false;
150
151		$id1 = '';
152		$id2 = '';
153		$idEmpty = 'empty-session-------------------';
154
155		$providerBuilder = $this->getMockBuilder( \DummySessionProvider::class )
156			->setMethods(
157				[ 'provideSessionInfo', 'newSessionInfo', '__toString', 'describe', 'unpersistSession' ]
158			);
159
160		$provider1 = $providerBuilder->getMock();
161		$provider1->expects( $this->any() )->method( 'provideSessionInfo' )
162			->with( $this->identicalTo( $request ) )
163			->will( $this->returnCallback( static function ( $request ) {
164				return $request->info1;
165			} ) );
166		$provider1->expects( $this->any() )->method( 'newSessionInfo' )
167			->will( $this->returnCallback( static function () use ( $idEmpty, $provider1 ) {
168				return new SessionInfo( SessionInfo::MIN_PRIORITY, [
169					'provider' => $provider1,
170					'id' => $idEmpty,
171					'persisted' => true,
172					'idIsSafe' => true,
173				] );
174			} ) );
175		$provider1->expects( $this->any() )->method( '__toString' )
176			->will( $this->returnValue( 'Provider1' ) );
177		$provider1->expects( $this->any() )->method( 'describe' )
178			->will( $this->returnValue( '#1 sessions' ) );
179		$provider1->expects( $this->any() )->method( 'unpersistSession' )
180			->will( $this->returnCallback( static function ( $request ) {
181				$request->unpersist1 = true;
182			} ) );
183
184		$provider2 = $providerBuilder->getMock();
185		$provider2->expects( $this->any() )->method( 'provideSessionInfo' )
186			->with( $this->identicalTo( $request ) )
187			->will( $this->returnCallback( static function ( $request ) {
188				return $request->info2;
189			} ) );
190		$provider2->expects( $this->any() )->method( '__toString' )
191			->will( $this->returnValue( 'Provider2' ) );
192		$provider2->expects( $this->any() )->method( 'describe' )
193			->will( $this->returnValue( '#2 sessions' ) );
194		$provider2->expects( $this->any() )->method( 'unpersistSession' )
195			->will( $this->returnCallback( static function ( $request ) {
196				$request->unpersist2 = true;
197			} ) );
198
199		$this->config->set( 'SessionProviders', [
200			$this->objectCacheDef( $provider1 ),
201			$this->objectCacheDef( $provider2 ),
202		] );
203
204		// No provider returns info
205		$request->info1 = null;
206		$request->info2 = null;
207		$session = $manager->getSessionForRequest( $request );
208		$this->assertInstanceOf( Session::class, $session );
209		$this->assertSame( $idEmpty, $session->getId() );
210		$this->assertFalse( $request->unpersist1 );
211		$this->assertFalse( $request->unpersist2 );
212
213		// Both providers return info, picks best one
214		$request->info1 = new SessionInfo( SessionInfo::MIN_PRIORITY + 1, [
215			'provider' => $provider1,
216			'id' => ( $id1 = $manager->generateSessionId() ),
217			'persisted' => true,
218			'idIsSafe' => true,
219		] );
220		$request->info2 = new SessionInfo( SessionInfo::MIN_PRIORITY + 2, [
221			'provider' => $provider2,
222			'id' => ( $id2 = $manager->generateSessionId() ),
223			'persisted' => true,
224			'idIsSafe' => true,
225		] );
226		$session = $manager->getSessionForRequest( $request );
227		$this->assertInstanceOf( Session::class, $session );
228		$this->assertSame( $id2, $session->getId() );
229		$this->assertFalse( $request->unpersist1 );
230		$this->assertFalse( $request->unpersist2 );
231
232		$request->info1 = new SessionInfo( SessionInfo::MIN_PRIORITY + 2, [
233			'provider' => $provider1,
234			'id' => ( $id1 = $manager->generateSessionId() ),
235			'persisted' => true,
236			'idIsSafe' => true,
237		] );
238		$request->info2 = new SessionInfo( SessionInfo::MIN_PRIORITY + 1, [
239			'provider' => $provider2,
240			'id' => ( $id2 = $manager->generateSessionId() ),
241			'persisted' => true,
242			'idIsSafe' => true,
243		] );
244		$session = $manager->getSessionForRequest( $request );
245		$this->assertInstanceOf( Session::class, $session );
246		$this->assertSame( $id1, $session->getId() );
247		$this->assertFalse( $request->unpersist1 );
248		$this->assertFalse( $request->unpersist2 );
249
250		// Tied priorities
251		$request->info1 = new SessionInfo( SessionInfo::MAX_PRIORITY, [
252			'provider' => $provider1,
253			'id' => ( $id1 = $manager->generateSessionId() ),
254			'persisted' => true,
255			'userInfo' => UserInfo::newAnonymous(),
256			'idIsSafe' => true,
257		] );
258		$request->info2 = new SessionInfo( SessionInfo::MAX_PRIORITY, [
259			'provider' => $provider2,
260			'id' => ( $id2 = $manager->generateSessionId() ),
261			'persisted' => true,
262			'userInfo' => UserInfo::newAnonymous(),
263			'idIsSafe' => true,
264		] );
265		try {
266			$manager->getSessionForRequest( $request );
267			$this->fail( 'Expcected exception not thrown' );
268		} catch ( SessionOverflowException $ex ) {
269			$this->assertStringStartsWith(
270				'Multiple sessions for this request tied for top priority: ',
271				$ex->getMessage()
272			);
273			$this->assertCount( 2, $ex->getSessionInfos() );
274			$this->assertContains( $request->info1, $ex->getSessionInfos() );
275			$this->assertContains( $request->info2, $ex->getSessionInfos() );
276		}
277		$this->assertFalse( $request->unpersist1 );
278		$this->assertFalse( $request->unpersist2 );
279
280		// Bad provider
281		$request->info1 = new SessionInfo( SessionInfo::MAX_PRIORITY, [
282			'provider' => $provider2,
283			'id' => ( $id1 = $manager->generateSessionId() ),
284			'persisted' => true,
285			'idIsSafe' => true,
286		] );
287		$request->info2 = null;
288		try {
289			$manager->getSessionForRequest( $request );
290			$this->fail( 'Expcected exception not thrown' );
291		} catch ( \UnexpectedValueException $ex ) {
292			$this->assertSame(
293				'Provider1 returned session info for a different provider: ' . $request->info1,
294				$ex->getMessage()
295			);
296		}
297		$this->assertFalse( $request->unpersist1 );
298		$this->assertFalse( $request->unpersist2 );
299
300		// Unusable session info
301		$this->logger->setCollect( true );
302		$request->info1 = new SessionInfo( SessionInfo::MAX_PRIORITY, [
303			'provider' => $provider1,
304			'id' => ( $id1 = $manager->generateSessionId() ),
305			'persisted' => true,
306			'userInfo' => UserInfo::newFromName( 'UTSysop', false ),
307			'idIsSafe' => true,
308		] );
309		$request->info2 = new SessionInfo( SessionInfo::MIN_PRIORITY, [
310			'provider' => $provider2,
311			'id' => ( $id2 = $manager->generateSessionId() ),
312			'persisted' => true,
313			'idIsSafe' => true,
314		] );
315		$session = $manager->getSessionForRequest( $request );
316		$this->assertInstanceOf( Session::class, $session );
317		$this->assertSame( $id2, $session->getId() );
318		$this->logger->setCollect( false );
319		$this->assertTrue( $request->unpersist1 );
320		$this->assertFalse( $request->unpersist2 );
321		$request->unpersist1 = false;
322
323		$this->logger->setCollect( true );
324		$request->info1 = new SessionInfo( SessionInfo::MAX_PRIORITY, [
325			'provider' => $provider1,
326			'id' => ( $id1 = $manager->generateSessionId() ),
327			'persisted' => true,
328			'idIsSafe' => true,
329		] );
330		$request->info2 = new SessionInfo( SessionInfo::MAX_PRIORITY, [
331			'provider' => $provider2,
332			'id' => ( $id2 = $manager->generateSessionId() ),
333			'persisted' => true,
334			'userInfo' => UserInfo::newFromName( 'UTSysop', false ),
335			'idIsSafe' => true,
336		] );
337		$session = $manager->getSessionForRequest( $request );
338		$this->assertInstanceOf( Session::class, $session );
339		$this->assertSame( $id1, $session->getId() );
340		$this->logger->setCollect( false );
341		$this->assertFalse( $request->unpersist1 );
342		$this->assertTrue( $request->unpersist2 );
343		$request->unpersist2 = false;
344
345		// Unpersisted session ID
346		$request->info1 = new SessionInfo( SessionInfo::MAX_PRIORITY, [
347			'provider' => $provider1,
348			'id' => ( $id1 = $manager->generateSessionId() ),
349			'persisted' => false,
350			'userInfo' => UserInfo::newFromName( 'UTSysop', true ),
351			'idIsSafe' => true,
352		] );
353		$request->info2 = null;
354		$session = $manager->getSessionForRequest( $request );
355		$this->assertInstanceOf( Session::class, $session );
356		$this->assertSame( $id1, $session->getId() );
357		$this->assertTrue( $request->unpersist1 ); // The saving of the session does it
358		$this->assertFalse( $request->unpersist2 );
359		$session->persist();
360		$this->assertTrue( $session->isPersistent(), 'sanity check' );
361	}
362
363	public function testGetSessionById() {
364		$manager = $this->getManager();
365		try {
366			$manager->getSessionById( 'bad' );
367			$this->fail( 'Expected exception not thrown' );
368		} catch ( \InvalidArgumentException $ex ) {
369			$this->assertSame( 'Invalid session ID', $ex->getMessage() );
370		}
371
372		// Unknown session ID
373		$id = $manager->generateSessionId();
374		$session = $manager->getSessionById( $id, true );
375		$this->assertInstanceOf( Session::class, $session );
376		$this->assertSame( $id, $session->getId() );
377
378		$id = $manager->generateSessionId();
379		$this->assertNull( $manager->getSessionById( $id, false ) );
380
381		// Known but unloadable session ID
382		$this->logger->setCollect( true );
383		$id = $manager->generateSessionId();
384		$this->store->setSession( $id, [ 'metadata' => [
385			'userId' => User::idFromName( 'UTSysop' ),
386			'userToken' => 'bad',
387		] ] );
388
389		$this->assertNull( $manager->getSessionById( $id, true ) );
390		$this->assertNull( $manager->getSessionById( $id, false ) );
391		$this->logger->setCollect( false );
392
393		// Known session ID
394		$this->store->setSession( $id, [] );
395		$session = $manager->getSessionById( $id, false );
396		$this->assertInstanceOf( Session::class, $session );
397		$this->assertSame( $id, $session->getId() );
398
399		// Store isn't checked if the session is already loaded
400		$this->store->setSession( $id, [ 'metadata' => [
401			'userId' => User::idFromName( 'UTSysop' ),
402			'userToken' => 'bad',
403		] ] );
404		$session2 = $manager->getSessionById( $id, false );
405		$this->assertInstanceOf( Session::class, $session2 );
406		$this->assertSame( $id, $session2->getId() );
407		unset( $session, $session2 );
408		$this->logger->setCollect( true );
409		$this->assertNull( $manager->getSessionById( $id, true ) );
410		$this->logger->setCollect( false );
411
412		// Failure to create an empty session
413		$manager = $this->getManager();
414		$provider = $this->getMockBuilder( \DummySessionProvider::class )
415			->setMethods( [ 'provideSessionInfo', 'newSessionInfo', '__toString' ] )
416			->getMock();
417		$provider->expects( $this->any() )->method( 'provideSessionInfo' )
418			->will( $this->returnValue( null ) );
419		$provider->expects( $this->any() )->method( 'newSessionInfo' )
420			->will( $this->returnValue( null ) );
421		$provider->expects( $this->any() )->method( '__toString' )
422			->will( $this->returnValue( 'MockProvider' ) );
423		$this->config->set( 'SessionProviders', [
424			$this->objectCacheDef( $provider ),
425		] );
426		$this->logger->setCollect( true );
427		$this->assertNull( $manager->getSessionById( $id, true ) );
428		$this->logger->setCollect( false );
429		$this->assertSame( [
430			[ LogLevel::ERROR, 'Failed to create empty session: {exception}' ]
431		], $this->logger->getBuffer() );
432	}
433
434	public function testGetEmptySession() {
435		$manager = $this->getManager();
436		$pmanager = TestingAccessWrapper::newFromObject( $manager );
437		$request = new \FauxRequest();
438
439		$providerBuilder = $this->getMockBuilder( \DummySessionProvider::class )
440			->setMethods( [ 'provideSessionInfo', 'newSessionInfo', '__toString' ] );
441
442		$expectId = null;
443		$info1 = null;
444		$info2 = null;
445
446		$provider1 = $providerBuilder->getMock();
447		$provider1->expects( $this->any() )->method( 'provideSessionInfo' )
448			->will( $this->returnValue( null ) );
449		$provider1->expects( $this->any() )->method( 'newSessionInfo' )
450			->with( $this->callback( static function ( $id ) use ( &$expectId ) {
451				return $id === $expectId;
452			} ) )
453			->will( $this->returnCallback( static function () use ( &$info1 ) {
454				return $info1;
455			} ) );
456		$provider1->expects( $this->any() )->method( '__toString' )
457			->will( $this->returnValue( 'MockProvider1' ) );
458
459		$provider2 = $providerBuilder->getMock();
460		$provider2->expects( $this->any() )->method( 'provideSessionInfo' )
461			->will( $this->returnValue( null ) );
462		$provider2->expects( $this->any() )->method( 'newSessionInfo' )
463			->with( $this->callback( static function ( $id ) use ( &$expectId ) {
464				return $id === $expectId;
465			} ) )
466			->will( $this->returnCallback( static function () use ( &$info2 ) {
467				return $info2;
468			} ) );
469		$provider1->expects( $this->any() )->method( '__toString' )
470			->will( $this->returnValue( 'MockProvider2' ) );
471
472		$this->config->set( 'SessionProviders', [
473			$this->objectCacheDef( $provider1 ),
474			$this->objectCacheDef( $provider2 ),
475		] );
476
477		// No info
478		$expectId = null;
479		$info1 = null;
480		$info2 = null;
481		try {
482			$manager->getEmptySession();
483			$this->fail( 'Expected exception not thrown' );
484		} catch ( \UnexpectedValueException $ex ) {
485			$this->assertSame(
486				'No provider could provide an empty session!',
487				$ex->getMessage()
488			);
489		}
490
491		// Info
492		$expectId = null;
493		$info1 = new SessionInfo( SessionInfo::MIN_PRIORITY, [
494			'provider' => $provider1,
495			'id' => 'empty---------------------------',
496			'persisted' => true,
497			'idIsSafe' => true,
498		] );
499		$info2 = null;
500		$session = $manager->getEmptySession();
501		$this->assertInstanceOf( Session::class, $session );
502		$this->assertSame( 'empty---------------------------', $session->getId() );
503
504		// Info, explicitly
505		$expectId = 'expected------------------------';
506		$info1 = new SessionInfo( SessionInfo::MIN_PRIORITY, [
507			'provider' => $provider1,
508			'id' => $expectId,
509			'persisted' => true,
510			'idIsSafe' => true,
511		] );
512		$info2 = null;
513		$session = $pmanager->getEmptySessionInternal( null, $expectId );
514		$this->assertInstanceOf( Session::class, $session );
515		$this->assertSame( $expectId, $session->getId() );
516
517		// Wrong ID
518		$expectId = 'expected-----------------------2';
519		$info1 = new SessionInfo( SessionInfo::MIN_PRIORITY, [
520			'provider' => $provider1,
521			'id' => "un$expectId",
522			'persisted' => true,
523			'idIsSafe' => true,
524		] );
525		$info2 = null;
526		try {
527			$pmanager->getEmptySessionInternal( null, $expectId );
528			$this->fail( 'Expected exception not thrown' );
529		} catch ( \UnexpectedValueException $ex ) {
530			$this->assertSame(
531				'MockProvider1 returned empty session info with a wrong id: ' .
532					"un$expectId != $expectId",
533				$ex->getMessage()
534			);
535		}
536
537		// Unsafe ID
538		$expectId = 'expected-----------------------2';
539		$info1 = new SessionInfo( SessionInfo::MIN_PRIORITY, [
540			'provider' => $provider1,
541			'id' => $expectId,
542			'persisted' => true,
543		] );
544		$info2 = null;
545		try {
546			$pmanager->getEmptySessionInternal( null, $expectId );
547			$this->fail( 'Expected exception not thrown' );
548		} catch ( \UnexpectedValueException $ex ) {
549			$this->assertSame(
550				'MockProvider1 returned empty session info with id flagged unsafe',
551				$ex->getMessage()
552			);
553		}
554
555		// Wrong provider
556		$expectId = null;
557		$info1 = new SessionInfo( SessionInfo::MIN_PRIORITY, [
558			'provider' => $provider2,
559			'id' => 'empty---------------------------',
560			'persisted' => true,
561			'idIsSafe' => true,
562		] );
563		$info2 = null;
564		try {
565			$manager->getEmptySession();
566			$this->fail( 'Expected exception not thrown' );
567		} catch ( \UnexpectedValueException $ex ) {
568			$this->assertSame(
569				'MockProvider1 returned an empty session info for a different provider: ' . $info1,
570				$ex->getMessage()
571			);
572		}
573
574		// Highest priority wins
575		$expectId = null;
576		$info1 = new SessionInfo( SessionInfo::MIN_PRIORITY + 1, [
577			'provider' => $provider1,
578			'id' => 'empty1--------------------------',
579			'persisted' => true,
580			'idIsSafe' => true,
581		] );
582		$info2 = new SessionInfo( SessionInfo::MIN_PRIORITY, [
583			'provider' => $provider2,
584			'id' => 'empty2--------------------------',
585			'persisted' => true,
586			'idIsSafe' => true,
587		] );
588		$session = $manager->getEmptySession();
589		$this->assertInstanceOf( Session::class, $session );
590		$this->assertSame( 'empty1--------------------------', $session->getId() );
591
592		$expectId = null;
593		$info1 = new SessionInfo( SessionInfo::MIN_PRIORITY + 1, [
594			'provider' => $provider1,
595			'id' => 'empty1--------------------------',
596			'persisted' => true,
597			'idIsSafe' => true,
598		] );
599		$info2 = new SessionInfo( SessionInfo::MIN_PRIORITY + 2, [
600			'provider' => $provider2,
601			'id' => 'empty2--------------------------',
602			'persisted' => true,
603			'idIsSafe' => true,
604		] );
605		$session = $manager->getEmptySession();
606		$this->assertInstanceOf( Session::class, $session );
607		$this->assertSame( 'empty2--------------------------', $session->getId() );
608
609		// Tied priorities throw an exception
610		$expectId = null;
611		$info1 = new SessionInfo( SessionInfo::MIN_PRIORITY, [
612			'provider' => $provider1,
613			'id' => 'empty1--------------------------',
614			'persisted' => true,
615			'userInfo' => UserInfo::newAnonymous(),
616			'idIsSafe' => true,
617		] );
618		$info2 = new SessionInfo( SessionInfo::MIN_PRIORITY, [
619			'provider' => $provider2,
620			'id' => 'empty2--------------------------',
621			'persisted' => true,
622			'userInfo' => UserInfo::newAnonymous(),
623			'idIsSafe' => true,
624		] );
625		try {
626			$manager->getEmptySession();
627			$this->fail( 'Expected exception not thrown' );
628		} catch ( \UnexpectedValueException $ex ) {
629			$this->assertStringStartsWith(
630				'Multiple empty sessions tied for top priority: ',
631				$ex->getMessage()
632			);
633		}
634
635		// Bad id
636		try {
637			$pmanager->getEmptySessionInternal( null, 'bad' );
638			$this->fail( 'Expected exception not thrown' );
639		} catch ( \InvalidArgumentException $ex ) {
640			$this->assertSame( 'Invalid session ID', $ex->getMessage() );
641		}
642
643		// Session already exists
644		$expectId = 'expected-----------------------3';
645		$this->store->setSessionMeta( $expectId, [
646			'provider' => 'MockProvider2',
647			'userId' => 0,
648			'userName' => null,
649			'userToken' => null,
650		] );
651		try {
652			$pmanager->getEmptySessionInternal( null, $expectId );
653			$this->fail( 'Expected exception not thrown' );
654		} catch ( \InvalidArgumentException $ex ) {
655			$this->assertSame( 'Session ID already exists', $ex->getMessage() );
656		}
657	}
658
659	public function testInvalidateSessionsForUser() {
660		$user = User::newFromName( 'UTSysop' );
661		$manager = $this->getManager();
662
663		$providerBuilder = $this->getMockBuilder( \DummySessionProvider::class )
664			->setMethods( [ 'invalidateSessionsForUser', '__toString' ] );
665
666		$provider1 = $providerBuilder->getMock();
667		$provider1->expects( $this->once() )->method( 'invalidateSessionsForUser' )
668			->with( $this->identicalTo( $user ) );
669		$provider1->expects( $this->any() )->method( '__toString' )
670			->will( $this->returnValue( 'MockProvider1' ) );
671
672		$provider2 = $providerBuilder->getMock();
673		$provider2->expects( $this->once() )->method( 'invalidateSessionsForUser' )
674			->with( $this->identicalTo( $user ) );
675		$provider2->expects( $this->any() )->method( '__toString' )
676			->will( $this->returnValue( 'MockProvider2' ) );
677
678		$this->config->set( 'SessionProviders', [
679			$this->objectCacheDef( $provider1 ),
680			$this->objectCacheDef( $provider2 ),
681		] );
682
683		$oldToken = $user->getToken( true );
684		$manager->invalidateSessionsForUser( $user );
685		$this->assertNotEquals( $oldToken, $user->getToken() );
686	}
687
688	public function testGetVaryHeaders() {
689		$manager = $this->getManager();
690
691		$providerBuilder = $this->getMockBuilder( \DummySessionProvider::class )
692			->setMethods( [ 'getVaryHeaders', '__toString' ] );
693
694		$provider1 = $providerBuilder->getMock();
695		$provider1->expects( $this->once() )->method( 'getVaryHeaders' )
696			->will( $this->returnValue( [
697				'Foo' => null,
698				'Bar' => [ 'X', 'Bar1' ],
699				'Quux' => null,
700			] ) );
701		$provider1->expects( $this->any() )->method( '__toString' )
702			->will( $this->returnValue( 'MockProvider1' ) );
703
704		$provider2 = $providerBuilder->getMock();
705		$provider2->expects( $this->once() )->method( 'getVaryHeaders' )
706			->will( $this->returnValue( [
707				'Baz' => null,
708				'Bar' => [ 'X', 'Bar2' ],
709				'Quux' => [ 'Quux' ],
710			] ) );
711		$provider2->expects( $this->any() )->method( '__toString' )
712			->will( $this->returnValue( 'MockProvider2' ) );
713
714		$this->config->set( 'SessionProviders', [
715			$this->objectCacheDef( $provider1 ),
716			$this->objectCacheDef( $provider2 ),
717		] );
718
719		$expect = [
720			'Foo' => null,
721			'Bar' => null,
722			'Quux' => null,
723			'Baz' => null,
724		];
725
726		$this->assertEquals( $expect, $manager->getVaryHeaders() );
727
728		// Again, to ensure it's cached
729		$this->assertEquals( $expect, $manager->getVaryHeaders() );
730	}
731
732	public function testGetVaryCookies() {
733		$manager = $this->getManager();
734
735		$providerBuilder = $this->getMockBuilder( \DummySessionProvider::class )
736			->setMethods( [ 'getVaryCookies', '__toString' ] );
737
738		$provider1 = $providerBuilder->getMock();
739		$provider1->expects( $this->once() )->method( 'getVaryCookies' )
740			->will( $this->returnValue( [ 'Foo', 'Bar' ] ) );
741		$provider1->expects( $this->any() )->method( '__toString' )
742			->will( $this->returnValue( 'MockProvider1' ) );
743
744		$provider2 = $providerBuilder->getMock();
745		$provider2->expects( $this->once() )->method( 'getVaryCookies' )
746			->will( $this->returnValue( [ 'Foo', 'Baz' ] ) );
747		$provider2->expects( $this->any() )->method( '__toString' )
748			->will( $this->returnValue( 'MockProvider2' ) );
749
750		$this->config->set( 'SessionProviders', [
751			$this->objectCacheDef( $provider1 ),
752			$this->objectCacheDef( $provider2 ),
753		] );
754
755		$expect = [ 'Foo', 'Bar', 'Baz' ];
756
757		$this->assertEquals( $expect, $manager->getVaryCookies() );
758
759		// Again, to ensure it's cached
760		$this->assertEquals( $expect, $manager->getVaryCookies() );
761	}
762
763	public function testGetProviders() {
764		$realManager = $this->getManager();
765		$manager = TestingAccessWrapper::newFromObject( $realManager );
766
767		$this->config->set( 'SessionProviders', [
768			[ 'class' => \DummySessionProvider::class ],
769		] );
770		$providers = $manager->getProviders();
771		$this->assertArrayHasKey( 'DummySessionProvider', $providers );
772		$provider = TestingAccessWrapper::newFromObject( $providers['DummySessionProvider'] );
773		$this->assertSame( $manager->logger, $provider->logger );
774		$this->assertSame( $manager->config, $provider->config );
775		$this->assertSame( $realManager, $provider->getManager() );
776
777		$this->config->set( 'SessionProviders', [
778			[ 'class' => \DummySessionProvider::class ],
779			[ 'class' => \DummySessionProvider::class ],
780		] );
781		$manager->sessionProviders = null;
782		try {
783			$manager->getProviders();
784			$this->fail( 'Expected exception not thrown' );
785		} catch ( \UnexpectedValueException $ex ) {
786			$this->assertSame(
787				'Duplicate provider name "DummySessionProvider"',
788				$ex->getMessage()
789			);
790		}
791	}
792
793	public function testShutdown() {
794		$manager = TestingAccessWrapper::newFromObject( $this->getManager() );
795		$manager->setLogger( new \Psr\Log\NullLogger() );
796
797		$mock = $this->getMockBuilder( stdClass::class )
798			->setMethods( [ 'shutdown' ] )->getMock();
799		$mock->expects( $this->once() )->method( 'shutdown' );
800
801		$manager->allSessionBackends = [ $mock ];
802		$manager->shutdown();
803	}
804
805	public function testGetSessionFromInfo() {
806		$manager = TestingAccessWrapper::newFromObject( $this->getManager() );
807		$request = new \FauxRequest();
808
809		$id = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
810
811		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
812			'provider' => $manager->getProvider( 'DummySessionProvider' ),
813			'id' => $id,
814			'persisted' => true,
815			'userInfo' => UserInfo::newFromName( 'UTSysop', true ),
816			'idIsSafe' => true,
817		] );
818		TestingAccessWrapper::newFromObject( $info )->idIsSafe = true;
819		$session1 = TestingAccessWrapper::newFromObject(
820			$manager->getSessionFromInfo( $info, $request )
821		);
822		$session2 = TestingAccessWrapper::newFromObject(
823			$manager->getSessionFromInfo( $info, $request )
824		);
825
826		$this->assertSame( $session1->backend, $session2->backend );
827		$this->assertNotEquals( $session1->index, $session2->index );
828		$this->assertSame( $session1->getSessionId(), $session2->getSessionId() );
829		$this->assertSame( $id, $session1->getId() );
830
831		TestingAccessWrapper::newFromObject( $info )->idIsSafe = false;
832		$session3 = $manager->getSessionFromInfo( $info, $request );
833		$this->assertNotSame( $id, $session3->getId() );
834	}
835
836	public function testBackendRegistration() {
837		$manager = $this->getManager();
838
839		$session = $manager->getSessionForRequest( new \FauxRequest );
840		$backend = TestingAccessWrapper::newFromObject( $session )->backend;
841		$sessionId = $session->getSessionId();
842		$id = (string)$sessionId;
843
844		$this->assertSame( $sessionId, $manager->getSessionById( $id, true )->getSessionId() );
845
846		$manager->changeBackendId( $backend );
847		$this->assertSame( $sessionId, $session->getSessionId() );
848		$this->assertNotEquals( $id, (string)$sessionId );
849		$id = (string)$sessionId;
850
851		$this->assertSame( $sessionId, $manager->getSessionById( $id, true )->getSessionId() );
852
853		// Destruction of the session here causes the backend to be deregistered
854		$session = null;
855
856		try {
857			$manager->changeBackendId( $backend );
858			$this->fail( 'Expected exception not thrown' );
859		} catch ( \InvalidArgumentException $ex ) {
860			$this->assertSame(
861				'Backend was not registered with this SessionManager', $ex->getMessage()
862			);
863		}
864
865		try {
866			$manager->deregisterSessionBackend( $backend );
867			$this->fail( 'Expected exception not thrown' );
868		} catch ( \InvalidArgumentException $ex ) {
869			$this->assertSame(
870				'Backend was not registered with this SessionManager', $ex->getMessage()
871			);
872		}
873
874		$session = $manager->getSessionById( $id, true );
875		$this->assertSame( $sessionId, $session->getSessionId() );
876	}
877
878	public function testGenerateSessionId() {
879		$manager = $this->getManager();
880
881		$id = $manager->generateSessionId();
882		$this->assertTrue( SessionManager::validateSessionId( $id ), "Generated ID: $id" );
883	}
884
885	public function testPreventSessionsForUser() {
886		$manager = $this->getManager();
887
888		$providerBuilder = $this->getMockBuilder( \DummySessionProvider::class )
889			->setMethods( [ 'preventSessionsForUser', '__toString' ] );
890
891		$provider1 = $providerBuilder->getMock();
892		$provider1->expects( $this->once() )->method( 'preventSessionsForUser' )
893			->with( $this->equalTo( 'UTSysop' ) );
894		$provider1->expects( $this->any() )->method( '__toString' )
895			->will( $this->returnValue( 'MockProvider1' ) );
896
897		$this->config->set( 'SessionProviders', [
898			$this->objectCacheDef( $provider1 ),
899		] );
900
901		$this->assertFalse( $manager->isUserSessionPrevented( 'UTSysop' ) );
902		$manager->preventSessionsForUser( 'UTSysop' );
903		$this->assertTrue( $manager->isUserSessionPrevented( 'UTSysop' ) );
904	}
905
906	public function testLoadSessionInfoFromStore() {
907		$manager = $this->getManager();
908		$logger = new \TestLogger( true );
909		$manager->setLogger( $logger );
910		$request = new \FauxRequest();
911
912		// TestingAccessWrapper can't handle methods with reference arguments, sigh.
913		$rClass = new \ReflectionClass( $manager );
914		$rMethod = $rClass->getMethod( 'loadSessionInfoFromStore' );
915		$rMethod->setAccessible( true );
916		$loadSessionInfoFromStore = static function ( &$info ) use ( $rMethod, $manager, $request ) {
917			return $rMethod->invokeArgs( $manager, [ &$info, $request ] );
918		};
919
920		$userInfo = UserInfo::newFromName( 'UTSysop', true );
921		$unverifiedUserInfo = UserInfo::newFromName( 'UTSysop', false );
922
923		$id = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
924		$metadata = [
925			'userId' => $userInfo->getId(),
926			'userName' => $userInfo->getName(),
927			'userToken' => $userInfo->getToken( true ),
928			'provider' => 'Mock',
929		];
930
931		$builder = $this->getMockBuilder( SessionProvider::class )
932			->setMethods( [ '__toString', 'mergeMetadata', 'refreshSessionInfo' ] );
933
934		$provider = $builder->getMockForAbstractClass();
935		$provider->setManager( $manager );
936		$provider->expects( $this->any() )->method( 'persistsSessionId' )
937			->will( $this->returnValue( true ) );
938		$provider->expects( $this->any() )->method( 'canChangeUser' )
939			->will( $this->returnValue( true ) );
940		$provider->expects( $this->any() )->method( 'refreshSessionInfo' )
941			->will( $this->returnValue( true ) );
942		$provider->expects( $this->any() )->method( '__toString' )
943			->will( $this->returnValue( 'Mock' ) );
944		$provider->expects( $this->any() )->method( 'mergeMetadata' )
945			->will( $this->returnCallback( static function ( $a, $b ) {
946				if ( $b === [ 'Throw' ] ) {
947					throw new MetadataMergeException( 'no merge!' );
948				}
949				return [ 'Merged' ];
950			} ) );
951
952		$provider2 = $builder->getMockForAbstractClass();
953		$provider2->setManager( $manager );
954		$provider2->expects( $this->any() )->method( 'persistsSessionId' )
955			->will( $this->returnValue( false ) );
956		$provider2->expects( $this->any() )->method( 'canChangeUser' )
957			->will( $this->returnValue( false ) );
958		$provider2->expects( $this->any() )->method( '__toString' )
959			->will( $this->returnValue( 'Mock2' ) );
960		$provider2->expects( $this->any() )->method( 'refreshSessionInfo' )
961			->will( $this->returnCallback( static function ( $info, $request, &$metadata ) {
962				$metadata['changed'] = true;
963				return true;
964			} ) );
965
966		$provider3 = $builder->getMockForAbstractClass();
967		$provider3->setManager( $manager );
968		$provider3->expects( $this->any() )->method( 'persistsSessionId' )
969			->will( $this->returnValue( true ) );
970		$provider3->expects( $this->any() )->method( 'canChangeUser' )
971			->will( $this->returnValue( true ) );
972		$provider3->expects( $this->once() )->method( 'refreshSessionInfo' )
973			->will( $this->returnValue( false ) );
974		$provider3->expects( $this->any() )->method( '__toString' )
975			->will( $this->returnValue( 'Mock3' ) );
976
977		TestingAccessWrapper::newFromObject( $manager )->sessionProviders = [
978			(string)$provider => $provider,
979			(string)$provider2 => $provider2,
980			(string)$provider3 => $provider3,
981		];
982
983		// No metadata, basic usage
984		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
985			'provider' => $provider,
986			'id' => $id,
987			'userInfo' => $userInfo
988		] );
989		$this->assertFalse( $info->isIdSafe(), 'sanity check' );
990		$this->assertTrue( $loadSessionInfoFromStore( $info ) );
991		$this->assertFalse( $info->isIdSafe() );
992		$this->assertSame( [], $logger->getBuffer() );
993
994		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
995			'provider' => $provider,
996			'userInfo' => $userInfo
997		] );
998		$this->assertTrue( $info->isIdSafe(), 'sanity check' );
999		$this->assertTrue( $loadSessionInfoFromStore( $info ) );
1000		$this->assertTrue( $info->isIdSafe() );
1001		$this->assertSame( [], $logger->getBuffer() );
1002
1003		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
1004			'provider' => $provider2,
1005			'id' => $id,
1006			'userInfo' => $userInfo
1007		] );
1008		$this->assertFalse( $info->isIdSafe(), 'sanity check' );
1009		$this->assertTrue( $loadSessionInfoFromStore( $info ) );
1010		$this->assertTrue( $info->isIdSafe() );
1011		$this->assertSame( [], $logger->getBuffer() );
1012
1013		// Unverified user, no metadata
1014		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
1015			'provider' => $provider,
1016			'id' => $id,
1017			'userInfo' => $unverifiedUserInfo
1018		] );
1019		$this->assertSame( $unverifiedUserInfo, $info->getUserInfo() );
1020		$this->assertFalse( $loadSessionInfoFromStore( $info ) );
1021		$this->assertSame( [
1022			[
1023				LogLevel::INFO,
1024				'Session "{session}": Unverified user provided and no metadata to auth it',
1025			]
1026		], $logger->getBuffer() );
1027		$logger->clearBuffer();
1028
1029		// No metadata, missing data
1030		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
1031			'id' => $id,
1032			'userInfo' => $userInfo
1033		] );
1034		$this->assertFalse( $loadSessionInfoFromStore( $info ) );
1035		$this->assertSame( [
1036			[ LogLevel::WARNING, 'Session "{session}": Null provider and no metadata' ],
1037		], $logger->getBuffer() );
1038		$logger->clearBuffer();
1039
1040		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
1041			'provider' => $provider,
1042			'id' => $id,
1043		] );
1044		$this->assertFalse( $info->isIdSafe(), 'sanity check' );
1045		$this->assertTrue( $loadSessionInfoFromStore( $info ) );
1046		$this->assertInstanceOf( UserInfo::class, $info->getUserInfo() );
1047		$this->assertTrue( $info->getUserInfo()->isVerified() );
1048		$this->assertTrue( $info->getUserInfo()->isAnon() );
1049		$this->assertFalse( $info->isIdSafe() );
1050		$this->assertSame( [], $logger->getBuffer() );
1051
1052		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
1053			'provider' => $provider2,
1054			'id' => $id,
1055		] );
1056		$this->assertFalse( $info->isIdSafe(), 'sanity check' );
1057		$this->assertFalse( $loadSessionInfoFromStore( $info ) );
1058		$this->assertSame( [
1059			[ LogLevel::INFO, 'Session "{session}": No user provided and provider cannot set user' ]
1060		], $logger->getBuffer() );
1061		$logger->clearBuffer();
1062
1063		// Incomplete/bad metadata
1064		$this->store->setRawSession( $id, true );
1065		$this->assertFalse( $loadSessionInfoFromStore( $info ) );
1066		$this->assertSame( [
1067			[ LogLevel::WARNING, 'Session "{session}": Bad data' ],
1068		], $logger->getBuffer() );
1069		$logger->clearBuffer();
1070
1071		$this->store->setRawSession( $id, [ 'data' => [] ] );
1072		$this->assertFalse( $loadSessionInfoFromStore( $info ) );
1073		$this->assertSame( [
1074			[ LogLevel::WARNING, 'Session "{session}": Bad data structure' ],
1075		], $logger->getBuffer() );
1076		$logger->clearBuffer();
1077
1078		$this->store->deleteSession( $id );
1079		$this->store->setRawSession( $id, [ 'metadata' => $metadata ] );
1080		$this->assertFalse( $loadSessionInfoFromStore( $info ) );
1081		$this->assertSame( [
1082			[ LogLevel::WARNING, 'Session "{session}": Bad data structure' ],
1083		], $logger->getBuffer() );
1084		$logger->clearBuffer();
1085
1086		$this->store->setRawSession( $id, [ 'metadata' => $metadata, 'data' => true ] );
1087		$this->assertFalse( $loadSessionInfoFromStore( $info ) );
1088		$this->assertSame( [
1089			[ LogLevel::WARNING, 'Session "{session}": Bad data structure' ],
1090		], $logger->getBuffer() );
1091		$logger->clearBuffer();
1092
1093		$this->store->setRawSession( $id, [ 'metadata' => true, 'data' => [] ] );
1094		$this->assertFalse( $loadSessionInfoFromStore( $info ) );
1095		$this->assertSame( [
1096			[ LogLevel::WARNING, 'Session "{session}": Bad data structure' ],
1097		], $logger->getBuffer() );
1098		$logger->clearBuffer();
1099
1100		foreach ( $metadata as $key => $dummy ) {
1101			$tmp = $metadata;
1102			unset( $tmp[$key] );
1103			$this->store->setRawSession( $id, [ 'metadata' => $tmp, 'data' => [] ] );
1104			$this->assertFalse( $loadSessionInfoFromStore( $info ) );
1105			$this->assertSame( [
1106				[ LogLevel::WARNING, 'Session "{session}": Bad metadata' ],
1107			], $logger->getBuffer() );
1108			$logger->clearBuffer();
1109		}
1110
1111		// Basic usage with metadata
1112		$this->store->setRawSession( $id, [ 'metadata' => $metadata, 'data' => [] ] );
1113		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
1114			'provider' => $provider,
1115			'id' => $id,
1116			'userInfo' => $userInfo
1117		] );
1118		$this->assertFalse( $info->isIdSafe(), 'sanity check' );
1119		$this->assertTrue( $loadSessionInfoFromStore( $info ) );
1120		$this->assertTrue( $info->isIdSafe() );
1121		$this->assertSame( [], $logger->getBuffer() );
1122
1123		// Mismatched provider
1124		$this->store->setSessionMeta( $id, [ 'provider' => 'Bad' ] + $metadata );
1125		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
1126			'provider' => $provider,
1127			'id' => $id,
1128			'userInfo' => $userInfo
1129		] );
1130		$this->assertFalse( $loadSessionInfoFromStore( $info ) );
1131		$this->assertSame( [
1132			[ LogLevel::WARNING, 'Session "{session}": Wrong provider Bad !== Mock' ],
1133		], $logger->getBuffer() );
1134		$logger->clearBuffer();
1135
1136		// Unknown provider
1137		$this->store->setSessionMeta( $id, [ 'provider' => 'Bad' ] + $metadata );
1138		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
1139			'id' => $id,
1140			'userInfo' => $userInfo
1141		] );
1142		$this->assertFalse( $loadSessionInfoFromStore( $info ) );
1143		$this->assertSame( [
1144			[ LogLevel::WARNING, 'Session "{session}": Unknown provider Bad' ],
1145		], $logger->getBuffer() );
1146		$logger->clearBuffer();
1147
1148		// Fill in provider
1149		$this->store->setSessionMeta( $id, $metadata );
1150		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
1151			'id' => $id,
1152			'userInfo' => $userInfo
1153		] );
1154		$this->assertFalse( $info->isIdSafe(), 'sanity check' );
1155		$this->assertTrue( $loadSessionInfoFromStore( $info ) );
1156		$this->assertTrue( $info->isIdSafe() );
1157		$this->assertSame( [], $logger->getBuffer() );
1158
1159		// Bad user metadata
1160		$this->store->setSessionMeta( $id, [ 'userId' => -1, 'userToken' => null ] + $metadata );
1161		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
1162			'provider' => $provider,
1163			'id' => $id,
1164		] );
1165		$this->assertFalse( $loadSessionInfoFromStore( $info ) );
1166		$this->assertSame( [
1167			[ LogLevel::ERROR, 'Session "{session}": {exception}' ],
1168		], $logger->getBuffer() );
1169		$logger->clearBuffer();
1170
1171		$this->store->setSessionMeta(
1172			$id, [ 'userId' => 0, 'userName' => '<X>', 'userToken' => null ] + $metadata
1173		);
1174		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
1175			'provider' => $provider,
1176			'id' => $id,
1177		] );
1178		$this->assertFalse( $loadSessionInfoFromStore( $info ) );
1179		$this->assertSame( [
1180			[ LogLevel::ERROR, 'Session "{session}": {exception}', ],
1181		], $logger->getBuffer() );
1182		$logger->clearBuffer();
1183
1184		// Mismatched user by ID
1185		$this->store->setSessionMeta(
1186			$id, [ 'userId' => $userInfo->getId() + 1, 'userToken' => null ] + $metadata
1187		);
1188		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
1189			'provider' => $provider,
1190			'id' => $id,
1191			'userInfo' => $userInfo
1192		] );
1193		$this->assertFalse( $loadSessionInfoFromStore( $info ) );
1194		$this->assertSame( [
1195			[ LogLevel::WARNING, 'Session "{session}": User ID mismatch, {uid_a} !== {uid_b}' ],
1196		], $logger->getBuffer() );
1197		$logger->clearBuffer();
1198
1199		// Mismatched user by name
1200		$this->store->setSessionMeta(
1201			$id, [ 'userId' => 0, 'userName' => 'X', 'userToken' => null ] + $metadata
1202		);
1203		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
1204			'provider' => $provider,
1205			'id' => $id,
1206			'userInfo' => $userInfo
1207		] );
1208		$this->assertFalse( $loadSessionInfoFromStore( $info ) );
1209		$this->assertSame( [
1210			[ LogLevel::WARNING, 'Session "{session}": User name mismatch, {uname_a} !== {uname_b}' ],
1211		], $logger->getBuffer() );
1212		$logger->clearBuffer();
1213
1214		// ID matches, name doesn't
1215		$this->store->setSessionMeta(
1216			$id, [ 'userId' => $userInfo->getId(), 'userName' => 'X', 'userToken' => null ] + $metadata
1217		);
1218		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
1219			'provider' => $provider,
1220			'id' => $id,
1221			'userInfo' => $userInfo
1222		] );
1223		$this->assertFalse( $loadSessionInfoFromStore( $info ) );
1224		$this->assertSame( [
1225			[
1226				LogLevel::WARNING,
1227				'Session "{session}": User ID matched but name didn\'t (rename?), {uname_a} !== {uname_b}'
1228			],
1229		], $logger->getBuffer() );
1230		$logger->clearBuffer();
1231
1232		// Mismatched anon user
1233		$this->store->setSessionMeta(
1234			$id, [ 'userId' => 0, 'userName' => null, 'userToken' => null ] + $metadata
1235		);
1236		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
1237			'provider' => $provider,
1238			'id' => $id,
1239			'userInfo' => $userInfo
1240		] );
1241		$this->assertFalse( $loadSessionInfoFromStore( $info ) );
1242		$this->assertSame( [
1243			[
1244				LogLevel::WARNING,
1245				'Session "{session}": Metadata has an anonymous user, ' .
1246				'but a non-anon user was provided',
1247			],
1248		], $logger->getBuffer() );
1249		$logger->clearBuffer();
1250
1251		// Lookup user by ID
1252		$this->store->setSessionMeta( $id, [ 'userToken' => null ] + $metadata );
1253		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
1254			'provider' => $provider,
1255			'id' => $id,
1256		] );
1257		$this->assertFalse( $info->isIdSafe(), 'sanity check' );
1258		$this->assertTrue( $loadSessionInfoFromStore( $info ) );
1259		$this->assertSame( $userInfo->getId(), $info->getUserInfo()->getId() );
1260		$this->assertTrue( $info->isIdSafe() );
1261		$this->assertSame( [], $logger->getBuffer() );
1262
1263		// Lookup user by name
1264		$this->store->setSessionMeta(
1265			$id, [ 'userId' => 0, 'userName' => 'UTSysop', 'userToken' => null ] + $metadata
1266		);
1267		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
1268			'provider' => $provider,
1269			'id' => $id,
1270		] );
1271		$this->assertFalse( $info->isIdSafe(), 'sanity check' );
1272		$this->assertTrue( $loadSessionInfoFromStore( $info ) );
1273		$this->assertSame( $userInfo->getId(), $info->getUserInfo()->getId() );
1274		$this->assertTrue( $info->isIdSafe() );
1275		$this->assertSame( [], $logger->getBuffer() );
1276
1277		// Lookup anonymous user
1278		$this->store->setSessionMeta(
1279			$id, [ 'userId' => 0, 'userName' => null, 'userToken' => null ] + $metadata
1280		);
1281		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
1282			'provider' => $provider,
1283			'id' => $id,
1284		] );
1285		$this->assertFalse( $info->isIdSafe(), 'sanity check' );
1286		$this->assertTrue( $loadSessionInfoFromStore( $info ) );
1287		$this->assertTrue( $info->getUserInfo()->isAnon() );
1288		$this->assertTrue( $info->isIdSafe() );
1289		$this->assertSame( [], $logger->getBuffer() );
1290
1291		// Unverified user with metadata
1292		$this->store->setSessionMeta( $id, $metadata );
1293		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
1294			'provider' => $provider,
1295			'id' => $id,
1296			'userInfo' => $unverifiedUserInfo
1297		] );
1298		$this->assertFalse( $info->isIdSafe(), 'sanity check' );
1299		$this->assertTrue( $loadSessionInfoFromStore( $info ) );
1300		$this->assertTrue( $info->getUserInfo()->isVerified() );
1301		$this->assertSame( $unverifiedUserInfo->getId(), $info->getUserInfo()->getId() );
1302		$this->assertSame( $unverifiedUserInfo->getName(), $info->getUserInfo()->getName() );
1303		$this->assertTrue( $info->isIdSafe() );
1304		$this->assertSame( [], $logger->getBuffer() );
1305
1306		// Unverified user with metadata
1307		$this->store->setSessionMeta( $id, $metadata );
1308		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
1309			'provider' => $provider,
1310			'id' => $id,
1311			'userInfo' => $unverifiedUserInfo
1312		] );
1313		$this->assertFalse( $info->isIdSafe(), 'sanity check' );
1314		$this->assertTrue( $loadSessionInfoFromStore( $info ) );
1315		$this->assertTrue( $info->getUserInfo()->isVerified() );
1316		$this->assertSame( $unverifiedUserInfo->getId(), $info->getUserInfo()->getId() );
1317		$this->assertSame( $unverifiedUserInfo->getName(), $info->getUserInfo()->getName() );
1318		$this->assertTrue( $info->isIdSafe() );
1319		$this->assertSame( [], $logger->getBuffer() );
1320
1321		// Wrong token
1322		$this->store->setSessionMeta( $id, [ 'userToken' => 'Bad' ] + $metadata );
1323		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
1324			'provider' => $provider,
1325			'id' => $id,
1326			'userInfo' => $userInfo
1327		] );
1328		$this->assertFalse( $loadSessionInfoFromStore( $info ) );
1329		$this->assertSame( [
1330			[ LogLevel::WARNING, 'Session "{session}": User token mismatch' ],
1331		], $logger->getBuffer() );
1332		$logger->clearBuffer();
1333
1334		// Provider metadata
1335		$this->store->setSessionMeta( $id, [ 'provider' => 'Mock2' ] + $metadata );
1336		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
1337			'provider' => $provider2,
1338			'id' => $id,
1339			'userInfo' => $userInfo,
1340			'metadata' => [ 'Info' ],
1341		] );
1342		$this->assertTrue( $loadSessionInfoFromStore( $info ) );
1343		$this->assertSame( [ 'Info', 'changed' => true ], $info->getProviderMetadata() );
1344		$this->assertSame( [], $logger->getBuffer() );
1345
1346		$this->store->setSessionMeta( $id, [ 'providerMetadata' => [ 'Saved' ] ] + $metadata );
1347		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
1348			'provider' => $provider,
1349			'id' => $id,
1350			'userInfo' => $userInfo,
1351		] );
1352		$this->assertTrue( $loadSessionInfoFromStore( $info ) );
1353		$this->assertSame( [ 'Saved' ], $info->getProviderMetadata() );
1354		$this->assertSame( [], $logger->getBuffer() );
1355
1356		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
1357			'provider' => $provider,
1358			'id' => $id,
1359			'userInfo' => $userInfo,
1360			'metadata' => [ 'Info' ],
1361		] );
1362		$this->assertTrue( $loadSessionInfoFromStore( $info ) );
1363		$this->assertSame( [ 'Merged' ], $info->getProviderMetadata() );
1364		$this->assertSame( [], $logger->getBuffer() );
1365
1366		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
1367			'provider' => $provider,
1368			'id' => $id,
1369			'userInfo' => $userInfo,
1370			'metadata' => [ 'Throw' ],
1371		] );
1372		$this->assertFalse( $loadSessionInfoFromStore( $info ) );
1373		$this->assertSame( [
1374			[
1375				LogLevel::WARNING,
1376				'Session "{session}": Metadata merge failed: {exception}',
1377			],
1378		], $logger->getBuffer() );
1379		$logger->clearBuffer();
1380
1381		// Remember from session
1382		$this->store->setSessionMeta( $id, $metadata );
1383		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
1384			'provider' => $provider,
1385			'id' => $id,
1386		] );
1387		$this->assertTrue( $loadSessionInfoFromStore( $info ) );
1388		$this->assertFalse( $info->wasRemembered() );
1389		$this->assertSame( [], $logger->getBuffer() );
1390
1391		$this->store->setSessionMeta( $id, [ 'remember' => true ] + $metadata );
1392		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
1393			'provider' => $provider,
1394			'id' => $id,
1395		] );
1396		$this->assertTrue( $loadSessionInfoFromStore( $info ) );
1397		$this->assertTrue( $info->wasRemembered() );
1398		$this->assertSame( [], $logger->getBuffer() );
1399
1400		$this->store->setSessionMeta( $id, [ 'remember' => false ] + $metadata );
1401		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
1402			'provider' => $provider,
1403			'id' => $id,
1404			'userInfo' => $userInfo
1405		] );
1406		$this->assertTrue( $loadSessionInfoFromStore( $info ) );
1407		$this->assertTrue( $info->wasRemembered() );
1408		$this->assertSame( [], $logger->getBuffer() );
1409
1410		// forceHTTPS from session
1411		$this->store->setSessionMeta( $id, $metadata );
1412		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
1413			'provider' => $provider,
1414			'id' => $id,
1415			'userInfo' => $userInfo
1416		] );
1417		$this->assertTrue( $loadSessionInfoFromStore( $info ) );
1418		$this->assertFalse( $info->forceHTTPS() );
1419		$this->assertSame( [], $logger->getBuffer() );
1420
1421		$this->store->setSessionMeta( $id, [ 'forceHTTPS' => true ] + $metadata );
1422		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
1423			'provider' => $provider,
1424			'id' => $id,
1425			'userInfo' => $userInfo
1426		] );
1427		$this->assertTrue( $loadSessionInfoFromStore( $info ) );
1428		$this->assertTrue( $info->forceHTTPS() );
1429		$this->assertSame( [], $logger->getBuffer() );
1430
1431		$this->store->setSessionMeta( $id, [ 'forceHTTPS' => false ] + $metadata );
1432		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
1433			'provider' => $provider,
1434			'id' => $id,
1435			'userInfo' => $userInfo,
1436			'forceHTTPS' => true
1437		] );
1438		$this->assertTrue( $loadSessionInfoFromStore( $info ) );
1439		$this->assertTrue( $info->forceHTTPS() );
1440		$this->assertSame( [], $logger->getBuffer() );
1441
1442		// "Persist" flag from session
1443		$this->store->setSessionMeta( $id, $metadata );
1444		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
1445			'provider' => $provider,
1446			'id' => $id,
1447			'userInfo' => $userInfo
1448		] );
1449		$this->assertTrue( $loadSessionInfoFromStore( $info ) );
1450		$this->assertFalse( $info->wasPersisted() );
1451		$this->assertSame( [], $logger->getBuffer() );
1452
1453		$this->store->setSessionMeta( $id, [ 'persisted' => true ] + $metadata );
1454		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
1455			'provider' => $provider,
1456			'id' => $id,
1457			'userInfo' => $userInfo
1458		] );
1459		$this->assertTrue( $loadSessionInfoFromStore( $info ) );
1460		$this->assertTrue( $info->wasPersisted() );
1461		$this->assertSame( [], $logger->getBuffer() );
1462
1463		$this->store->setSessionMeta( $id, [ 'persisted' => false ] + $metadata );
1464		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
1465			'provider' => $provider,
1466			'id' => $id,
1467			'userInfo' => $userInfo,
1468			'persisted' => true
1469		] );
1470		$this->assertTrue( $loadSessionInfoFromStore( $info ) );
1471		$this->assertTrue( $info->wasPersisted() );
1472		$this->assertSame( [], $logger->getBuffer() );
1473
1474		// Provider refreshSessionInfo() returning false
1475		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
1476			'provider' => $provider3,
1477		] );
1478		$this->assertFalse( $loadSessionInfoFromStore( $info ) );
1479		$this->assertSame( [], $logger->getBuffer() );
1480
1481		// Hook
1482		$called = false;
1483		$data = [ 'foo' => 1 ];
1484		$this->store->setSession( $id, [ 'metadata' => $metadata, 'data' => $data ] );
1485		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
1486			'provider' => $provider,
1487			'id' => $id,
1488			'userInfo' => $userInfo
1489		] );
1490		$this->mergeMwGlobalArrayValue( 'wgHooks', [
1491			'SessionCheckInfo' => [ function ( &$reason, $i, $r, $m, $d ) use (
1492				$info, $metadata, $data, $request, &$called
1493			) {
1494				$this->assertSame( $info->getId(), $i->getId() );
1495				$this->assertSame( $info->getProvider(), $i->getProvider() );
1496				$this->assertSame( $info->getUserInfo(), $i->getUserInfo() );
1497				$this->assertSame( $request, $r );
1498				$this->assertEquals( $metadata, $m );
1499				$this->assertEquals( $data, $d );
1500				$called = true;
1501				return false;
1502			} ]
1503		] );
1504		$this->assertFalse( $loadSessionInfoFromStore( $info ) );
1505		$this->assertTrue( $called );
1506		$this->assertSame( [
1507			[ LogLevel::WARNING, 'Session "{session}": Hook aborted' ],
1508		], $logger->getBuffer() );
1509		$logger->clearBuffer();
1510		$this->mergeMwGlobalArrayValue( 'wgHooks', [ 'SessionCheckInfo' => [] ] );
1511
1512		// forceUse deletes bad backend data
1513		$this->store->setSessionMeta( $id, [ 'userToken' => 'Bad' ] + $metadata );
1514		$info = new SessionInfo( SessionInfo::MIN_PRIORITY, [
1515			'provider' => $provider,
1516			'id' => $id,
1517			'userInfo' => $userInfo,
1518			'forceUse' => true,
1519		] );
1520		$this->assertTrue( $loadSessionInfoFromStore( $info ) );
1521		$this->assertFalse( $this->store->getSession( $id ) );
1522		$this->assertSame( [
1523			[ LogLevel::WARNING, 'Session "{session}": User token mismatch' ],
1524		], $logger->getBuffer() );
1525		$logger->clearBuffer();
1526	}
1527
1528	/**
1529	 * @dataProvider provideLogPotentialSessionLeakage
1530	 */
1531	public function testLogPotentialSessionLeakage(
1532		$ip, $mwuser, $sessionData, $expectedSessionData, $expectedLogLevel
1533	) {
1534		\MWTimestamp::setFakeTime( 1234567 );
1535		$this->setMwGlobals( 'wgSuspiciousIpExpiry', 600 );
1536		$manager = new SessionManager();
1537		$logger = $this->createMock( LoggerInterface::class );
1538		$this->setLogger( 'session-ip', $logger );
1539		$request = new \FauxRequest();
1540		$request->setIP( $ip );
1541		$request->setCookie( 'mwuser-sessionId', $mwuser );
1542
1543		$proxyLookup = $this->createMock( \ProxyLookup::class );
1544		$proxyLookup->method( 'isConfiguredProxy' )->willReturnCallback( static function ( $ip ) {
1545			return $ip === '11.22.33.44';
1546		} );
1547		$this->setService( 'ProxyLookup', $proxyLookup );
1548
1549		$session = $this->createMock( Session::class );
1550		$session->method( 'isPersistent' )->willReturn( true );
1551		$session->method( 'getUser' )->willReturn( User::newFromName( 'UTSysop' ) );
1552		$session->method( 'getRequest' )->willReturn( $request );
1553		$session->method( 'getProvider' )->willReturn(
1554			$this->createMock( CookieSessionProvider::class ) );
1555		$session->method( 'get' )
1556			->with( 'SessionManager-logPotentialSessionLeakage' )
1557			->willReturn( $sessionData );
1558		$session->expects( $this->exactly( isset( $expectedSessionData ) ) )->method( 'set' )
1559			->with( 'SessionManager-logPotentialSessionLeakage', $expectedSessionData );
1560
1561		$logger->expects( $this->exactly( isset( $expectedLogLevel ) ) )->method( 'log' )
1562			->with( $expectedLogLevel );
1563
1564		$manager->logPotentialSessionLeakage( $session );
1565	}
1566
1567	public function provideLogPotentialSessionLeakage() {
1568		$now = 1234567;
1569		$valid = $now - 100;
1570		$expired = $now - 1000;
1571		return [
1572			'no log for new IP' => [
1573				'ip' => '1.2.3.4',
1574				'mwuser' => null,
1575				'sessionData' => [],
1576				'expectedSessionData' => [ 'ip' => '1.2.3.4', 'mwuser' => null, 'timestamp' => $now ],
1577				'expectedLogLevel' => null,
1578			],
1579			'no log for same IP' => [
1580				'ip' => '1.2.3.4',
1581				'mwuser' => null,
1582				'sessionData' => [ 'ip' => '1.2.3.4', 'mwuser' => null, 'timestamp' => $valid ],
1583				'expectedSessionData' => null,
1584				'expectedLogLevel' => null,
1585			],
1586			'no log for expired IP' => [
1587				'ip' => '1.2.3.4',
1588				'mwuser' => null,
1589				'sessionData' => [ 'ip' => '10.20.30.40', 'mwuser' => null, 'timestamp' => $expired ],
1590				'expectedSessionData' => [ 'ip' => '1.2.3.4', 'mwuser' => null, 'timestamp' => $now ],
1591				'expectedLogLevel' => null,
1592			],
1593			'INFO log for changed IP' => [
1594				'ip' => '1.2.3.4',
1595				'mwuser' => null,
1596				'sessionData' => [ 'ip' => '10.20.30.40', 'mwuser' => null, 'timestamp' => $valid ],
1597				'expectedSessionData' => [ 'ip' => '1.2.3.4', 'mwuser' => null, 'timestamp' => $now ],
1598				'expectedLogLevel' => LogLevel::INFO,
1599			],
1600
1601			'no log for new mwuser' => [
1602				'ip' => '1.2.3.4',
1603				'mwuser' => 'new',
1604				'sessionData' => [],
1605				'expectedSessionData' => [ 'ip' => '1.2.3.4', 'mwuser' => 'new', 'timestamp' => $now ],
1606				'expectedLogLevel' => null,
1607			],
1608			'no log for same mwuser' => [
1609				'ip' => '1.2.3.4',
1610				'mwuser' => 'old',
1611				'sessionData' => [ 'ip' => '1.2.3.4', 'mwuser' => 'old', 'timestamp' => $valid ],
1612				'expectedSessionData' => null,
1613				'expectedLogLevel' => null,
1614			],
1615			'NOTICE log for changed mwuser' => [
1616				'ip' => '1.2.3.4',
1617				'mwuser' => 'new',
1618				'sessionData' => [ 'ip' => '1.2.3.4', 'mwuser' => 'old', 'timestamp' => $valid ],
1619				'expectedSessionData' => [ 'ip' => '1.2.3.4', 'mwuser' => 'new', 'timestamp' => $now ],
1620				'expectedLogLevel' => LogLevel::NOTICE,
1621			],
1622			'no expiration for mwuser' => [
1623				'ip' => '1.2.3.4',
1624				'mwuser' => 'new',
1625				'sessionData' => [ 'ip' => '1.2.3.4', 'mwuser' => 'old', 'timestamp' => $expired ],
1626				'expectedSessionData' => [ 'ip' => '1.2.3.4', 'mwuser' => 'new', 'timestamp' => $now ],
1627				'expectedLogLevel' => LogLevel::NOTICE,
1628			],
1629			'WARNING log for changed IP + mwuser' => [
1630				'ip' => '1.2.3.4',
1631				'mwuser' => 'new',
1632				'sessionData' => [ 'ip' => '10.20.30.40', 'mwuser' => 'old', 'timestamp' => $valid ],
1633				'expectedSessionData' => [ 'ip' => '1.2.3.4', 'mwuser' => 'new', 'timestamp' => $now ],
1634				'expectedLogLevel' => LogLevel::WARNING,
1635			],
1636
1637			'special IPs are ignored (1)' => [
1638				'ip' => '127.0.0.1',
1639				'mwuser' => 'new',
1640				'sessionData' => [ 'ip' => '10.20.30.40', 'mwuser' => 'old', 'timestamp' => $valid ],
1641				'expectedSessionData' => null,
1642				'expectedLogLevel' => null,
1643			],
1644			'special IPs are ignored (2)' => [
1645				'ip' => '11.22.33.44',
1646				'mwuser' => 'new',
1647				'sessionData' => [ 'ip' => '10.20.30.40', 'mwuser' => 'old', 'timestamp' => $valid ],
1648				'expectedSessionData' => null,
1649				'expectedLogLevel' => null,
1650			],
1651		];
1652	}
1653}
1654