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('