Add unit tests for all listeners, the backup-codes command, and test support helpers

- Add ListenerTestHelper providing a real Twig Environment pointed at the
  project templates and configurable RateLimiterFactoryInterface doubles
  (fixed-remaining and counting variants)
- Add AcceptListenerTest: valid cookie session (host & auth-subdomain cookie
  names), missing/empty cookie, no session
- Add AllowListenerTest: valid IP session, missing session, IP access disabled
- Add RejectListenerTest: blocked -> teapot / too-many-requests, unblocked,
  error template content
- Add InterceptListenerTest: central-auth redirect, login page rendering,
  post form on auth subdomain, invalid-cookie pruning (host & auth cookies)
- Add LoginListenerTest: no-login early return, successful login via header
  and via POST to auth subdomain, failed login (json/html), rate-limited
  (teapot/too-many), invalid payload handling
- Add GenerateBackupCodesCommandTest: default/specific count, default argument
  is 10, boot+persist invocation, zero codes, name & description configuration
- Fix MakeNonceTraitTest nonce cache lookups to use makeCacheKey() (was flaky
  when random nonce contained '-')
This commit is contained in:
2026-08-05 16:55:28 -04:00
parent 890cc225ef
commit 4d314bcb28
8 changed files with 1049 additions and 3 deletions
+111
View File
@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace App\Tests\Support;
use App\ConfigBag;
use App\Service\DomainManager;
use Psr\Log\NullLogger;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\RateLimiter\RateLimit;
use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;
use Symfony\Component\RateLimiter\LimiterInterface;
use Twig\Environment;
use Twig\Loader\FilesystemLoader;
/**
* Helpers for constructing the collaborators that the kernel listeners
* depend on, without booting the full Symfony container.
*/
trait ListenerTestHelper {
use TotpTestHelper;
/** Build a Twig Environment pointed at the project's real templates. */
private function makeTwig(): Environment {
$loader = new FilesystemLoader(dirname(__DIR__, 2) . '/templates');
$twig = new Environment($loader, ['strict_variables' => 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;
}
};
}
}
@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Command;
use App\Command\GenerateBackupCodesCommand;
use App\PersistCache;
use App\Service\BackupCodeInterface;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\Console\Tester\CommandTester;
final class GenerateBackupCodesCommandTest extends TestCase {
/** PersistCache is final, so construct a real one backed by ArrayAdapters. */
private function makePersistCache(): PersistCache {
return new PersistCache(new ArrayAdapter(), new ArrayAdapter());
}
/** A stub BackupCodeInterface that returns the given codes from generate(). */
private function makeManagerStub(array $generatedCodes): BackupCodeInterface {
$manager = $this->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 "singleuse",
// so assert against the substring to avoid encoding fragility
self::assertStringContainsString('backup codes', $command->getDescription());
}
}
+124
View File
@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Listener;
use App\Listener\AcceptListener;
use App\Service\DomainManager;
use App\Tests\Support\TotpTestHelper;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
final class AcceptListenerTest extends TestCase {
use TotpTestHelper;
private const string COOKIE_NAME = '__Host-Http-Preauth';
private const string AUTH_COOKIE_NAME = '__Http-Domain-Preauth';
private function makeListener(ArrayAdapter $pool, DomainManager $domainManager): AcceptListener {
$listener = new AcceptListener($pool, $domainManager);
$listener->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());
}
}
+95
View File
@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Listener;
use App\ConfigBag;
use App\Listener\AllowListener;
use App\Tests\Support\TotpTestHelper;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
final class AllowListenerTest extends TestCase {
use TotpTestHelper;
private function makeListener(ArrayAdapter $pool, ConfigBag $config): AllowListener {
$listener = new AllowListener($pool, $config);
$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 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'));
}
}
@@ -0,0 +1,206 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Listener;
use App\Listener\InterceptListener;
use App\Service\DomainManager;
use App\Tests\Support\ListenerTestHelper;
use PHPUnit\Framework\TestCase;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Log\NullLogger;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
final class InterceptListenerTest extends TestCase {
use ListenerTestHelper;
private const string COOKIE_NAME = '__Host-Http-Preauth';
private const string AUTH_COOKIE_NAME = '__Http-Domain-Preauth';
private function makeListener(
DomainManager $domainManager,
?CacheItemPoolInterface $nonceCache = null,
): InterceptListener {
$listener = new InterceptListener(
$this->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('<form', $content);
// the rendered page should embed a freshly generated nonce
self::assertStringContainsString('name="nonce"', $content);
}
public function testGeneratedNonceIsStoredInCache(): void {
$nonceCache = new ArrayAdapter();
$domainManager = new DomainManager(false, '');
$listener = $this->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');
}
}
+295
View File
@@ -0,0 +1,295 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Listener;
use App\Data\Payload;
use App\Enum\Scope;
use App\Listener\LoginListener;
use App\Service\DomainManager;
use App\Service\LoginInterface;
use App\Tests\Support\ListenerTestHelper;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
final class LoginListenerTest extends TestCase {
use ListenerTestHelper;
private const string HEADER_NAME = 'X-Preauth';
private function makeListener(
?LoginInterface $loginManager = null,
?DomainManager $domainManager = null,
?int $rateLimitRemaining = 5,
): LoginListener {
$listener = new LoginListener(
$this->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('<form', $response->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());
}
}
@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Listener;
use App\Listener\RejectListener;
use App\Service\DomainManager;
use App\Tests\Support\ListenerTestHelper;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
final class RejectListenerTest extends TestCase {
use ListenerTestHelper;
private function makeListener(
bool $teapot = true,
int $remainingTokens = 5,
): RejectListener {
$listener = new RejectListener(
$this->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 &#039;
self::assertStringContainsString('a teapot', $content);
self::assertStringContainsString('I refuse to brew coffee', $content);
}
}
+9 -3
View File
@@ -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());