diff --git a/tests/Support/ListenerTestHelper.php b/tests/Support/ListenerTestHelper.php new file mode 100644 index 0000000..bd335b2 --- /dev/null +++ b/tests/Support/ListenerTestHelper.php @@ -0,0 +1,111 @@ + true]); + // the templates reference a global `env` object; supply one with the + // keys used by base/login/error/_script/_style + $twig->addGlobal('env', (object)[ + 'title' => 'Pre-Authentication System', + 'bg_color' => '#029386', + 'fg_color' => '#ffffff', + 'error_color' => '#ffb16d', + 'id_name' => 'Session ID', + 'token_name' => 'Authentication Token', + 'submit_name' => 'Submit', + 'error_message' => 'Unsuccessful login attempt', + 'teapot' => true, + 'teapot_title' => "I'm a teapot", + 'teapot_message' => 'I refuse to brew coffee', + 'too_many_title' => 'Too many requests', + 'too_many_message' => 'Try again later', + 'debug' => 0, + ]); + return $twig; + } + + /** + * A RateLimiterFactoryInterface whose created limiter returns a RateLimit + * with the given remaining tokens. + */ + private function makeRateLimiterFactory(int $remainingTokens): RateLimiterFactoryInterface { + $limiter = $this->makeLimiter($remainingTokens); + return new class($limiter) implements RateLimiterFactoryInterface { + public function __construct(private LimiterInterface $limiter) {} + public function create(?string $key = null): LimiterInterface { + return $this->limiter; + } + }; + } + + private function makeLimiter(int $remainingTokens): LimiterInterface { + $rateLimit = new RateLimit( + $remainingTokens, + new \DateTimeImmutable('+10 seconds'), + $remainingTokens > 0, + 10, + ); + return new class($rateLimit) implements LimiterInterface { + public function __construct(private RateLimit $rateLimit) {} + public function reserve(int $tokens = 1, ?float $maxTime = null): \Symfony\Component\RateLimiter\Reservation { + throw new \Symfony\Component\RateLimiter\Exception\ReserveNotSupportedException(); + } + public function consume(int $tokens = 1): RateLimit { + return $this->rateLimit; + } + public function reset(): void {} + }; + } + + /** + * A factory whose limiter tracks how many consume(1) calls were made and + * reports the limit as reached only after $threshold failures. + */ + private function makeCountingRateLimiterFactory(int $threshold): RateLimiterFactoryInterface { + $limiter = new class($threshold) implements LimiterInterface { + private int $consumed = 0; + public function __construct(private int $threshold) {} + public function reserve(int $tokens = 1, ?float $maxTime = null): \Symfony\Component\RateLimiter\Reservation { + throw new \Symfony\Component\RateLimiter\Exception\ReserveNotSupportedException(); + } + public function consume(int $tokens = 1): RateLimit { + $this->consumed += $tokens; + $remaining = max(0, $this->threshold - $this->consumed); + return new RateLimit( + $remaining, + new \DateTimeImmutable('+10 seconds'), + $remaining > 0, + $this->threshold, + ); + } + public function reset(): void { $this->consumed = 0; } + }; + return new class($limiter) implements RateLimiterFactoryInterface { + public function __construct(private LimiterInterface $limiter) {} + public function create(?string $key = null): LimiterInterface { + return $this->limiter; + } + }; + } +} diff --git a/tests/Unit/Command/GenerateBackupCodesCommandTest.php b/tests/Unit/Command/GenerateBackupCodesCommandTest.php new file mode 100644 index 0000000..f8b5e62 --- /dev/null +++ b/tests/Unit/Command/GenerateBackupCodesCommandTest.php @@ -0,0 +1,120 @@ +createStub(BackupCodeInterface::class); + $manager->method('generate')->willReturn($generatedCodes); + return $manager; + } + + public function testGenerateDefaultCountOutputsCodes(): void { + $codes = ['abc123', 'def456', 'ghi789', 'jkl012', 'mno345', + 'pqr678', 'stu901', 'vwx234', 'yzA567', 'bCd890']; + $command = new GenerateBackupCodesCommand( + $this->makeManagerStub($codes), $this->makePersistCache() + ); + $command->setName('app:generate-backup-codes'); + + $tester = new CommandTester($command); + $exit = $tester->execute([]); + + self::assertSame(0, $exit); + $output = $tester->getDisplay(); + foreach ($codes as $code) { + self::assertStringContainsString($code, $output); + } + } + + public function testGenerateSpecificCountPassesCountToManager(): void { + $manager = $this->createMock(BackupCodeInterface::class); + $manager->expects(self::once()) + ->method('generate') + ->with(self::identicalTo(5)) + ->willReturn(['c1', 'c2', 'c3', 'c4', 'c5']); + + $command = new GenerateBackupCodesCommand($manager, $this->makePersistCache()); + $command->setName('app:generate-backup-codes'); + + $tester = new CommandTester($command); + $exit = $tester->execute(['count' => 5]); + + self::assertSame(0, $exit); + } + + public function testDefaultCountArgumentIsTen(): void { + // the configured default for the count argument should be 10 + $manager = $this->createMock(BackupCodeInterface::class); + $manager->expects(self::once()) + ->method('generate') + ->with(self::identicalTo(10)) + ->willReturn(array_fill(0, 10, 'code')); + + $command = new GenerateBackupCodesCommand($manager, $this->makePersistCache()); + $command->setName('app:generate-backup-codes'); + + $tester = new CommandTester($command); + $tester->execute([]); + + // assertion is in the mock expectation above + $this->addToAssertionCount(1); + } + + public function testBootsAndPersistsCache(): void { + // PersistCache is final and can't be mocked, but we can verify the + // command runs end-to-end with a real instance; boot()/persist() + // are invoked implicitly. A successful exit confirms both were called + // without throwing. + $command = new GenerateBackupCodesCommand( + $this->makeManagerStub(['code1']), $this->makePersistCache() + ); + $command->setName('app:generate-backup-codes'); + + $tester = new CommandTester($command); + $exit = $tester->execute([]); + + self::assertSame(0, $exit); + } + + public function testZeroCodesOutputsNothing(): void { + $command = new GenerateBackupCodesCommand( + $this->makeManagerStub([]), $this->makePersistCache() + ); + $command->setName('app:generate-backup-codes'); + + $tester = new CommandTester($command); + $exit = $tester->execute(['count' => 0]); + + self::assertSame(0, $exit); + self::assertSame('', trim($tester->getDisplay())); + } + + public function testCommandNameAndDescriptionAreConfigured(): void { + $command = new GenerateBackupCodesCommand( + $this->makeManagerStub([]), $this->makePersistCache() + ); + // configuring via the Application runs the protected configure() + $app = new \Symfony\Component\Console\Application(); + $app->addCommand($command); + self::assertSame('app:generate-backup-codes', $command->getName()); + // the source uses a non-breaking hyphen (U+2011) in "single‑use", + // so assert against the substring to avoid encoding fragility + self::assertStringContainsString('backup codes', $command->getDescription()); + } +} diff --git a/tests/Unit/Listener/AcceptListenerTest.php b/tests/Unit/Listener/AcceptListenerTest.php new file mode 100644 index 0000000..41b04a9 --- /dev/null +++ b/tests/Unit/Listener/AcceptListenerTest.php @@ -0,0 +1,124 @@ +setLogger(new NullLogger()); + return $listener; + } + + private function makeEvent(Request $request): RequestEvent { + return new RequestEvent( + $this->createStub(\Symfony\Component\HttpKernel\HttpKernelInterface::class), + $request, + HttpKernelInterface::MAIN_REQUEST, + ); + } + + /* ── valid cookie session ─────────────────────────────────────────── */ + + public function testValidCookieSetsResponseWithRemoteUser(): void { + $pool = new ArrayAdapter(); + $ulid = '01HXY1234567890ABCDEFGHIJK'; + $item = $pool->getItem('cookie_' . $ulid); + $item->set('alice'); + $pool->save($item); + + $domainManager = new DomainManager(false, ''); + $listener = $this->makeListener($pool, $domainManager); + + $request = Request::create('/', 'GET'); + $request->cookies->set(self::COOKIE_NAME, $ulid); + + $event = $this->makeEvent($request); + $listener->onKernelRequest($event); + + self::assertTrue($event->hasResponse()); + $response = $event->getResponse(); + self::assertSame(200, $response->getStatusCode()); + self::assertSame('alice', $response->headers->get('Remote-User')); + self::assertSame('text/plain', $response->headers->get('Content-Type')); + } + + public function testValidCookieUsesAuthCookieNameWhenUsingCentralAuth(): void { + $pool = new ArrayAdapter(); + $ulid = '01HXY1234567890ABCDEFGHIJK'; + $item = $pool->getItem('cookie_' . $ulid); + $item->set('bob'); + $pool->save($item); + + $domainManager = new DomainManager(true, 'auth.example.com'); + $listener = $this->makeListener($pool, $domainManager); + + $request = Request::create('/', 'GET'); + $request->cookies->set(self::AUTH_COOKIE_NAME, $ulid); + + $event = $this->makeEvent($request); + $listener->onKernelRequest($event); + + self::assertTrue($event->hasResponse()); + self::assertSame('bob', $event->getResponse()->headers->get('Remote-User')); + } + + /* ── negative cases ───────────────────────────────────────────────── */ + + public function testNoCookieSetsNoResponse(): void { + $pool = new ArrayAdapter(); + $domainManager = new DomainManager(false, ''); + $listener = $this->makeListener($pool, $domainManager); + + $event = $this->makeEvent(Request::create('/', 'GET')); + $listener->onKernelRequest($event); + + self::assertFalse($event->hasResponse()); + } + + public function testCookieWithoutSessionSetsNoResponse(): void { + $pool = new ArrayAdapter(); + $domainManager = new DomainManager(false, ''); + $listener = $this->makeListener($pool, $domainManager); + + $request = Request::create('/', 'GET'); + $request->cookies->set(self::COOKIE_NAME, 'unknown-ulid'); + + $event = $this->makeEvent($request); + $listener->onKernelRequest($event); + + self::assertFalse($event->hasResponse()); + } + + public function testEmptyCookieValueSetsNoResponse(): void { + $pool = new ArrayAdapter(); + $domainManager = new DomainManager(false, ''); + $listener = $this->makeListener($pool, $domainManager); + + // cookies->set with empty string + $request = Request::create('/', 'GET'); + $request->cookies->set(self::COOKIE_NAME, ''); + + $event = $this->makeEvent($request); + $listener->onKernelRequest($event); + + // empty cookie value should not be treated as a valid session + self::assertFalse($event->hasResponse()); + } +} diff --git a/tests/Unit/Listener/AllowListenerTest.php b/tests/Unit/Listener/AllowListenerTest.php new file mode 100644 index 0000000..3b4917e --- /dev/null +++ b/tests/Unit/Listener/AllowListenerTest.php @@ -0,0 +1,95 @@ +setLogger(new NullLogger()); + return $listener; + } + + private function makeEvent(Request $request): RequestEvent { + return new RequestEvent( + $this->createStub(HttpKernelInterface::class), + $request, + HttpKernelInterface::MAIN_REQUEST, + ); + } + + public function testValidIpSessionSetsResponseWithRemoteUser(): void { + $pool = new ArrayAdapter(); + $item = $pool->getItem('ip_1.2.3.4'); + $item->set('carol'); + $pool->save($item); + + $config = $this->makeConfig(ipTtl: 1800); + $listener = $this->makeListener($pool, $config); + + $request = Request::create('/', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']); + $event = $this->makeEvent($request); + $listener->onKernelRequest($event); + + self::assertTrue($event->hasResponse()); + $response = $event->getResponse(); + self::assertSame(200, $response->getStatusCode()); + self::assertSame('carol', $response->headers->get('Remote-User')); + self::assertSame('text/plain', $response->headers->get('Content-Type')); + } + + public function testNoIpSessionSetsNoResponse(): void { + $pool = new ArrayAdapter(); + $config = $this->makeConfig(ipTtl: 1800); + $listener = $this->makeListener($pool, $config); + + $request = Request::create('/', 'GET', [], [], [], ['REMOTE_ADDR' => '9.9.9.9']); + $event = $this->makeEvent($request); + $listener->onKernelRequest($event); + + self::assertFalse($event->hasResponse()); + } + + public function testIpAccessDisabledSetsNoResponse(): void { + $pool = new ArrayAdapter(); + // even though there's a stored session, ip access is disabled + $item = $pool->getItem('ip_1.2.3.4'); + $item->set('carol'); + $pool->save($item); + + $config = $this->makeConfig(ipTtl: 0); + $listener = $this->makeListener($pool, $config); + + $request = Request::create('/', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']); + $event = $this->makeEvent($request); + $listener->onKernelRequest($event); + + self::assertFalse($event->hasResponse()); + } + + public function testIpAccessDisabledDoesNotCheckCache(): void { + $pool = new ArrayAdapter(); + $config = $this->makeConfig(ipTtl: 0); + $listener = $this->makeListener($pool, $config); + + $request = Request::create('/', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']); + $event = $this->makeEvent($request); + $listener->onKernelRequest($event); + + // when disabled, nothing should have been written/read as a session + self::assertFalse($event->hasResponse()); + self::assertFalse($pool->hasItem('ip_1.2.3.4')); + } +} diff --git a/tests/Unit/Listener/InterceptListenerTest.php b/tests/Unit/Listener/InterceptListenerTest.php new file mode 100644 index 0000000..8ca30c1 --- /dev/null +++ b/tests/Unit/Listener/InterceptListenerTest.php @@ -0,0 +1,206 @@ +makeConfig(), + $domainManager, + $this->makeTwig(), + ); + $listener->setLogger(new NullLogger()); + $listener->setNonceCache($nonceCache ?? new ArrayAdapter()); + return $listener; + } + + private function makeEvent(Request $request): RequestEvent { + return new RequestEvent( + $this->createStub(HttpKernelInterface::class), + $request, + HttpKernelInterface::MAIN_REQUEST, + ); + } + + /* ── central-auth redirect branch ─────────────────────────────────── */ + + public function testRedirectsToAuthSubdomainWhenHostMatchesBaseDomain(): void { + $domainManager = new DomainManager(true, 'auth.example.com'); + $listener = $this->makeListener($domainManager); + + $request = Request::create('https://app.example.com/dashboard', 'GET'); + $event = $this->makeEvent($request); + $listener->onKernelRequest($event); + + self::assertTrue($event->hasResponse()); + $response = $event->getResponse(); + self::assertSame(Response::HTTP_SEE_OTHER, $response->getStatusCode()); + $location = $response->headers->get('Location'); + self::assertStringStartsWith('https://auth.example.com/?', $location); + // the return query should contain the original url + self::assertStringContainsString('return=', $location); + self::assertStringContainsString(urlencode('https://app.example.com/dashboard'), $location); + } + + public function testDoesNotRedirectWhenAlreadyOnAuthSubdomain(): void { + $domainManager = new DomainManager(true, 'auth.example.com'); + $listener = $this->makeListener($domainManager); + + $request = Request::create('https://auth.example.com/', 'GET'); + $event = $this->makeEvent($request); + $listener->onKernelRequest($event); + + // should render login page, not redirect + self::assertTrue($event->hasResponse()); + $response = $event->getResponse(); + self::assertNotSame(Response::HTTP_SEE_OTHER, $response->getStatusCode()); + self::assertSame(Response::HTTP_UNAUTHORIZED, $response->getStatusCode()); + } + + /* ── login page rendering branch ──────────────────────────────────── */ + + public function testPresentsLoginPageWithUnauthorizedStatus(): void { + $domainManager = new DomainManager(false, ''); + $listener = $this->makeListener($domainManager); + + $request = Request::create('https://example.com/', 'GET'); + $event = $this->makeEvent($request); + $listener->onKernelRequest($event); + + self::assertTrue($event->hasResponse()); + $response = $event->getResponse(); + self::assertSame(Response::HTTP_UNAUTHORIZED, $response->getStatusCode()); + self::assertSame('text/html', $response->headers->get('Content-Type')); + $content = $response->getContent(); + self::assertStringContainsString('makeListener($domainManager, $nonceCache); + + $request = Request::create('https://example.com/', 'GET'); + $event = $this->makeEvent($request); + $listener->onKernelRequest($event); + + // exactly one nonce should now exist in the cache, marked valid + $found = false; + foreach ($nonceCache->getValues() as $key => $value) { + if (str_starts_with($key, 'test_') || preg_match('/^[A-Za-z0-9_.]+$/', $key)) { + $found = true; + } + } + // ArrayAdapter stores raw values; verify at least one item was saved + self::assertTrue(count($nonceCache->getValues()) > 0); + } + + public function testLoginTemplateUsesPostFormWhenOnAuthSubdomain(): void { + $domainManager = new DomainManager(true, 'auth.example.com'); + $listener = $this->makeListener($domainManager); + + $request = Request::create('https://auth.example.com/', 'GET'); + $event = $this->makeEvent($request); + $listener->onKernelRequest($event); + + $content = $event->getResponse()->getContent(); + // when on the auth subdomain, post=true so the form has method="post" + self::assertStringContainsString('method="post"', $content); + } + + public function testLoginTemplateDoesNotUsePostFormWhenNotOnAuthSubdomain(): void { + $domainManager = new DomainManager(false, ''); + $listener = $this->makeListener($domainManager); + + $request = Request::create('https://example.com/', 'GET'); + $event = $this->makeEvent($request); + $listener->onKernelRequest($event); + + $content = $event->getResponse()->getContent(); + // not on auth subdomain, so the form should NOT have method="post" + self::assertStringNotContainsString('method="post"', $content); + } + + /* ── invalid cookie pruning ───────────────────────────────────────── */ + + public function testInvalidCookieIsClearedWhenPresent(): void { + $domainManager = new DomainManager(false, ''); + $listener = $this->makeListener($domainManager); + + $request = Request::create('https://example.com/', 'GET'); + // send a cookie that won't match any session (so AcceptListener didn't fire) + $request->cookies->set(self::COOKIE_NAME, 'stale-ulid'); + + $event = $this->makeEvent($request); + $listener->onKernelRequest($event); + + self::assertTrue($event->hasResponse()); + $response = $event->getResponse(); + // a Clear-Site-Data style clearCookie should produce a Set-Cookie that expires it + $cookies = $response->headers->getCookies(); + $cleared = false; + foreach ($cookies as $cookie) { + if ($cookie->getName() === self::COOKIE_NAME && $cookie->isCleared()) { + $cleared = true; + } + } + self::assertTrue($cleared, 'Expected the invalid cookie to be cleared'); + } + + public function testNoCookieClearingWhenNoCookiePresent(): void { + $domainManager = new DomainManager(false, ''); + $listener = $this->makeListener($domainManager); + + $request = Request::create('https://example.com/', 'GET'); + $event = $this->makeEvent($request); + $listener->onKernelRequest($event); + + $response = $event->getResponse(); + self::assertSame([], $response->headers->getCookies()); + } + + public function testInvalidCookieUsesAuthCookieNameWithCentralAuth(): void { + $domainManager = new DomainManager(true, 'auth.example.com'); + $listener = $this->makeListener($domainManager); + + // request to auth subdomain with a stale auth-domain cookie + $request = Request::create('https://auth.example.com/', 'GET'); + $request->cookies->set(self::AUTH_COOKIE_NAME, 'stale-ulid'); + + $event = $this->makeEvent($request); + $listener->onKernelRequest($event); + + $response = $event->getResponse(); + $cleared = false; + foreach ($response->headers->getCookies() as $cookie) { + if ($cookie->getName() === self::AUTH_COOKIE_NAME && $cookie->isCleared()) { + $cleared = true; + } + } + self::assertTrue($cleared, 'Expected the auth cookie to be cleared'); + } +} diff --git a/tests/Unit/Listener/LoginListenerTest.php b/tests/Unit/Listener/LoginListenerTest.php new file mode 100644 index 0000000..d8d1b65 --- /dev/null +++ b/tests/Unit/Listener/LoginListenerTest.php @@ -0,0 +1,295 @@ +makeTwig(), + $this->makeRateLimiterFactory($rateLimitRemaining ?? 5), + $domainManager ?? new DomainManager(false, ''), + $loginManager ?? $this->createStub(LoginInterface::class), + $this->makeConfig(), + ); + $listener->setLogger(new NullLogger()); + $listener->setNonceCache(new ArrayAdapter()); + return $listener; + } + + private function makeEvent(Request $request): RequestEvent { + return new RequestEvent( + $this->createStub(HttpKernelInterface::class), + $request, + HttpKernelInterface::MAIN_REQUEST, + ); + } + + /** Build a base64url-encoded X-Preauth header value for a payload. */ + private function encodePayload(array $data): string { + $json = json_encode($data, JSON_THROW_ON_ERROR); + return rtrim(strtr(base64_encode($json), '+/', '-_'), '='); + } + + /* ── no login attempt ─────────────────────────────────────────────── */ + + public function testNoHeaderAndNoPostReturnsEarlyWithoutResponse(): void { + $listener = $this->makeListener(); + + $request = Request::create('https://example.com/', 'GET'); + $event = $this->makeEvent($request); + $listener->onKernelRequest($event); + + self::assertFalse($event->hasResponse()); + } + + public function testPostToNonAuthSubdomainReturnsEarlyWithoutResponse(): void { + // POST only counts as a login attempt when on the auth subdomain + $domainManager = new DomainManager(true, 'auth.example.com'); + $listener = $this->makeListener(domainManager: $domainManager); + + $request = Request::create('https://app.example.com/', 'POST'); + $event = $this->makeEvent($request); + $listener->onKernelRequest($event); + + self::assertFalse($event->hasResponse()); + } + + /* ── successful login via header ──────────────────────────────────── */ + + public function testSuccessfulLoginViaHeaderSetsResponseFromManager(): void { + $expected = new Response('hi alice', 200, ['Remote-User' => 'alice']); + $loginManager = $this->createStub(LoginInterface::class); + $loginManager->method('checkToken')->willReturn($expected); + + $listener = $this->makeListener(loginManager: $loginManager); + + $payload = $this->encodePayload([ + 'id' => 'alice', 'token' => '123456', 'nonce' => 'nonce-1', 'json' => true, + ]); + $request = Request::create('https://example.com/', 'GET'); + $request->headers->set(self::HEADER_NAME, $payload); + + $event = $this->makeEvent($request); + $listener->onKernelRequest($event); + + self::assertTrue($event->hasResponse()); + self::assertSame($expected, $event->getResponse()); + } + + public function testSuccessfulLoginViaPostToAuthSubdomain(): void { + $expected = new Response('hi bob', 303, ['Location' => '/']); + $loginManager = $this->createStub(LoginInterface::class); + $loginManager->method('checkToken')->willReturn($expected); + + $domainManager = new DomainManager(true, 'auth.example.com'); + $listener = $this->makeListener(loginManager: $loginManager, domainManager: $domainManager); + + $request = Request::create('https://auth.example.com/', 'POST', [ + 'username' => 'bob', 'totp' => '654321', 'nonce' => 'nonce-2', + ]); + + $event = $this->makeEvent($request); + $listener->onKernelRequest($event); + + self::assertTrue($event->hasResponse()); + self::assertSame($expected, $event->getResponse()); + } + + /* ── failed login ─────────────────────────────────────────────────── */ + + public function testFailedLoginReturnsJsonErrorWithNewNonce(): void { + $loginManager = $this->createStub(LoginInterface::class); + $loginManager->method('checkToken')->willReturn(null); + + $listener = $this->makeListener(loginManager: $loginManager); + + $payload = $this->encodePayload([ + 'id' => 'alice', 'token' => 'wrong', 'nonce' => 'nonce-1', 'json' => true, + ]); + $request = Request::create('https://example.com/', 'GET'); + $request->headers->set(self::HEADER_NAME, $payload); + + $event = $this->makeEvent($request); + $listener->onKernelRequest($event); + + self::assertTrue($event->hasResponse()); + $response = $event->getResponse(); + self::assertSame(Response::HTTP_UNAUTHORIZED, $response->getStatusCode()); + self::assertSame('application/json', $response->headers->get('Content-Type')); + $body = json_decode($response->getContent(), true); + // the TotpTestHelper::makeConfig default errorMessage is 'Error' + self::assertSame('Error', $body['message']); + self::assertNotEmpty($body['nonce']); + self::assertFalse($body['post']); + // username is echoed back (sanitized via makeCacheKey) + self::assertSame('alice', $body['username']); + } + + public function testFailedLoginHtmlResponseWhenJsonFalse(): void { + $loginManager = $this->createStub(LoginInterface::class); + $loginManager->method('checkToken')->willReturn(null); + + $listener = $this->makeListener(loginManager: $loginManager); + + $payload = $this->encodePayload([ + 'id' => 'alice', 'token' => 'wrong', 'nonce' => 'nonce-1', 'json' => false, + ]); + $request = Request::create('https://example.com/', 'GET'); + $request->headers->set(self::HEADER_NAME, $payload); + + $event = $this->makeEvent($request); + $listener->onKernelRequest($event); + + $response = $event->getResponse(); + self::assertSame(Response::HTTP_UNAUTHORIZED, $response->getStatusCode()); + self::assertSame('text/html', $response->headers->get('Content-Type')); + self::assertStringContainsString('getContent()); + } + + public function testFailedLoginOnAuthSubdomainUsesPostForm(): void { + $loginManager = $this->createStub(LoginInterface::class); + $loginManager->method('checkToken')->willReturn(null); + + $domainManager = new DomainManager(true, 'auth.example.com'); + $listener = $this->makeListener( + loginManager: $loginManager, + domainManager: $domainManager, + ); + + $payload = $this->encodePayload([ + 'id' => 'alice', 'token' => 'wrong', 'nonce' => 'nonce-1', 'json' => false, + ]); + $request = Request::create('https://auth.example.com/', 'GET'); + $request->headers->set(self::HEADER_NAME, $payload); + + $event = $this->makeEvent($request); + $listener->onKernelRequest($event); + + $content = $event->getResponse()->getContent(); + self::assertStringContainsString('method="post"', $content); + } + + /* ── rate-limited (blocked) login ─────────────────────────────────── */ + + public function testRateLimitedLoginReturnsTeapotWhenTeapotEnabled(): void { + $loginManager = $this->createStub(LoginInterface::class); + $loginManager->method('checkToken')->willReturn(null); + + // limiter with 0 remaining tokens -> blocked + $listener = $this->makeListener( + loginManager: $loginManager, + rateLimitRemaining: 0, + ); + + $payload = $this->encodePayload([ + 'id' => 'alice', 'token' => 'wrong', 'nonce' => 'nonce-1', 'json' => true, + ]); + $request = Request::create('https://example.com/', 'GET'); + $request->headers->set(self::HEADER_NAME, $payload); + + $event = $this->makeEvent($request); + $listener->onKernelRequest($event); + + $response = $event->getResponse(); + self::assertSame(Response::HTTP_I_AM_A_TEAPOT, $response->getStatusCode()); + $body = json_decode($response->getContent(), true); + // the TotpTestHelper::makeConfig default teapotTitle is 'Teapot' + self::assertSame('Teapot', $body['message']); + } + + public function testRateLimitedLoginReturnsTooManyRequestsWhenTeapotDisabled(): void { + $loginManager = $this->createStub(LoginInterface::class); + $loginManager->method('checkToken')->willReturn(null); + + $listener = new LoginListener( + $this->makeTwig(), + $this->makeRateLimiterFactory(0), + new DomainManager(false, ''), + $loginManager, + $this->makeConfig(teapot: false), + ); + $listener->setLogger(new NullLogger()); + $listener->setNonceCache(new ArrayAdapter()); + + $payload = $this->encodePayload([ + 'id' => 'alice', 'token' => 'wrong', 'nonce' => 'nonce-1', 'json' => true, + ]); + $request = Request::create('https://example.com/', 'GET'); + $request->headers->set(self::HEADER_NAME, $payload); + + $event = $this->makeEvent($request); + $listener->onKernelRequest($event); + + $response = $event->getResponse(); + self::assertSame(Response::HTTP_TOO_MANY_REQUESTS, $response->getStatusCode()); + $body = json_decode($response->getContent(), true); + // teapot disabled, so tooManyTitle is used; helper default is 'Too Many' + self::assertSame('Too Many', $body['message']); + } + + /* ── invalid payload handling ─────────────────────────────────────── */ + + public function testInvalidHeaderPayloadStillRecordsFailureAndResponds(): void { + $loginManager = $this->createMock(LoginInterface::class); + // checkToken should not be called with a null payload + $loginManager->expects(self::never())->method('checkToken'); + + $listener = $this->makeListener(loginManager: $loginManager); + + // an un-decodable header value + $request = Request::create('https://example.com/', 'GET'); + $request->headers->set(self::HEADER_NAME, '!!!not-valid-base64!!!'); + + $event = $this->makeEvent($request); + $listener->onKernelRequest($event); + + // Payload::decode returns null, so checkToken is skipped, but a + // failure response is still produced (the rate limiter is consulted) + self::assertTrue($event->hasResponse()); + self::assertSame(Response::HTTP_UNAUTHORIZED, $event->getResponse()->getStatusCode()); + } + + public function testPostWithoutRequiredFieldsDoesNotAttemptLogin(): void { + $loginManager = $this->createMock(LoginInterface::class); + $loginManager->expects(self::never())->method('checkToken'); + + $domainManager = new DomainManager(true, 'auth.example.com'); + $listener = $this->makeListener( + loginManager: $loginManager, + domainManager: $domainManager, + ); + + // POST to auth subdomain but missing the required fields + $request = Request::create('https://auth.example.com/', 'POST', ['username' => 'only-user']); + + $event = $this->makeEvent($request); + $listener->onKernelRequest($event); + + // Payload::load returns null (missing totp & nonce), so it falls through + // to the failure path and produces a response + self::assertTrue($event->hasResponse()); + } +} diff --git a/tests/Unit/Listener/RejectListenerTest.php b/tests/Unit/Listener/RejectListenerTest.php new file mode 100644 index 0000000..2e8ab5d --- /dev/null +++ b/tests/Unit/Listener/RejectListenerTest.php @@ -0,0 +1,89 @@ +makeConfig(teapot: $teapot), + $this->makeTwig(), + $this->makeRateLimiterFactory($remainingTokens), + ); + $listener->setLogger(new NullLogger()); + return $listener; + } + + private function makeEvent(Request $request): RequestEvent { + return new RequestEvent( + $this->createStub(HttpKernelInterface::class), + $request, + HttpKernelInterface::MAIN_REQUEST, + ); + } + + public function testBlockedRequestReturnsTeapotWhenTeapotEnabled(): void { + $listener = $this->makeListener(teapot: true, remainingTokens: 0); + + $request = Request::create('/', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']); + $event = $this->makeEvent($request); + $listener->onKernelRequest($event); + + self::assertTrue($event->hasResponse()); + $response = $event->getResponse(); + self::assertSame(Response::HTTP_I_AM_A_TEAPOT, $response->getStatusCode()); + self::assertSame('text/html', $response->headers->get('Content-Type')); + } + + public function testBlockedRequestReturnsTooManyRequestsWhenTeapotDisabled(): void { + $listener = $this->makeListener(teapot: false, remainingTokens: 0); + + $request = Request::create('/', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']); + $event = $this->makeEvent($request); + $listener->onKernelRequest($event); + + self::assertTrue($event->hasResponse()); + $response = $event->getResponse(); + self::assertSame(Response::HTTP_TOO_MANY_REQUESTS, $response->getStatusCode()); + self::assertSame('text/html', $response->headers->get('Content-Type')); + } + + public function testUnblockedRequestSetsNoResponse(): void { + $listener = $this->makeListener(remainingTokens: 5); + + $request = Request::create('/', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']); + $event = $this->makeEvent($request); + $listener->onKernelRequest($event); + + // consume(0) with remaining tokens > 0 should not block + self::assertFalse($event->hasResponse()); + } + + public function testBlockedResponseContainsErrorTemplateContent(): void { + $listener = $this->makeListener(teapot: true, remainingTokens: 0); + + $request = Request::create('/', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']); + $event = $this->makeEvent($request); + $listener->onKernelRequest($event); + + $content = $event->getResponse()->getContent(); + // Twig escapes the apostrophe in "I'm a teapot" to ' + self::assertStringContainsString('a teapot', $content); + self::assertStringContainsString('I refuse to brew coffee', $content); + } +} diff --git a/tests/Unit/Trait/MakeNonceTraitTest.php b/tests/Unit/Trait/MakeNonceTraitTest.php index a8aa464..ff704ee 100644 --- a/tests/Unit/Trait/MakeNonceTraitTest.php +++ b/tests/Unit/Trait/MakeNonceTraitTest.php @@ -23,6 +23,10 @@ final class MakeNonceTraitTest extends TestCase { public function publicMakeNonce(int $retries = 3): string { return $this->makeNonce($retries); } + + public function publicMakeCacheKey(string $name): string { + return $this->makeCacheKey($name); + } }; } @@ -48,8 +52,10 @@ final class MakeNonceTraitTest extends TestCase { $nonce = $obj->publicMakeNonce(); - self::assertTrue($pool->hasItem($nonce)); - $item = $pool->getItem($nonce); + // makeNonce stores via makeCacheKey() which rewrites '-' to '_' + $key = $obj->publicMakeCacheKey($nonce); + self::assertTrue($pool->hasItem($key)); + $item = $pool->getItem($key); self::assertTrue($item->get()); } @@ -61,7 +67,7 @@ final class MakeNonceTraitTest extends TestCase { $nonce = $obj->publicMakeNonce(); - $item = $pool->getItem($nonce); + $item = $pool->getItem($obj->publicMakeCacheKey($nonce)); $expiry = $item->getMetadata()['expiry']; // NONCE_TTL is 120 seconds self::assertLessThanOrEqual(120, (int) $expiry - time());