diff --git a/tests/Unit/Listener/LoginListenerTest.php b/tests/Unit/Listener/LoginListenerTest.php index ee7eb1d..bc73ad5 100644 --- a/tests/Unit/Listener/LoginListenerTest.php +++ b/tests/Unit/Listener/LoginListenerTest.php @@ -4,18 +4,19 @@ declare(strict_types=1); namespace App\Tests\Unit\Listener; use App\ConfigBag; -use App\Data\Payload; use App\Listener\LoginListener; use App\Service\DomainManager; -use App\Service\LoginManager; +use App\Service\LoginInterface; use App\Utilities; use OTPHP\TOTP; +use PHPUnit\Framework\MockObject\Rule\InvocationOrder; use PHPUnit\Framework\TestCase; use Psr\Cache\CacheItemInterface; use Psr\Cache\CacheItemPoolInterface; use Psr\Clock\ClockInterface; use Psr\Log\LoggerInterface; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Event\RequestEvent; use Symfony\Component\HttpKernel\HttpKernelInterface; use Symfony\Component\RateLimiter\RateLimit; @@ -25,13 +26,13 @@ use Twig\Environment; final class LoginListenerTest extends TestCase { private function createEvent(Request $request): RequestEvent { - $kernel = $this->createMock(HttpKernelInterface::class); + $kernel = $this->createStub(HttpKernelInterface::class); return new RequestEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST); } private function createConfigBag(bool $teapot = false): ConfigBag { - $clock = $this->createMock(ClockInterface::class); - $cache = $this->createMock(CacheItemPoolInterface::class); + $clock = $this->createStub(ClockInterface::class); + $cache = $this->createStub(CacheItemPoolInterface::class); $utilities = new Utilities($clock, $cache); $totp = TOTP::generate($clock); $totp->setLabel('Test'); @@ -49,48 +50,73 @@ final class LoginListenerTest extends TestCase { ); } - private function createLimiter(int $remainingTokens): LimiterInterface { + private function createLimiter(InvocationOrder $expectation, int $remainingTokens): LimiterInterface { $limit = $this->createMock(RateLimit::class); - $limit->method('getRemainingTokens')->willReturn($remainingTokens); + $limit->expects($expectation)->method('getRemainingTokens') + ->willReturn($remainingTokens); $limiter = $this->createMock(LimiterInterface::class); - $limiter->method('consume')->with(1)->willReturn($limit); + $limiter->expects($expectation)->method('consume') + ->with(1)->willReturn($limit); return $limiter; } + /** + * @param LoginInterface|null $loginManager + * @param DomainManager|null $domainManager + * @param bool $teapot Setting teapot variable used in config + * @param bool $reject Make the LoginInterface::checkToken return null when true + * @param int $limit Number of remaining login attempt tokens + * @param bool $html Indicates if we expect login form to be produced + * @param bool $cacheUsed Indicates if we expect the nonce cache to be used + * @return LoginListener */ private function createListener( - ?LoginManager $loginManager = null, + ?LoginInterface $loginManager = null, ?DomainManager $domainManager = null, - ?RateLimiterFactoryInterface $rateLimiter = null, - bool $teapot = false + bool $teapot = false, + bool $reject = false, + int $limit = 5, + bool $html = false, + bool $cacheUsed = true, ): LoginListener { + /* we expect the login page when request is rejected and limit not reached */ $twig = $this->createMock(Environment::class); - $twig->method('render')->with('login.html.twig', self::anything())->willReturn('login'); + $twig->expects($html ? $this->once() : $this->never()) + ->method('render')->with('login.html.twig', self::anything()) + ->willReturn('login'); - $rateLimiter = $rateLimiter ?? $this->createMock(RateLimiterFactoryInterface::class); - $rateLimiter->method('create')->willReturn($this->createLimiter(5)); + $expectation = $reject ? $this->atLeastOnce() : $this->never(); + $rateLimiter = $this->createStub(RateLimiterFactoryInterface::class); + $rateLimiter->method('create')->willReturn($this->createLimiter($expectation, $limit)); $dm = $domainManager ?? new DomainManager(false, ''); - $lm = $loginManager ?? $this->createMock(LoginManager::class); + if ( ! $loginManager) { + $loginManager = $this->createMock(LoginInterface::class); + $loginManager->expects($this->once())->method('checkToken') + ->willReturn($reject ? null : new Response('success')); + } + $lm = $loginManager; $config = $this->createConfigBag($teapot); $listener = new LoginListener($twig, $rateLimiter, $dm, $lm, $config); - $listener->setLogger($this->createMock(LoggerInterface::class)); + $listener->setLogger($this->createStub(LoggerInterface::class)); + $cacheExpected = $cacheUsed ? $this->atLeastOnce() : $this->never(); $cache = $this->createMock(CacheItemPoolInterface::class); $item = $this->createMock(CacheItemInterface::class); - $item->method('isHit')->willReturn(false); - $item->method('set')->willReturnSelf(); - $item->method('expiresAfter')->willReturnSelf(); - $cache->method('getItem')->willReturn($item); - $cache->method('save')->willReturn(true); + $item->expects($cacheExpected)->method('isHit')->willReturn(false); + $item->expects($cacheExpected)->method('set')->willReturnSelf(); + $item->expects($cacheExpected)->method('expiresAfter')->willReturnSelf(); + $cache->expects($cacheExpected)->method('getItem')->willReturn($item); + $cache->expects($cacheExpected)->method('save')->willReturn(true); $listener->setNonceCache($cache); return $listener; } + /** no login attempt should be quietly ignored */ public function testOnKernelRequestWithNoLoginAttempt(): void { - $listener = $this->createListener(); + $listener = $this->createListener($this->createStub(LoginInterface::class), cacheUsed: false); $request = Request::create('https://example.com/'); $event = $this->createEvent($request); @@ -99,17 +125,17 @@ final class LoginListenerTest extends TestCase { self::assertNull($event->getResponse()); } + /** valid login via header will return success response */ public function testOnKernelRequestWithHeaderLoginSuccess(): void { - $loginManager = $this->createMock(LoginManager::class); - $loginManager->method('checkToken')->willReturnCallback(function (Payload $payload) { - return new \Symfony\Component\HttpFoundation\Response('success'); - }); - - $listener = $this->createListener($loginManager); + $listener = $this->createListener(cacheUsed: false); $request = Request::create('https://example.com/'); $request->headers->set('X-Preauth', base64_encode(json_encode([ - 'id' => 'user1', 'token' => '123456', 'nonce' => 'abc', 'json' => true, 'scope' => 'cookie' + 'id' => 'user1', + 'token' => '123456', + 'nonce' => 'abc', + 'json' => true, + 'scope' => 'cookie', ]))); $event = $this->createEvent($request); @@ -120,12 +146,10 @@ final class LoginListenerTest extends TestCase { self::assertSame('success', $response->getContent()); } + /** valid login via post will return success response */ public function testOnKernelRequestWithPostLoginSuccess(): void { - $loginManager = $this->createMock(LoginManager::class); - $loginManager->method('checkToken')->willReturn(new \Symfony\Component\HttpFoundation\Response('success')); - $dm = new DomainManager(true, 'auth.example.com'); - $listener = $this->createListener($loginManager, $dm); + $listener = $this->createListener(domainManager: $dm, cacheUsed: false); $request = Request::create('https://auth.example.com/', 'POST', [ 'username' => 'user1', @@ -141,15 +165,17 @@ final class LoginListenerTest extends TestCase { self::assertSame('success', $response->getContent()); } + /** rejected login will return unauthorized */ public function testOnKernelRequestWithFailedLoginReturnsUnauthorized(): void { - $loginManager = $this->createMock(LoginManager::class); - $loginManager->method('checkToken')->willReturn(null); - - $listener = $this->createListener($loginManager); + $listener = $this->createListener(reject: true); $request = Request::create('https://example.com/'); $request->headers->set('X-Preauth', base64_encode(json_encode([ - 'id' => 'user1', 'token' => 'bad', 'nonce' => 'abc', 'json' => true, 'scope' => 'cookie' + 'id' => 'user1', + 'token' => 'bad', + 'nonce' => 'abc', + 'json' => true, + 'scope' => 'cookie', ]))); $event = $this->createEvent($request); @@ -160,22 +186,17 @@ final class LoginListenerTest extends TestCase { self::assertSame(401, $response->getStatusCode()); } + /** rejected login and reached rate-limit will return too-many */ public function testOnKernelRequestWithRateLimitReached(): void { - $loginManager = $this->createMock(LoginManager::class); - $loginManager->method('checkToken')->willReturn(null); - - $rateLimiter = $this->createMock(RateLimiterFactoryInterface::class); - $limit = $this->createMock(RateLimit::class); - $limit->method('getRemainingTokens')->willReturn(0); - $limiter = $this->createMock(LimiterInterface::class); - $limiter->method('consume')->with(1)->willReturn($limit); - $rateLimiter->method('create')->willReturn($limiter); - - $listener = $this->createListener($loginManager, null, $rateLimiter); + $listener = $this->createListener(reject: true, limit: 0); $request = Request::create('https://example.com/'); $request->headers->set('X-Preauth', base64_encode(json_encode([ - 'id' => 'user1', 'token' => 'bad', 'nonce' => 'abc', 'json' => true, 'scope' => 'cookie' + 'id' => 'user1', + 'token' => 'bad', + 'nonce' => 'abc', + 'json' => true, + 'scope' => 'cookie', ]))); $event = $this->createEvent($request); @@ -186,22 +207,17 @@ final class LoginListenerTest extends TestCase { self::assertSame(429, $response->getStatusCode()); } + /** rejected login and reached rate-limit with teapot will return teapot */ public function testOnKernelRequestWithRateLimitReachedAndTeapot(): void { - $loginManager = $this->createMock(LoginManager::class); - $loginManager->method('checkToken')->willReturn(null); - - $rateLimiter = $this->createMock(RateLimiterFactoryInterface::class); - $limit = $this->createMock(RateLimit::class); - $limit->method('getRemainingTokens')->willReturn(0); - $limiter = $this->createMock(LimiterInterface::class); - $limiter->method('consume')->with(1)->willReturn($limit); - $rateLimiter->method('create')->willReturn($limiter); - - $listener = $this->createListener($loginManager, null, $rateLimiter, true); + $listener = $this->createListener(teapot: true, reject: true, limit: 0); $request = Request::create('https://example.com/'); $request->headers->set('X-Preauth', base64_encode(json_encode([ - 'id' => 'user1', 'token' => 'bad', 'nonce' => 'abc', 'json' => true, 'scope' => 'cookie' + 'id' => 'user1', + 'token' => 'bad', + 'nonce' => 'abc', + 'json' => true, + 'scope' => 'cookie', ]))); $event = $this->createEvent($request); @@ -212,15 +228,17 @@ final class LoginListenerTest extends TestCase { self::assertSame(418, $response->getStatusCode()); } + /** rejected login with json will return as json */ public function testOnKernelRequestWithJsonResponse(): void { - $loginManager = $this->createMock(LoginManager::class); - $loginManager->method('checkToken')->willReturn(null); - - $listener = $this->createListener($loginManager); + $listener = $this->createListener(reject: true); $request = Request::create('https://example.com/'); $request->headers->set('X-Preauth', base64_encode(json_encode([ - 'id' => 'user1', 'token' => 'bad', 'nonce' => 'abc', 'json' => true, 'scope' => 'cookie' + 'id' => 'user1', + 'token' => 'bad', + 'nonce' => 'abc', + 'json' => true, + 'scope' => 'cookie', ]))); $event = $this->createEvent($request); @@ -231,11 +249,13 @@ final class LoginListenerTest extends TestCase { self::assertSame('application/json', $response->headers->get('Content-Type')); } + /** rejected login via POST will return as html */ public function testOnKernelRequestWithHtmlResponse(): void { - $loginManager = $this->createMock(LoginManager::class); - $loginManager->method('checkToken')->willReturn(null); - - $listener = $this->createListener($loginManager); + $listener = $this->createListener( + domainManager: new DomainManager(true, 'auth.example.com'), + reject: true, + html: true, + ); $request = Request::create('https://auth.example.com/', 'POST', [ 'username' => 'user1',