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:
@@ -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 "single‑use",
|
||||
// so assert against the substring to avoid encoding fragility
|
||||
self::assertStringContainsString('backup codes', $command->getDescription());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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 '
|
||||
self::assertStringContainsString('a teapot', $content);
|
||||
self::assertStringContainsString('I refuse to brew coffee', $content);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user