chore: add php-cs-fixer with PSR-12 config and CI check
Sync GitHub / sync (push) Failing after 5s
Tests / test (pull_request) Successful in 49s

- Add friendsofphp/php-cs-fixer to require-dev
- Create .php-cs-fixer.dist.php configured for @PSR12 ruleset
- Add php-cs-fixer dry-run step to CI pipeline
- Auto-fix existing PSR-12 violations
- Document code style tooling in readme.md
This commit is contained in:
2026-08-11 08:30:05 -04:00
parent 6b5a711fa9
commit cb378e20bc
58 changed files with 2402 additions and 481 deletions
+54 -25
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional;
@@ -14,8 +15,8 @@ use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
* travels through RejectListener -> LoginListener -> AllowListener ->
* AcceptListener -> InterceptListener and the services they orchestrate.
*/
final class AuthenticationFlowTest extends WebTestCase {
final class AuthenticationFlowTest extends WebTestCase
{
private const string TOTP_SECRET = 'JBSWY3DPEHPK3PXP';
private const string COOKIE_NAME = '__Host-Http-Preauth';
@@ -32,13 +33,15 @@ final class AuthenticationFlowTest extends WebTestCase {
return $client;
}
private function validTotpCode(): string {
private function validTotpCode(): string
{
// the app uses the real system clock, so generate the code for now()
return TOTP::createFromSecret(self::TOTP_SECRET)->now();
}
/** base64url-encode a payload, matching the client-side JS / X-Preauth header. */
private function encodePayload(array $data): string {
private function encodePayload(array $data): string
{
$json = json_encode($data, JSON_THROW_ON_ERROR);
return rtrim(strtr(base64_encode($json), '+/', '-_'), '=');
}
@@ -59,7 +62,8 @@ final class AuthenticationFlowTest extends WebTestCase {
/* ── unauthenticated access ──────────────────────────────────────── */
public function testUnauthenticatedRequestShowsLoginPage(): void {
public function testUnauthenticatedRequestShowsLoginPage(): void
{
$client = static::createClient();
$client->request('GET', '/');
@@ -71,7 +75,8 @@ final class AuthenticationFlowTest extends WebTestCase {
self::assertSelectorExists('input[name="totp"]');
}
public function testLoginPageContainsGeneratedNonce(): void {
public function testLoginPageContainsGeneratedNonce(): void
{
$client = static::createClient();
$crawler = $client->request('GET', '/');
@@ -81,7 +86,8 @@ final class AuthenticationFlowTest extends WebTestCase {
self::assertMatchesRegularExpression('/^[A-Za-z0-9_-]+$/', $nonceInput);
}
public function testLoginFormDoesNotUsePostMethodWithoutAuthSubdomain(): void {
public function testLoginFormDoesNotUsePostMethodWithoutAuthSubdomain(): void
{
$client = static::createClient();
$crawler = $client->request('GET', '/');
@@ -93,7 +99,8 @@ final class AuthenticationFlowTest extends WebTestCase {
/* ── successful TOTP login ────────────────────────────────────────── */
public function testSuccessfulTotpLoginViaHeaderSetsCookieAndRedirects(): void {
public function testSuccessfulTotpLoginViaHeaderSetsCookieAndRedirects(): void
{
$client = static::createClient();
// first, grab a valid nonce from the login page
@@ -125,7 +132,8 @@ final class AuthenticationFlowTest extends WebTestCase {
self::assertTrue($hasPreauthCookie, 'Expected a preauth cookie to be set after login');
}
public function testSuccessfulLoginReturnsJsonWhenJsonRequested(): void {
public function testSuccessfulLoginReturnsJsonWhenJsonRequested(): void
{
$client = static::createClient();
$crawler = $client->request('GET', '/');
@@ -147,7 +155,8 @@ final class AuthenticationFlowTest extends WebTestCase {
self::assertSame('Login successful', $body['message']);
}
public function testSuccessfulLoginReturnsHtmlWhenJsonFalse(): void {
public function testSuccessfulLoginReturnsHtmlWhenJsonFalse(): void
{
$client = static::createClient();
$crawler = $client->request('GET', '/');
@@ -167,7 +176,8 @@ final class AuthenticationFlowTest extends WebTestCase {
self::assertStringStartsWith('text/html', $response->headers->get('Content-Type'));
}
public function testAuthenticatedCookieAccessAfterLogin(): void {
public function testAuthenticatedCookieAccessAfterLogin(): void
{
$client = static::createClient();
// login
@@ -204,7 +214,8 @@ final class AuthenticationFlowTest extends WebTestCase {
self::assertSame('dave', $response->headers->get('Remote-User'));
}
public function testScopeNoneReturnsPlainTextWithoutRedirect(): void {
public function testScopeNoneReturnsPlainTextWithoutRedirect(): void
{
$client = static::createClient();
$crawler = $client->request('GET', '/');
@@ -229,7 +240,8 @@ final class AuthenticationFlowTest extends WebTestCase {
/* ── failed login ─────────────────────────────────────────────────── */
public function testFailedLoginReturnsUnauthorizedJsonWithError(): void {
public function testFailedLoginReturnsUnauthorizedJsonWithError(): void
{
$client = static::createClient();
$crawler = $client->request('GET', '/');
@@ -254,7 +266,8 @@ final class AuthenticationFlowTest extends WebTestCase {
self::assertNotEmpty($body['nonce']);
}
public function testFailedLoginReturnsHtmlWhenJsonFalse(): void {
public function testFailedLoginReturnsHtmlWhenJsonFalse(): void
{
$client = static::createClient();
$crawler = $client->request('GET', '/');
@@ -275,7 +288,8 @@ final class AuthenticationFlowTest extends WebTestCase {
self::assertSelectorExists('form#preauth-form');
}
public function testFailedLoginWithSpentNonceIsRejected(): void {
public function testFailedLoginWithSpentNonceIsRejected(): void
{
$client = static::createClient();
$crawler = $client->request('GET', '/');
@@ -309,7 +323,8 @@ final class AuthenticationFlowTest extends WebTestCase {
self::assertSame(401, $client->getResponse()->getStatusCode());
}
public function testFailedLoginWithInvalidNonceIsRejected(): void {
public function testFailedLoginWithInvalidNonceIsRejected(): void
{
$client = static::createClient();
// skip fetching a real nonce; use one that was never stored
@@ -327,7 +342,8 @@ final class AuthenticationFlowTest extends WebTestCase {
/* ── invalid payload ──────────────────────────────────────────────── */
public function testInvalidHeaderPayloadReturnsUnauthorized(): void {
public function testInvalidHeaderPayloadReturnsUnauthorized(): void
{
$client = static::createClient();
$client->request('GET', '/', [], [], [
@@ -338,7 +354,8 @@ final class AuthenticationFlowTest extends WebTestCase {
self::assertSame(401, $client->getResponse()->getStatusCode());
}
public function testPayloadWithMissingFieldsReturnsUnauthorized(): void {
public function testPayloadWithMissingFieldsReturnsUnauthorized(): void
{
$client = static::createClient();
// payload missing token
@@ -353,7 +370,8 @@ final class AuthenticationFlowTest extends WebTestCase {
/* ── invalid cookie ───────────────────────────────────────────────── */
public function testInvalidCookieIsClearedAndLoginPageShown(): void {
public function testInvalidCookieIsClearedAndLoginPageShown(): void
{
$client = static::createClient();
// the cookie must be set via the CookieJar so that the HttpFoundation
@@ -361,8 +379,15 @@ final class AuthenticationFlowTest extends WebTestCase {
// not parsed by Request::create)
$client->getCookieJar()->set(
new \Symfony\Component\BrowserKit\Cookie(
self::COOKIE_NAME, 'invalid-ulid-value',
null, '/', 'localhost', true, true, false, 'Strict',
self::COOKIE_NAME,
'invalid-ulid-value',
null,
'/',
'localhost',
true,
true,
false,
'Strict',
)
);
@@ -383,7 +408,8 @@ final class AuthenticationFlowTest extends WebTestCase {
/* ── backup code authentication ───────────────────────────────────── */
public function testBackupCodeAuthenticationWorks(): void {
public function testBackupCodeAuthenticationWorks(): void
{
$client = static::createClient();
$container = $client->getContainer();
@@ -407,7 +433,8 @@ final class AuthenticationFlowTest extends WebTestCase {
self::assertSame(303, $client->getResponse()->getStatusCode());
}
public function testConsumedBackupCodeCannotBeReused(): void {
public function testConsumedBackupCodeCannotBeReused(): void
{
$client = static::createClient();
$container = $client->getContainer();
@@ -442,7 +469,8 @@ final class AuthenticationFlowTest extends WebTestCase {
/* ── return URL handling ──────────────────────────────────────────── */
public function testSuccessfulLoginWithValidReturnUrl(): void {
public function testSuccessfulLoginWithValidReturnUrl(): void
{
$client = static::createClient();
$crawler = $client->request('GET', '/?return=https://example.com/app');
@@ -460,7 +488,8 @@ final class AuthenticationFlowTest extends WebTestCase {
self::assertSame('https://example.com/app', $response->headers->get('Location'));
}
public function testSuccessfulLoginWithInvalidReturnFallsBackToPath(): void {
public function testSuccessfulLoginWithInvalidReturnFallsBackToPath(): void
{
$client = static::createClient();
$crawler = $client->request('GET', '/?return=not-a-url');
+46 -21
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Tests\Support;
@@ -17,11 +18,13 @@ use Twig\Loader\FilesystemLoader;
* Helpers for constructing the collaborators that the kernel listeners
* depend on, without booting the full Symfony container.
*/
trait ListenerTestHelper {
trait ListenerTestHelper
{
use TotpTestHelper;
/** Build a Twig Environment pointed at the project's real templates. */
private function makeTwig(): Environment {
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
@@ -49,32 +52,43 @@ trait ListenerTestHelper {
* A RateLimiterFactoryInterface whose created limiter returns a RateLimit
* with the given remaining tokens.
*/
private function makeRateLimiterFactory(int $remainingTokens): RateLimiterFactoryInterface {
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 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 {
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 {
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 {
public function consume(int $tokens = 1): RateLimit
{
return $this->rateLimit;
}
public function reset(): void {}
public function reset(): void
{
}
};
}
@@ -82,14 +96,19 @@ trait ListenerTestHelper {
* 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 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 {
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 {
public function consume(int $tokens = 1): RateLimit
{
$this->consumed += $tokens;
$remaining = max(0, $this->threshold - $this->consumed);
return new RateLimit(
@@ -99,11 +118,17 @@ trait ListenerTestHelper {
$this->threshold,
);
}
public function reset(): void { $this->consumed = 0; }
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 new class ($limiter) implements RateLimiterFactoryInterface {
public function __construct(private LimiterInterface $limiter)
{
}
public function create(?string $key = null): LimiterInterface
{
return $this->limiter;
}
};
+28 -12
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Tests\Support;
@@ -17,7 +18,8 @@ use Symfony\Component\Cache\Adapter\ArrayAdapter;
* Provides a deterministic TOTP fixture plus a frozen clock and ready-made
* ConfigBag / cache-pool helpers for tests that exercise TOTP-dependent code.
*/
trait TotpTestHelper {
trait TotpTestHelper
{
/** well-known Base32 test secret (JBSWY3DPEHPK3PXP) */
private const string TOTP_SECRET = 'JBSWY3DPEHPK3PXP';
@@ -25,30 +27,37 @@ trait TotpTestHelper {
protected const string FROZEN_TIME = '2025-06-15 12:00:00';
/** Frozen clock that always returns the same instant. */
private function frozenClock(): PsrClockInterface {
private function frozenClock(): PsrClockInterface
{
$time = self::FROZEN_TIME;
return new class($time) implements PsrClockInterface {
public function __construct(private string $time) {}
public function now(): DateTimeImmutable {
return new class ($time) implements PsrClockInterface {
public function __construct(private string $time)
{
}
public function now(): DateTimeImmutable
{
return new DateTimeImmutable($this->time);
}
};
}
/** Provisioning URI built from the well-known secret + frozen clock. */
private function totpUri(): string {
private function totpUri(): string
{
$totp = TOTP::createFromSecret(self::TOTP_SECRET, $this->frozenClock());
$totp->setLabel('Test-TOTP');
return $totp->getProvisioningUri();
}
/** The TOTP code that is valid at the frozen timestamp. */
private function validTotpCode(): string {
private function validTotpCode(): string
{
return TOTP::createFromSecret(self::TOTP_SECRET, $this->frozenClock())->now();
}
/** A fresh in-memory cache pool suitable for wrapping in MonitorCacheKeys. */
private function emptyPool(): CacheItemPoolInterface {
private function emptyPool(): CacheItemPoolInterface
{
return new ArrayAdapter();
}
@@ -67,9 +76,15 @@ trait TotpTestHelper {
$clock = $this->frozenClock();
$utilities = $this->createUtilities($clock);
return new ConfigBag(
$utilities, $clock,
$cookieTtl, $this->totpUri(), $ipTtl, $teapot,
$errorMessage, $teapotTitle, $tooManyTitle,
$utilities,
$clock,
$cookieTtl,
$this->totpUri(),
$ipTtl,
$teapot,
$errorMessage,
$teapotTitle,
$tooManyTitle,
);
}
@@ -77,7 +92,8 @@ trait TotpTestHelper {
* Minimal Utilities stub that never triggers TOTP generation when
* a non-empty totpUri is supplied to ConfigBag.
*/
private function createUtilities(?PsrClockInterface $clock = null): Utilities {
private function createUtilities(?PsrClockInterface $clock = null): Utilities
{
$clock ??= $this->frozenClock();
$cache = $this->createStub(CacheItemPoolInterface::class);
$cache->method('hasItem')->willReturn(false);
+2 -1
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Tests;
@@ -27,7 +28,7 @@ class TestKernel extends AppKernel
{
parent::build($container);
$container->addCompilerPass(new class implements CompilerPassInterface {
$container->addCompilerPass(new class () implements CompilerPassInterface {
public function process(ContainerBuilder $container): void
{
foreach (['nonceCache', 'rateLimitCache', 'sessionCache', 'sessionStorage'] as $poolId) {
+5 -2
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit;
@@ -6,8 +7,10 @@ namespace App\Tests\Unit;
use App\Clock;
use PHPUnit\Framework\TestCase;
final class ClockTest extends TestCase {
public function testNowReturnsDateTimeImmutable(): void {
final class ClockTest extends TestCase
{
public function testNowReturnsDateTimeImmutable(): void
{
$clock = new Clock();
$before = new \DateTimeImmutable();
$now = $clock->now();
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Command;
@@ -10,25 +11,29 @@ use PHPUnit\Framework\TestCase;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\Console\Tester\CommandTester;
final class GenerateBackupCodesCommandTest extends TestCase {
final class GenerateBackupCodesCommandTest extends TestCase
{
/** PersistCache is final, so construct a real one backed by ArrayAdapters. */
private function makePersistCache(): PersistCache {
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 {
private function makeManagerStub(array $generatedCodes): BackupCodeInterface
{
$manager = $this->createStub(BackupCodeInterface::class);
$manager->method('generate')->willReturn($generatedCodes);
return $manager;
}
public function testGenerateDefaultCountOutputsCodes(): void {
public function testGenerateDefaultCountOutputsCodes(): void
{
$codes = ['abc123', 'def456', 'ghi789', 'jkl012', 'mno345',
'pqr678', 'stu901', 'vwx234', 'yzA567', 'bCd890'];
$command = new GenerateBackupCodesCommand(
$this->makeManagerStub($codes), $this->makePersistCache()
$this->makeManagerStub($codes),
$this->makePersistCache()
);
$command->setName('app:generate-backup-codes');
@@ -42,7 +47,8 @@ final class GenerateBackupCodesCommandTest extends TestCase {
}
}
public function testGenerateSpecificCountPassesCountToManager(): void {
public function testGenerateSpecificCountPassesCountToManager(): void
{
$manager = $this->createMock(BackupCodeInterface::class);
$manager->expects(self::once())
->method('generate')
@@ -58,7 +64,8 @@ final class GenerateBackupCodesCommandTest extends TestCase {
self::assertSame(0, $exit);
}
public function testDefaultCountArgumentIsTen(): void {
public function testDefaultCountArgumentIsTen(): void
{
// the configured default for the count argument should be 10
$manager = $this->createMock(BackupCodeInterface::class);
$manager->expects(self::once())
@@ -76,13 +83,15 @@ final class GenerateBackupCodesCommandTest extends TestCase {
$this->addToAssertionCount(1);
}
public function testBootsAndPersistsCache(): void {
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()
$this->makeManagerStub(['code1']),
$this->makePersistCache()
);
$command->setName('app:generate-backup-codes');
@@ -92,9 +101,11 @@ final class GenerateBackupCodesCommandTest extends TestCase {
self::assertSame(0, $exit);
}
public function testZeroCodesOutputsNothing(): void {
public function testZeroCodesOutputsNothing(): void
{
$command = new GenerateBackupCodesCommand(
$this->makeManagerStub([]), $this->makePersistCache()
$this->makeManagerStub([]),
$this->makePersistCache()
);
$command->setName('app:generate-backup-codes');
@@ -105,9 +116,11 @@ final class GenerateBackupCodesCommandTest extends TestCase {
self::assertSame('', trim($tester->getDisplay()));
}
public function testCommandNameAndDescriptionAreConfigured(): void {
public function testCommandNameAndDescriptionAreConfigured(): void
{
$command = new GenerateBackupCodesCommand(
$this->makeManagerStub([]), $this->makePersistCache()
$this->makeManagerStub([]),
$this->makePersistCache()
);
// configuring via the Application runs the protected configure()
$app = new \Symfony\Component\Console\Application();
+49 -19
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit;
@@ -10,8 +11,10 @@ use Psr\Cache\CacheItemInterface;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Clock\ClockInterface;
final class ConfigBagTest extends TestCase {
private function createUtilities(?string $totp = null): Utilities {
final class ConfigBagTest extends TestCase
{
private function createUtilities(?string $totp = null): Utilities
{
$clock = $this->createStub(ClockInterface::class);
$cache = $this->createStub(CacheItemPoolInterface::class);
@@ -28,14 +31,21 @@ final class ConfigBagTest extends TestCase {
return new Utilities($clock, $cache);
}
public function testGettersWithExplicitValues(): void {
public function testGettersWithExplicitValues(): void
{
$clock = $this->createStub(ClockInterface::class);
$utilities = $this->createUtilities();
$config = new ConfigBag(
$utilities, $clock,
3600, 'otpauth://totp/test', 1800, true,
'Error!', 'Teapot!', 'Too Many!'
$utilities,
$clock,
3600,
'otpauth://totp/test',
1800,
true,
'Error!',
'Teapot!',
'Too Many!'
);
self::assertSame($clock, $config->clock());
@@ -48,43 +58,63 @@ final class ConfigBagTest extends TestCase {
self::assertSame('Too Many!', $config->tooManyTitle());
}
public function testTotpUriFallsBackToUtilitiesWhenEmpty(): void {
public function testTotpUriFallsBackToUtilitiesWhenEmpty(): void
{
$clock = $this->createStub(ClockInterface::class);
$utilities = $this->createUtilities('fallback-totp');
$config = new ConfigBag(
$utilities, $clock,
3600, '', 1800, false,
'Error', 'Teapot', 'Too Many'
$utilities,
$clock,
3600,
'',
1800,
false,
'Error',
'Teapot',
'Too Many'
);
self::assertSame('fallback-totp', $config->totpUri());
}
public function testIpTtlFallsBackToNullWhenZero(): void {
public function testIpTtlFallsBackToNullWhenZero(): void
{
$clock = $this->createStub(ClockInterface::class);
$utilities = $this->createUtilities();
$config = new ConfigBag(
$utilities, $clock,
3600, 'otpauth://totp/test', 0, false,
'Error', 'Teapot', 'Too Many'
$utilities,
$clock,
3600,
'otpauth://totp/test',
0,
false,
'Error',
'Teapot',
'Too Many'
);
self::assertNull($config->ipTtl());
}
public function testIpTtlFallsBackToNullWhenNull(): void {
public function testIpTtlFallsBackToNullWhenNull(): void
{
$clock = $this->createStub(ClockInterface::class);
$utilities = $this->createUtilities();
$config = new ConfigBag(
$utilities, $clock,
3600, 'otpauth://totp/test', null, false,
'Error', 'Teapot', 'Too Many'
$utilities,
$clock,
3600,
'otpauth://totp/test',
null,
false,
'Error',
'Teapot',
'Too Many'
);
self::assertNull($config->ipTtl());
}
}
+55 -27
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Data;
@@ -8,12 +9,15 @@ use App\Enum\Scope;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\InputBag;
final class PayloadTest extends TestCase {
private static function b64u(string $data): string {
final class PayloadTest extends TestCase
{
private static function b64u(string $data): string
{
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
public function testDecodeValidBase64Url(): void {
public function testDecodeValidBase64Url(): void
{
$data = json_encode([
'id' => 'testuser', 'token' => '123456', 'nonce' => 'abc123',
'json' => true, 'scope' => 'cookie',
@@ -28,41 +32,50 @@ final class PayloadTest extends TestCase {
self::assertSame(Scope::Cookie, $payload->scope);
}
public function testDecodeInvalidBase64UrlReturnsNull(): void {
public function testDecodeInvalidBase64UrlReturnsNull(): void
{
self::assertNull(Payload::decode('!!!not-valid-base64!!!'));
}
public function testDecodeNonObjectJsonReturnsNull(): void {
public function testDecodeNonObjectJsonReturnsNull(): void
{
self::assertNull(Payload::decode(self::b64u('"just a string"')));
}
public function testDecodeInvalidJsonReturnsNull(): void {
public function testDecodeInvalidJsonReturnsNull(): void
{
// valid base64url but invalid JSON
self::assertNull(Payload::decode(self::b64u('{invalid json')));
}
public function testDecodeJsonArrayReturnsNull(): void {
public function testDecodeJsonArrayReturnsNull(): void
{
self::assertNull(Payload::decode(self::b64u('[1,2,3]')));
}
public function testDecodeJsonNullReturnsNull(): void {
public function testDecodeJsonNullReturnsNull(): void
{
self::assertNull(Payload::decode(self::b64u('null')));
}
public function testDecodeJsonBooleanReturnsNull(): void {
public function testDecodeJsonBooleanReturnsNull(): void
{
self::assertNull(Payload::decode(self::b64u('true')));
self::assertNull(Payload::decode(self::b64u('false')));
}
public function testDecodeJsonNumberReturnsNull(): void {
public function testDecodeJsonNumberReturnsNull(): void
{
self::assertNull(Payload::decode(self::b64u('42')));
}
public function testDecodeEmptyStringReturnsNull(): void {
public function testDecodeEmptyStringReturnsNull(): void
{
self::assertNull(Payload::decode(''));
}
public function testLoadWithValidInputBag(): void {
public function testLoadWithValidInputBag(): void
{
$input = new InputBag([
'username' => 'alice', 'nonce' => 'nonce123', 'totp' => '654321',
]);
@@ -76,28 +89,33 @@ final class PayloadTest extends TestCase {
self::assertSame(Scope::Cookie, $payload->scope);
}
public function testLoadMissingUsernameReturnsNull(): void {
public function testLoadMissingUsernameReturnsNull(): void
{
$input = new InputBag(['nonce' => 'n', 'totp' => 't']);
self::assertNull(Payload::load($input));
}
public function testLoadMissingNonceReturnsNull(): void {
public function testLoadMissingNonceReturnsNull(): void
{
$input = new InputBag(['username' => 'u', 'totp' => 't']);
self::assertNull(Payload::load($input));
}
public function testLoadMissingTotpReturnsNull(): void {
public function testLoadMissingTotpReturnsNull(): void
{
$input = new InputBag(['username' => 'u', 'nonce' => 'n']);
self::assertNull(Payload::load($input));
}
public function testLoadWithAllFieldsPresentButEmptyReturnsNull(): void {
public function testLoadWithAllFieldsPresentButEmptyReturnsNull(): void
{
// has() returns true for all, but create() rejects empty values
$input = new InputBag(['username' => '', 'nonce' => '', 'totp' => '']);
self::assertNull(Payload::load($input));
}
public function testCreateWithValidData(): void {
public function testCreateWithValidData(): void
{
$data = (object)[
'id' => 'user1', 'token' => 'tok1', 'nonce' => 'non1',
'json' => false, 'scope' => 'ip',
@@ -112,13 +130,15 @@ final class PayloadTest extends TestCase {
self::assertSame(Scope::Ip, $payload->scope);
}
public function testCreateWithDefaultScope(): void {
public function testCreateWithDefaultScope(): void
{
$data = (object)['id' => 'user1', 'token' => 'tok1', 'nonce' => 'non1'];
$payload = Payload::create($data);
self::assertSame(Scope::Cookie, $payload->scope);
}
public function testCreateWithInvalidScopeFallsBackToCookie(): void {
public function testCreateWithInvalidScopeFallsBackToCookie(): void
{
$data = (object)[
'id' => 'user1', 'token' => 'tok1', 'nonce' => 'non1',
'scope' => 'admin',
@@ -127,13 +147,15 @@ final class PayloadTest extends TestCase {
self::assertSame(Scope::Cookie, $payload->scope);
}
public function testCreateWithMissingJsonDefaultsToTrue(): void {
public function testCreateWithMissingJsonDefaultsToTrue(): void
{
$data = (object)['id' => 'user1', 'token' => 'tok1', 'nonce' => 'non1'];
$payload = Payload::create($data);
self::assertTrue($payload->json);
}
public function testCreateWithNoneScopeSetsJsonFalse(): void {
public function testCreateWithNoneScopeSetsJsonFalse(): void
{
$data = (object)[
'id' => 'user1', 'token' => 'tok1', 'nonce' => 'non1',
'json' => true, 'scope' => 'none',
@@ -143,27 +165,32 @@ final class PayloadTest extends TestCase {
self::assertFalse($payload->json);
}
public function testCreateWithEmptyIdReturnsNull(): void {
public function testCreateWithEmptyIdReturnsNull(): void
{
$data = (object)['id' => '', 'token' => 't', 'nonce' => 'n'];
self::assertNull(Payload::create($data));
}
public function testCreateWithWhitespaceIdReturnsNull(): void {
public function testCreateWithWhitespaceIdReturnsNull(): void
{
$data = (object)['id' => ' ', 'token' => 't', 'nonce' => 'n'];
self::assertNull(Payload::create($data));
}
public function testCreateWithEmptyTokenReturnsNull(): void {
public function testCreateWithEmptyTokenReturnsNull(): void
{
$data = (object)['id' => 'u', 'token' => '', 'nonce' => 'n'];
self::assertNull(Payload::create($data));
}
public function testCreateWithEmptyNonceReturnsNull(): void {
public function testCreateWithEmptyNonceReturnsNull(): void
{
$data = (object)['id' => 'u', 'token' => 't', 'nonce' => ''];
self::assertNull(Payload::create($data));
}
public function testCreateTrimsAndTruncatesFields(): void {
public function testCreateTrimsAndTruncatesFields(): void
{
$long = str_repeat('a', 200);
$data = (object)[
'id' => ' ' . $long . ' ',
@@ -177,7 +204,8 @@ final class PayloadTest extends TestCase {
self::assertSame($expected, $payload->nonce);
}
public function testToString(): void {
public function testToString(): void
{
$payload = new Payload();
$payload->id = 'u';
$payload->token = 't';
+9 -4
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Enum;
@@ -6,20 +7,24 @@ namespace App\Tests\Unit\Enum;
use App\Enum\Scope;
use PHPUnit\Framework\TestCase;
final class ScopeTest extends TestCase {
public function testCases(): void {
final class ScopeTest extends TestCase
{
public function testCases(): void
{
self::assertSame('cookie', Scope::Cookie->value);
self::assertSame('ip', Scope::Ip->value);
self::assertSame('none', Scope::None->value);
}
public function testTryFromValid(): void {
public function testTryFromValid(): void
{
self::assertSame(Scope::Cookie, Scope::tryFrom('cookie'));
self::assertSame(Scope::Ip, Scope::tryFrom('ip'));
self::assertSame(Scope::None, Scope::tryFrom('none'));
}
public function testTryFromInvalid(): void {
public function testTryFromInvalid(): void
{
self::assertNull(Scope::tryFrom('invalid'));
self::assertNull(Scope::tryFrom(''));
}
+17 -8
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Listener;
@@ -14,19 +15,22 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
final class AcceptListenerTest extends TestCase {
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 {
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 {
private function makeEvent(Request $request): RequestEvent
{
return new RequestEvent(
$this->createStub(\Symfony\Component\HttpKernel\HttpKernelInterface::class),
$request,
@@ -36,7 +40,8 @@ final class AcceptListenerTest extends TestCase {
/* ── valid cookie session ─────────────────────────────────────────── */
public function testValidCookieSetsResponseWithRemoteUser(): void {
public function testValidCookieSetsResponseWithRemoteUser(): void
{
$pool = new ArrayAdapter();
$ulid = '01HXY1234567890ABCDEFGHIJK';
$item = $pool->getItem('cookie_' . $ulid);
@@ -59,7 +64,8 @@ final class AcceptListenerTest extends TestCase {
self::assertSame('text/plain', $response->headers->get('Content-Type'));
}
public function testValidCookieUsesAuthCookieNameWhenUsingCentralAuth(): void {
public function testValidCookieUsesAuthCookieNameWhenUsingCentralAuth(): void
{
$pool = new ArrayAdapter();
$ulid = '01HXY1234567890ABCDEFGHIJK';
$item = $pool->getItem('cookie_' . $ulid);
@@ -81,7 +87,8 @@ final class AcceptListenerTest extends TestCase {
/* ── negative cases ───────────────────────────────────────────────── */
public function testNoCookieSetsNoResponse(): void {
public function testNoCookieSetsNoResponse(): void
{
$pool = new ArrayAdapter();
$domainManager = new DomainManager(false, '');
$listener = $this->makeListener($pool, $domainManager);
@@ -92,7 +99,8 @@ final class AcceptListenerTest extends TestCase {
self::assertFalse($event->hasResponse());
}
public function testCookieWithoutSessionSetsNoResponse(): void {
public function testCookieWithoutSessionSetsNoResponse(): void
{
$pool = new ArrayAdapter();
$domainManager = new DomainManager(false, '');
$listener = $this->makeListener($pool, $domainManager);
@@ -106,7 +114,8 @@ final class AcceptListenerTest extends TestCase {
self::assertFalse($event->hasResponse());
}
public function testEmptyCookieValueSetsNoResponse(): void {
public function testEmptyCookieValueSetsNoResponse(): void
{
$pool = new ArrayAdapter();
$domainManager = new DomainManager(false, '');
$listener = $this->makeListener($pool, $domainManager);
+15 -7
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Listener;
@@ -13,16 +14,19 @@ use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
final class AllowListenerTest extends TestCase {
final class AllowListenerTest extends TestCase
{
use TotpTestHelper;
private function makeListener(ArrayAdapter $pool, ConfigBag $config): AllowListener {
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 {
private function makeEvent(Request $request): RequestEvent
{
return new RequestEvent(
$this->createStub(HttpKernelInterface::class),
$request,
@@ -30,7 +34,8 @@ final class AllowListenerTest extends TestCase {
);
}
public function testValidIpSessionSetsResponseWithRemoteUser(): void {
public function testValidIpSessionSetsResponseWithRemoteUser(): void
{
$pool = new ArrayAdapter();
$item = $pool->getItem('ip_1.2.3.4');
$item->set('carol');
@@ -50,7 +55,8 @@ final class AllowListenerTest extends TestCase {
self::assertSame('text/plain', $response->headers->get('Content-Type'));
}
public function testNoIpSessionSetsNoResponse(): void {
public function testNoIpSessionSetsNoResponse(): void
{
$pool = new ArrayAdapter();
$config = $this->makeConfig(ipTtl: 1800);
$listener = $this->makeListener($pool, $config);
@@ -62,7 +68,8 @@ final class AllowListenerTest extends TestCase {
self::assertFalse($event->hasResponse());
}
public function testIpAccessDisabledSetsNoResponse(): void {
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');
@@ -79,7 +86,8 @@ final class AllowListenerTest extends TestCase {
self::assertFalse($event->hasResponse());
}
public function testIpAccessDisabledDoesNotCheckCache(): void {
public function testIpAccessDisabledDoesNotCheckCache(): void
{
$pool = new ArrayAdapter();
$config = $this->makeConfig(ipTtl: 0);
$listener = $this->makeListener($pool, $config);
+23 -11
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Listener;
@@ -16,7 +17,8 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
final class InterceptListenerTest extends TestCase {
final class InterceptListenerTest extends TestCase
{
use ListenerTestHelper;
private const string COOKIE_NAME = '__Host-Http-Preauth';
@@ -36,7 +38,8 @@ final class InterceptListenerTest extends TestCase {
return $listener;
}
private function makeEvent(Request $request): RequestEvent {
private function makeEvent(Request $request): RequestEvent
{
return new RequestEvent(
$this->createStub(HttpKernelInterface::class),
$request,
@@ -46,7 +49,8 @@ final class InterceptListenerTest extends TestCase {
/* ── central-auth redirect branch ─────────────────────────────────── */
public function testRedirectsToAuthSubdomainWhenHostMatchesBaseDomain(): void {
public function testRedirectsToAuthSubdomainWhenHostMatchesBaseDomain(): void
{
$domainManager = new DomainManager(true, 'auth.example.com');
$listener = $this->makeListener($domainManager);
@@ -64,7 +68,8 @@ final class InterceptListenerTest extends TestCase {
self::assertStringContainsString(urlencode('https://app.example.com/dashboard'), $location);
}
public function testDoesNotRedirectWhenAlreadyOnAuthSubdomain(): void {
public function testDoesNotRedirectWhenAlreadyOnAuthSubdomain(): void
{
$domainManager = new DomainManager(true, 'auth.example.com');
$listener = $this->makeListener($domainManager);
@@ -81,7 +86,8 @@ final class InterceptListenerTest extends TestCase {
/* ── login page rendering branch ──────────────────────────────────── */
public function testPresentsLoginPageWithUnauthorizedStatus(): void {
public function testPresentsLoginPageWithUnauthorizedStatus(): void
{
$domainManager = new DomainManager(false, '');
$listener = $this->makeListener($domainManager);
@@ -99,7 +105,8 @@ final class InterceptListenerTest extends TestCase {
self::assertStringContainsString('name="nonce"', $content);
}
public function testGeneratedNonceIsStoredInCache(): void {
public function testGeneratedNonceIsStoredInCache(): void
{
$nonceCache = new ArrayAdapter();
$domainManager = new DomainManager(false, '');
$listener = $this->makeListener($domainManager, $nonceCache);
@@ -119,7 +126,8 @@ final class InterceptListenerTest extends TestCase {
self::assertTrue(count($nonceCache->getValues()) > 0);
}
public function testLoginTemplateUsesPostFormWhenOnAuthSubdomain(): void {
public function testLoginTemplateUsesPostFormWhenOnAuthSubdomain(): void
{
$domainManager = new DomainManager(true, 'auth.example.com');
$listener = $this->makeListener($domainManager);
@@ -132,7 +140,8 @@ final class InterceptListenerTest extends TestCase {
self::assertStringContainsString('method="post"', $content);
}
public function testLoginTemplateDoesNotUsePostFormWhenNotOnAuthSubdomain(): void {
public function testLoginTemplateDoesNotUsePostFormWhenNotOnAuthSubdomain(): void
{
$domainManager = new DomainManager(false, '');
$listener = $this->makeListener($domainManager);
@@ -147,7 +156,8 @@ final class InterceptListenerTest extends TestCase {
/* ── invalid cookie pruning ───────────────────────────────────────── */
public function testInvalidCookieIsClearedWhenPresent(): void {
public function testInvalidCookieIsClearedWhenPresent(): void
{
$domainManager = new DomainManager(false, '');
$listener = $this->makeListener($domainManager);
@@ -171,7 +181,8 @@ final class InterceptListenerTest extends TestCase {
self::assertTrue($cleared, 'Expected the invalid cookie to be cleared');
}
public function testNoCookieClearingWhenNoCookiePresent(): void {
public function testNoCookieClearingWhenNoCookiePresent(): void
{
$domainManager = new DomainManager(false, '');
$listener = $this->makeListener($domainManager);
@@ -183,7 +194,8 @@ final class InterceptListenerTest extends TestCase {
self::assertSame([], $response->headers->getCookies());
}
public function testInvalidCookieUsesAuthCookieNameWithCentralAuth(): void {
public function testInvalidCookieUsesAuthCookieNameWithCentralAuth(): void
{
$domainManager = new DomainManager(true, 'auth.example.com');
$listener = $this->makeListener($domainManager);
+29 -14
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Listener;
@@ -17,7 +18,8 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
final class LoginListenerTest extends TestCase {
final class LoginListenerTest extends TestCase
{
use ListenerTestHelper;
private const string HEADER_NAME = 'X-Preauth';
@@ -39,7 +41,8 @@ final class LoginListenerTest extends TestCase {
return $listener;
}
private function makeEvent(Request $request): RequestEvent {
private function makeEvent(Request $request): RequestEvent
{
return new RequestEvent(
$this->createStub(HttpKernelInterface::class),
$request,
@@ -48,14 +51,16 @@ final class LoginListenerTest extends TestCase {
}
/** Build a base64url-encoded X-Preauth header value for a payload. */
private function encodePayload(array $data): string {
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 {
public function testNoHeaderAndNoPostReturnsEarlyWithoutResponse(): void
{
$listener = $this->makeListener();
$request = Request::create('https://example.com/', 'GET');
@@ -65,7 +70,8 @@ final class LoginListenerTest extends TestCase {
self::assertFalse($event->hasResponse());
}
public function testPostToNonAuthSubdomainReturnsEarlyWithoutResponse(): void {
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);
@@ -79,7 +85,8 @@ final class LoginListenerTest extends TestCase {
/* ── successful login via header ──────────────────────────────────── */
public function testSuccessfulLoginViaHeaderSetsResponseFromManager(): void {
public function testSuccessfulLoginViaHeaderSetsResponseFromManager(): void
{
$expected = new Response('hi alice', 200, ['Remote-User' => 'alice']);
$loginManager = $this->createStub(LoginInterface::class);
$loginManager->method('checkToken')->willReturn($expected);
@@ -99,7 +106,8 @@ final class LoginListenerTest extends TestCase {
self::assertSame($expected, $event->getResponse());
}
public function testSuccessfulLoginViaPostToAuthSubdomain(): void {
public function testSuccessfulLoginViaPostToAuthSubdomain(): void
{
$expected = new Response('hi bob', 303, ['Location' => '/']);
$loginManager = $this->createStub(LoginInterface::class);
$loginManager->method('checkToken')->willReturn($expected);
@@ -120,7 +128,8 @@ final class LoginListenerTest extends TestCase {
/* ── failed login ─────────────────────────────────────────────────── */
public function testFailedLoginReturnsJsonErrorWithNewNonce(): void {
public function testFailedLoginReturnsJsonErrorWithNewNonce(): void
{
$loginManager = $this->createStub(LoginInterface::class);
$loginManager->method('checkToken')->willReturn(null);
@@ -148,7 +157,8 @@ final class LoginListenerTest extends TestCase {
self::assertSame('alice', $body['username']);
}
public function testFailedLoginHtmlResponseWhenJsonFalse(): void {
public function testFailedLoginHtmlResponseWhenJsonFalse(): void
{
$loginManager = $this->createStub(LoginInterface::class);
$loginManager->method('checkToken')->willReturn(null);
@@ -169,7 +179,8 @@ final class LoginListenerTest extends TestCase {
self::assertStringContainsString('<form', $response->getContent());
}
public function testFailedLoginOnAuthSubdomainUsesPostForm(): void {
public function testFailedLoginOnAuthSubdomainUsesPostForm(): void
{
$loginManager = $this->createStub(LoginInterface::class);
$loginManager->method('checkToken')->willReturn(null);
@@ -194,7 +205,8 @@ final class LoginListenerTest extends TestCase {
/* ── rate-limited (blocked) login ─────────────────────────────────── */
public function testRateLimitedLoginReturnsTeapotWhenTeapotEnabled(): void {
public function testRateLimitedLoginReturnsTeapotWhenTeapotEnabled(): void
{
$loginManager = $this->createStub(LoginInterface::class);
$loginManager->method('checkToken')->willReturn(null);
@@ -220,7 +232,8 @@ final class LoginListenerTest extends TestCase {
self::assertSame('Teapot', $body['message']);
}
public function testRateLimitedLoginReturnsTooManyRequestsWhenTeapotDisabled(): void {
public function testRateLimitedLoginReturnsTooManyRequestsWhenTeapotDisabled(): void
{
$loginManager = $this->createStub(LoginInterface::class);
$loginManager->method('checkToken')->willReturn(null);
@@ -252,7 +265,8 @@ final class LoginListenerTest extends TestCase {
/* ── invalid payload handling ─────────────────────────────────────── */
public function testInvalidHeaderPayloadStillRecordsFailureAndResponds(): void {
public function testInvalidHeaderPayloadStillRecordsFailureAndResponds(): void
{
$loginManager = $this->createMock(LoginInterface::class);
// checkToken should not be called with a null payload
$loginManager->expects(self::never())->method('checkToken');
@@ -272,7 +286,8 @@ final class LoginListenerTest extends TestCase {
self::assertSame(Response::HTTP_UNAUTHORIZED, $event->getResponse()->getStatusCode());
}
public function testPostWithoutRequiredFieldsDoesNotAttemptLogin(): void {
public function testPostWithoutRequiredFieldsDoesNotAttemptLogin(): void
{
$loginManager = $this->createMock(LoginInterface::class);
$loginManager->expects(self::never())->method('checkToken');
+13 -6
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Listener;
@@ -13,7 +14,8 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
final class RejectListenerTest extends TestCase {
final class RejectListenerTest extends TestCase
{
use ListenerTestHelper;
private function makeListener(
@@ -29,7 +31,8 @@ final class RejectListenerTest extends TestCase {
return $listener;
}
private function makeEvent(Request $request): RequestEvent {
private function makeEvent(Request $request): RequestEvent
{
return new RequestEvent(
$this->createStub(HttpKernelInterface::class),
$request,
@@ -37,7 +40,8 @@ final class RejectListenerTest extends TestCase {
);
}
public function testBlockedRequestReturnsTeapotWhenTeapotEnabled(): void {
public function testBlockedRequestReturnsTeapotWhenTeapotEnabled(): void
{
$listener = $this->makeListener(teapot: true, remainingTokens: 0);
$request = Request::create('/', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']);
@@ -50,7 +54,8 @@ final class RejectListenerTest extends TestCase {
self::assertSame('text/html', $response->headers->get('Content-Type'));
}
public function testBlockedRequestReturnsTooManyRequestsWhenTeapotDisabled(): void {
public function testBlockedRequestReturnsTooManyRequestsWhenTeapotDisabled(): void
{
$listener = $this->makeListener(teapot: false, remainingTokens: 0);
$request = Request::create('/', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']);
@@ -63,7 +68,8 @@ final class RejectListenerTest extends TestCase {
self::assertSame('text/html', $response->headers->get('Content-Type'));
}
public function testUnblockedRequestSetsNoResponse(): void {
public function testUnblockedRequestSetsNoResponse(): void
{
$listener = $this->makeListener(remainingTokens: 5);
$request = Request::create('/', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']);
@@ -74,7 +80,8 @@ final class RejectListenerTest extends TestCase {
self::assertFalse($event->hasResponse());
}
public function testBlockedResponseContainsErrorTemplateContent(): void {
public function testBlockedResponseContainsErrorTemplateContent(): void
{
$listener = $this->makeListener(teapot: true, remainingTokens: 0);
$request = Request::create('/', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']);
+55 -27
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit;
@@ -8,20 +9,24 @@ use OutOfBoundsException;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
final class MonitorCacheKeysTest extends TestCase {
private function wrap(?ArrayAdapter $pool = null): MonitorCacheKeys {
final class MonitorCacheKeysTest extends TestCase
{
private function wrap(?ArrayAdapter $pool = null): MonitorCacheKeys
{
$pool ??= new ArrayAdapter();
return new MonitorCacheKeys($pool);
}
public function testConstructorInitializesEmptyPool(): void {
public function testConstructorInitializesEmptyPool(): void
{
$monitor = $this->wrap();
self::assertSame([], $monitor->getKeys());
self::assertSame([], $monitor->getChanges());
}
public function testSaveAddsKeyAndTracksChange(): void {
public function testSaveAddsKeyAndTracksChange(): void
{
$monitor = $this->wrap();
$item = $monitor->getItem('alpha');
$item->set('value');
@@ -31,7 +36,8 @@ final class MonitorCacheKeysTest extends TestCase {
self::assertSame(['alpha' => MonitorCacheKeys::UPDATED], $monitor->getChanges());
}
public function testSaveDeferredThenCommitAddsKey(): void {
public function testSaveDeferredThenCommitAddsKey(): void
{
$monitor = $this->wrap();
$item = $monitor->getItem('beta');
$item->set('value');
@@ -42,7 +48,8 @@ final class MonitorCacheKeysTest extends TestCase {
self::assertSame(['beta' => MonitorCacheKeys::UPDATED], $monitor->getChanges());
}
public function testGetItemReturnsUnderlyingItem(): void {
public function testGetItemReturnsUnderlyingItem(): void
{
$monitor = $this->wrap();
$item = $monitor->getItem('mykey');
$item->set('data');
@@ -53,7 +60,8 @@ final class MonitorCacheKeysTest extends TestCase {
self::assertSame('data', $fetched->get());
}
public function testGetItemsReturnsMultipleItems(): void {
public function testGetItemsReturnsMultipleItems(): void
{
$monitor = $this->wrap();
$a = $monitor->getItem('a');
$a->set(1);
@@ -70,7 +78,8 @@ final class MonitorCacheKeysTest extends TestCase {
self::assertSame(['a' => 1, 'b' => 2], $keys);
}
public function testHasItemReturnsTrueForExistingKey(): void {
public function testHasItemReturnsTrueForExistingKey(): void
{
$monitor = $this->wrap();
$item = $monitor->getItem('exists');
$item->set('v');
@@ -80,7 +89,8 @@ final class MonitorCacheKeysTest extends TestCase {
self::assertFalse($monitor->hasItem('missing'));
}
public function testDeleteItemRemovesKeyAndTracksRemoval(): void {
public function testDeleteItemRemovesKeyAndTracksRemoval(): void
{
$monitor = $this->wrap();
$item = $monitor->getItem('doomed');
$item->set('v');
@@ -93,7 +103,8 @@ final class MonitorCacheKeysTest extends TestCase {
self::assertFalse($monitor->hasItem('doomed'));
}
public function testDeleteItemOnMissingKeyIsNoop(): void {
public function testDeleteItemOnMissingKeyIsNoop(): void
{
$monitor = $this->wrap();
$result = $monitor->deleteItem('nonexistent');
@@ -102,7 +113,8 @@ final class MonitorCacheKeysTest extends TestCase {
self::assertSame([], $monitor->getKeys());
}
public function testDeleteItemsRemovesMultipleKeys(): void {
public function testDeleteItemsRemovesMultipleKeys(): void
{
$monitor = $this->wrap();
foreach (['x', 'y', 'z'] as $key) {
$item = $monitor->getItem($key);
@@ -118,7 +130,8 @@ final class MonitorCacheKeysTest extends TestCase {
self::assertSame(MonitorCacheKeys::REMOVED, $changes['y']);
}
public function testDeleteItemsWithMissingKeysStillReturnsTrue(): void {
public function testDeleteItemsWithMissingKeysStillReturnsTrue(): void
{
$monitor = $this->wrap();
$result = $monitor->deleteItems(['ghost1', 'ghost2']);
@@ -126,7 +139,8 @@ final class MonitorCacheKeysTest extends TestCase {
self::assertTrue($result);
}
public function testClearWipesPoolWhenNotEmpty(): void {
public function testClearWipesPoolWhenNotEmpty(): void
{
$monitor = $this->wrap();
$item = $monitor->getItem('keep');
$item->set('v');
@@ -138,7 +152,8 @@ final class MonitorCacheKeysTest extends TestCase {
self::assertSame([], $monitor->getKeys());
}
public function testClearIsNoopWhenEmpty(): void {
public function testClearIsNoopWhenEmpty(): void
{
$monitor = $this->wrap();
$result = $monitor->clear();
@@ -146,7 +161,8 @@ final class MonitorCacheKeysTest extends TestCase {
self::assertTrue($result);
}
public function testMarkCleanResetsChangeList(): void {
public function testMarkCleanResetsChangeList(): void
{
$monitor = $this->wrap();
$item = $monitor->getItem('temp');
$item->set('v');
@@ -160,13 +176,15 @@ final class MonitorCacheKeysTest extends TestCase {
self::assertSame(['temp'], $monitor->getKeys());
}
public function testCommitPassesThrough(): void {
public function testCommitPassesThrough(): void
{
$monitor = $this->wrap();
self::assertTrue($monitor->commit());
}
public function testSaveKeyListThrowsOutOfBoundsException(): void {
public function testSaveKeyListThrowsOutOfBoundsException(): void
{
$monitor = $this->wrap();
$item = $monitor->getItem('__key_list');
@@ -174,7 +192,8 @@ final class MonitorCacheKeysTest extends TestCase {
$monitor->save($item);
}
public function testSaveChangeListThrowsOutOfBoundsException(): void {
public function testSaveChangeListThrowsOutOfBoundsException(): void
{
$monitor = $this->wrap();
$item = $monitor->getItem('__chg_list');
@@ -182,35 +201,40 @@ final class MonitorCacheKeysTest extends TestCase {
$monitor->save($item);
}
public function testDeleteKeyListThrowsOutOfBoundsException(): void {
public function testDeleteKeyListThrowsOutOfBoundsException(): void
{
$monitor = $this->wrap();
$this->expectException(OutOfBoundsException::class);
$monitor->deleteItem('__key_list');
}
public function testDeleteChangeListThrowsOutOfBoundsException(): void {
public function testDeleteChangeListThrowsOutOfBoundsException(): void
{
$monitor = $this->wrap();
$this->expectException(OutOfBoundsException::class);
$monitor->deleteItem('__chg_list');
}
public function testDeleteItemsWithKeyListThrowsOutOfBoundsException(): void {
public function testDeleteItemsWithKeyListThrowsOutOfBoundsException(): void
{
$monitor = $this->wrap();
$this->expectException(OutOfBoundsException::class);
$monitor->deleteItems(['safe', '__key_list']);
}
public function testDeleteItemsWithChangeListThrowsOutOfBoundsException(): void {
public function testDeleteItemsWithChangeListThrowsOutOfBoundsException(): void
{
$monitor = $this->wrap();
$this->expectException(OutOfBoundsException::class);
$monitor->deleteItems(['__chg_list']);
}
public function testSaveDeferredOnKeyListThrowsOutOfBoundsException(): void {
public function testSaveDeferredOnKeyListThrowsOutOfBoundsException(): void
{
$monitor = $this->wrap();
$item = $monitor->getItem('safe');
$item->set('value');
@@ -223,7 +247,8 @@ final class MonitorCacheKeysTest extends TestCase {
$monitor->saveDeferred($keyListItem);
}
public function testSaveDeferredOnChangeListThrowsOutOfBoundsException(): void {
public function testSaveDeferredOnChangeListThrowsOutOfBoundsException(): void
{
$monitor = $this->wrap();
$changeListItem = $monitor->getItem('__chg_list');
@@ -231,7 +256,8 @@ final class MonitorCacheKeysTest extends TestCase {
$monitor->saveDeferred($changeListItem);
}
public function testGetKeysReturnsEmptyArrayWhenKeyListMissing(): void {
public function testGetKeysReturnsEmptyArrayWhenKeyListMissing(): void
{
// If the underlying pool loses its key list, getKeys should return []
$pool = new ArrayAdapter();
$monitor = new MonitorCacheKeys($pool);
@@ -249,7 +275,8 @@ final class MonitorCacheKeysTest extends TestCase {
self::assertSame([], $monitor2->getKeys());
}
public function testDeleteItemReturnsTrueForExistingKey(): void {
public function testDeleteItemReturnsTrueForExistingKey(): void
{
$monitor = $this->wrap();
$item = $monitor->getItem('to-delete');
$item->set('value');
@@ -259,7 +286,8 @@ final class MonitorCacheKeysTest extends TestCase {
self::assertNotContains('to-delete', $monitor->getKeys());
}
public function testDeleteItemsReturnsTrue(): void {
public function testDeleteItemsReturnsTrue(): void
{
$monitor = $this->wrap();
foreach (['a', 'b', 'c'] as $key) {
$item = $monitor->getItem($key);
+21 -10
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit;
@@ -8,8 +9,10 @@ use App\PersistCache;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
final class PersistCacheTest extends TestCase {
public function testBootWithEmptyStorageIsNoop(): void {
final class PersistCacheTest extends TestCase
{
public function testBootWithEmptyStorageIsNoop(): void
{
$sessionCache = new ArrayAdapter();
$sessionStorage = new ArrayAdapter();
@@ -21,7 +24,8 @@ final class PersistCacheTest extends TestCase {
self::assertSame([], $monitor->getKeys());
}
public function testBootLoadsFromStorageIntoCache(): void {
public function testBootLoadsFromStorageIntoCache(): void
{
$sessionCache = new ArrayAdapter();
$sessionStorage = new ArrayAdapter();
@@ -43,7 +47,8 @@ final class PersistCacheTest extends TestCase {
self::assertSame([], $cacheMonitor->getChanges());
}
public function testBootDoesNotReloadWhenCacheAlreadyWarm(): void {
public function testBootDoesNotReloadWhenCacheAlreadyWarm(): void
{
$sessionCache = new ArrayAdapter();
$sessionStorage = new ArrayAdapter();
@@ -68,7 +73,8 @@ final class PersistCacheTest extends TestCase {
self::assertNotContains('cookie_new', $monitor->getKeys());
}
public function testPersistWritesChangesToStorage(): void {
public function testPersistWritesChangesToStorage(): void
{
$sessionCache = new ArrayAdapter();
$sessionStorage = new ArrayAdapter();
@@ -89,7 +95,8 @@ final class PersistCacheTest extends TestCase {
self::assertSame('user2', $storageMonitor->getItem('cookie_xyz')->get());
}
public function testPersistHandlesRemovals(): void {
public function testPersistHandlesRemovals(): void
{
$sessionCache = new ArrayAdapter();
$sessionStorage = new ArrayAdapter();
@@ -114,7 +121,8 @@ final class PersistCacheTest extends TestCase {
self::assertNotContains('cookie_to_remove', $storageMonitor->getKeys());
}
public function testPersistIsNoopWhenNoChanges(): void {
public function testPersistIsNoopWhenNoChanges(): void
{
$sessionCache = new ArrayAdapter();
$sessionStorage = new ArrayAdapter();
@@ -126,7 +134,8 @@ final class PersistCacheTest extends TestCase {
self::assertSame([], $storageMonitor->getKeys());
}
public function testFullBootModifyPersistCycle(): void {
public function testFullBootModifyPersistCycle(): void
{
$sessionCache = new ArrayAdapter();
$sessionStorage = new ArrayAdapter();
@@ -151,7 +160,8 @@ final class PersistCacheTest extends TestCase {
self::assertSame('cycled-user', $monitor->getItem('cookie_cycle')->get());
}
public function testPersistHandlesMixedUpdatesAndRemovals(): void {
public function testPersistHandlesMixedUpdatesAndRemovals(): void
{
$sessionCache = new ArrayAdapter();
$sessionStorage = new ArrayAdapter();
@@ -184,7 +194,8 @@ final class PersistCacheTest extends TestCase {
self::assertNotContains('cookie_remove', $storageMonitor->getKeys());
}
public function testMultipleBootModifyPersistCycles(): void {
public function testMultipleBootModifyPersistCycles(): void
{
$sessionCache = new ArrayAdapter();
$sessionStorage = new ArrayAdapter();
+39 -19
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Service;
@@ -9,10 +10,12 @@ use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
final class BackupCodeManagerTest extends TestCase {
final class BackupCodeManagerTest extends TestCase
{
use TotpTestHelper;
private function makeManager(?ArrayAdapter $pool = null): BackupCodeManager {
private function makeManager(?ArrayAdapter $pool = null): BackupCodeManager
{
$pool ??= new ArrayAdapter();
$manager = new BackupCodeManager($pool);
$manager->setConfig($this->makeConfig());
@@ -20,7 +23,8 @@ final class BackupCodeManagerTest extends TestCase {
return $manager;
}
public function testGenerateReturnsRequestedCount(): void {
public function testGenerateReturnsRequestedCount(): void
{
$manager = $this->makeManager();
$codes = $manager->generate(5);
@@ -33,7 +37,8 @@ final class BackupCodeManagerTest extends TestCase {
}
}
public function testGenerateDefaultCount(): void {
public function testGenerateDefaultCount(): void
{
$manager = $this->makeManager();
$codes = $manager->generate();
@@ -41,7 +46,8 @@ final class BackupCodeManagerTest extends TestCase {
self::assertCount(10, $codes);
}
public function testGenerateZeroReturnsEmptyArray(): void {
public function testGenerateZeroReturnsEmptyArray(): void
{
$manager = $this->makeManager();
$codes = $manager->generate(0);
@@ -49,7 +55,8 @@ final class BackupCodeManagerTest extends TestCase {
self::assertSame([], $codes);
}
public function testGeneratedCodesAreStoredInCache(): void {
public function testGeneratedCodesAreStoredInCache(): void
{
$pool = new ArrayAdapter();
$manager = $this->makeManager($pool);
@@ -65,7 +72,8 @@ final class BackupCodeManagerTest extends TestCase {
}
}
public function testGeneratedCodesHaveFarFutureExpiry(): void {
public function testGeneratedCodesHaveFarFutureExpiry(): void
{
$pool = new ArrayAdapter();
$manager = $this->makeManager($pool);
@@ -77,7 +85,8 @@ final class BackupCodeManagerTest extends TestCase {
self::assertGreaterThan((new \DateTimeImmutable('+10 years'))->getTimestamp(), (int) $expiry);
}
public function testVerifyAndConsumeValidCode(): void {
public function testVerifyAndConsumeValidCode(): void
{
$manager = $this->makeManager();
$codes = $manager->generate(2);
@@ -86,7 +95,8 @@ final class BackupCodeManagerTest extends TestCase {
self::assertTrue($manager->verifyAndConsume($code));
}
public function testVerifyAndConsumeMarksCodeAsUsed(): void {
public function testVerifyAndConsumeMarksCodeAsUsed(): void
{
$pool = new ArrayAdapter();
$manager = $this->makeManager($pool);
$codes = $manager->generate(1);
@@ -99,13 +109,15 @@ final class BackupCodeManagerTest extends TestCase {
self::assertFalse($manager->verifyAndConsume($code));
}
public function testVerifyAndConsumeInvalidCode(): void {
public function testVerifyAndConsumeInvalidCode(): void
{
$manager = $this->makeManager();
self::assertFalse($manager->verifyAndConsume('nonexistent_code'));
}
public function testVerifyAndConsumeIsCaseInsensitive(): void {
public function testVerifyAndConsumeIsCaseInsensitive(): void
{
$manager = $this->makeManager();
$codes = $manager->generate(1);
$code = $codes[0];
@@ -114,7 +126,8 @@ final class BackupCodeManagerTest extends TestCase {
self::assertTrue($manager->verifyAndConsume(strtoupper($code)));
}
public function testVerifyAndConsumeStripsInvalidCharacters(): void {
public function testVerifyAndConsumeStripsInvalidCharacters(): void
{
$manager = $this->makeManager();
$codes = $manager->generate(1);
$code = $codes[0];
@@ -123,7 +136,8 @@ final class BackupCodeManagerTest extends TestCase {
self::assertTrue($manager->verifyAndConsume(' ' . $code . '!!'));
}
public function testExpireRemovesAllBackupCodes(): void {
public function testExpireRemovesAllBackupCodes(): void
{
$pool = new ArrayAdapter();
$manager = $this->makeManager($pool);
$codes = $manager->generate(5);
@@ -136,7 +150,8 @@ final class BackupCodeManagerTest extends TestCase {
}
}
public function testExpireWhenNoBackupCodesIsNoop(): void {
public function testExpireWhenNoBackupCodesIsNoop(): void
{
$pool = new ArrayAdapter();
$manager = $this->makeManager($pool);
@@ -147,7 +162,8 @@ final class BackupCodeManagerTest extends TestCase {
self::assertTrue(true);
}
public function testExpireRemovesOnlyBackupPrefixedKeys(): void {
public function testExpireRemovesOnlyBackupPrefixedKeys(): void
{
$pool = new ArrayAdapter();
$manager = $this->makeManager($pool);
@@ -169,14 +185,16 @@ final class BackupCodeManagerTest extends TestCase {
}
}
public function testVerifyAndConsumeEmptyStringReturnsFalse(): void {
public function testVerifyAndConsumeEmptyStringReturnsFalse(): void
{
$manager = $this->makeManager();
// empty string after preg_replace becomes 'backup_' with nothing after it
self::assertFalse($manager->verifyAndConsume(''));
}
public function testVerifyAndConsumeCodeWithValueFalseReturnsFalse(): void {
public function testVerifyAndConsumeCodeWithValueFalseReturnsFalse(): void
{
$pool = new ArrayAdapter();
$manager = $this->makeManager($pool);
$codes = $manager->generate(1);
@@ -195,7 +213,8 @@ final class BackupCodeManagerTest extends TestCase {
self::assertFalse($manager->verifyAndConsume($code));
}
public function testGenerateProducesUniqueCodes(): void {
public function testGenerateProducesUniqueCodes(): void
{
$manager = $this->makeManager();
$codes = $manager->generate(50);
@@ -204,7 +223,8 @@ final class BackupCodeManagerTest extends TestCase {
self::assertCount(50, array_unique($codes), 'All generated codes should be unique');
}
public function testGenerateCodeLengthIsDigitsPlusTwo(): void {
public function testGenerateCodeLengthIsDigitsPlusTwo(): void
{
$manager = $this->makeManager();
$codes = $manager->generate(1);
+73 -36
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Service;
@@ -6,44 +7,52 @@ namespace App\Tests\Unit\Service;
use App\Service\DomainManager;
use PHPUnit\Framework\TestCase;
final class DomainManagerTest extends TestCase {
private function createManager(bool $subdomainRedirect, string $authSubdomain): DomainManager {
final class DomainManagerTest extends TestCase
{
private function createManager(bool $subdomainRedirect, string $authSubdomain): DomainManager
{
return new DomainManager($subdomainRedirect, $authSubdomain);
}
/* ── authBase / getAuthSubdomain ─────────────────────────────────────── */
public function testAuthBaseIsNullWhenSubdomainRedirectIsDisabled(): void {
public function testAuthBaseIsNullWhenSubdomainRedirectIsDisabled(): void
{
$manager = $this->createManager(false, 'auth.example.com');
self::assertNull($manager->authBase());
self::assertNull($manager->getAuthSubdomain());
}
public function testAuthBaseIsNullWhenAuthSubdomainIsEmpty(): void {
public function testAuthBaseIsNullWhenAuthSubdomainIsEmpty(): void
{
$manager = $this->createManager(true, '');
self::assertNull($manager->authBase());
self::assertNull($manager->getAuthSubdomain());
}
public function testAuthBaseExtractsSimpleDomain(): void {
public function testAuthBaseExtractsSimpleDomain(): void
{
$manager = $this->createManager(true, 'auth.example.com');
self::assertSame('example.com', $manager->authBase());
self::assertSame('auth.example.com', $manager->getAuthSubdomain());
}
public function testAuthBaseExtractsMultiPartTld(): void {
public function testAuthBaseExtractsMultiPartTld(): void
{
$manager = $this->createManager(true, 'auth.example.co.uk');
self::assertSame('example.co.uk', $manager->authBase());
self::assertSame('auth.example.co.uk', $manager->getAuthSubdomain());
}
public function testAuthBaseIsNullForLocalhostAuth(): void {
public function testAuthBaseIsNullForLocalhostAuth(): void
{
$manager = $this->createManager(true, 'localhost');
self::assertNull($manager->authBase());
self::assertNull($manager->getAuthSubdomain());
}
public function testAuthBaseIsNullForIpAuth(): void {
public function testAuthBaseIsNullForIpAuth(): void
{
$manager = $this->createManager(true, '192.168.1.1');
self::assertNull($manager->authBase());
self::assertNull($manager->getAuthSubdomain());
@@ -51,90 +60,105 @@ final class DomainManagerTest extends TestCase {
/* ── validReturn ──────────────────────────────────────────────────────── */
public function testValidReturnAcceptsAnyUrlWhenNoSubdomain(): void {
public function testValidReturnAcceptsAnyUrlWhenNoSubdomain(): void
{
$manager = $this->createManager(false, '');
self::assertTrue($manager->validReturn('https://evil.com/page'));
self::assertTrue($manager->validReturn('https://example.com/ok'));
}
public function testValidReturnRejectsInvalidUrl(): void {
public function testValidReturnRejectsInvalidUrl(): void
{
$manager = $this->createManager(true, 'auth.example.com');
self::assertFalse($manager->validReturn('not-a-url'));
self::assertFalse($manager->validReturn(''));
}
public function testValidReturnAcceptsSameBaseDomain(): void {
public function testValidReturnAcceptsSameBaseDomain(): void
{
$manager = $this->createManager(true, 'auth.example.com');
self::assertTrue($manager->validReturn('https://app.example.com/dashboard'));
self::assertTrue($manager->validReturn('https://example.com/'));
}
public function testValidReturnRejectsDifferentBaseDomain(): void {
public function testValidReturnRejectsDifferentBaseDomain(): void
{
$manager = $this->createManager(true, 'auth.example.com');
self::assertFalse($manager->validReturn('https://evil.com/phish'));
self::assertFalse($manager->validReturn('https://other-example.com/'));
}
public function testValidReturnHandlesCoUkTld(): void {
public function testValidReturnHandlesCoUkTld(): void
{
$manager = $this->createManager(true, 'auth.example.co.uk');
self::assertTrue($manager->validReturn('https://www.example.co.uk/'));
self::assertFalse($manager->validReturn('https://example.com/'));
}
public function testValidReturnRejectsUrlWithoutHost(): void {
public function testValidReturnRejectsUrlWithoutHost(): void
{
$manager = $this->createManager(true, 'auth.example.com');
self::assertFalse($manager->validReturn('mailto:test@example.com'));
}
/* ── matchesAuth ──────────────────────────────────────────────────────── */
public function testMatchesAuthIsFalseWhenSubdomainRedirectDisabled(): void {
public function testMatchesAuthIsFalseWhenSubdomainRedirectDisabled(): void
{
$manager = $this->createManager(false, 'auth.example.com');
self::assertFalse($manager->matchesAuth('example.com'));
self::assertFalse($manager->matchesAuth('app.example.com'));
}
public function testMatchesAuthIsFalseWhenAuthSubdomainIsEmpty(): void {
public function testMatchesAuthIsFalseWhenAuthSubdomainIsEmpty(): void
{
$manager = $this->createManager(true, '');
self::assertFalse($manager->matchesAuth('example.com'));
}
public function testMatchesAuthMatchesSameBaseDomain(): void {
public function testMatchesAuthMatchesSameBaseDomain(): void
{
$manager = $this->createManager(true, 'auth.example.com');
self::assertTrue($manager->matchesAuth('example.com'));
self::assertTrue($manager->matchesAuth('app.example.com'));
}
public function testMatchesAuthRejectsDifferentBaseDomain(): void {
public function testMatchesAuthRejectsDifferentBaseDomain(): void
{
$manager = $this->createManager(true, 'auth.example.com');
self::assertFalse($manager->matchesAuth('evil.com'));
self::assertFalse($manager->matchesAuth('example.org'));
}
public function testMatchesAuthHandlesMultiPartTld(): void {
public function testMatchesAuthHandlesMultiPartTld(): void
{
$manager = $this->createManager(true, 'auth.example.co.uk');
self::assertTrue($manager->matchesAuth('www.example.co.uk'));
self::assertFalse($manager->matchesAuth('example.com'));
}
public function testMatchesAuthRejectsIpHost(): void {
public function testMatchesAuthRejectsIpHost(): void
{
$manager = $this->createManager(true, 'auth.example.com');
self::assertFalse($manager->matchesAuth('192.168.1.1'));
}
public function testMatchesAuthRejectsLocalhost(): void {
public function testMatchesAuthRejectsLocalhost(): void
{
$manager = $this->createManager(true, 'auth.example.com');
self::assertFalse($manager->matchesAuth('localhost'));
}
/* ── baseDomain edge cases via matchesAuth ────────────────────────────── */
public function testMatchesAuthWithDeepSubdomain(): void {
public function testMatchesAuthWithDeepSubdomain(): void
{
$manager = $this->createManager(true, 'auth.example.com');
self::assertTrue($manager->matchesAuth('a.b.c.example.com'));
}
public function testMatchesAuthWithTwoPartDomain(): void {
public function testMatchesAuthWithTwoPartDomain(): void
{
/* for a 2-part auth subdomain, the baseDomain retains both parts */
$manager = $this->createManager(true, 'auth.local');
self::assertSame('auth.local', $manager->authBase());
@@ -145,7 +169,8 @@ final class DomainManagerTest extends TestCase {
/* ── TLD table coverage ──────────────────────────────────────────────── */
public function testMatchesAuthWithComAuTld(): void {
public function testMatchesAuthWithComAuTld(): void
{
// com.au is NOT in the TLD table (table has au? no, it doesn't),
// so it's treated as a standard 2-part TLD: base = com.au
$manager = $this->createManager(true, 'auth.example.com.au');
@@ -154,7 +179,8 @@ final class DomainManagerTest extends TestCase {
self::assertFalse($manager->matchesAuth('example.com'));
}
public function testMatchesAuthWithCoJpTld(): void {
public function testMatchesAuthWithCoJpTld(): void
{
// co.jp is NOT in the TLD table (table has jpn under com, not jp under co)
// so base = co.jp
$manager = $this->createManager(true, 'auth.example.co.jp');
@@ -162,7 +188,8 @@ final class DomainManagerTest extends TestCase {
self::assertTrue($manager->matchesAuth('www.example.co.jp'));
}
public function testMatchesAuthWithComBrTld(): void {
public function testMatchesAuthWithComBrTld(): void
{
// com.br: TLD table has com => [br], meaning *.br.com is multi-part
// but com.br has last=br, TLD['br'] doesn't exist, so base = com.br
$manager = $this->createManager(true, 'auth.example.com.br');
@@ -170,35 +197,40 @@ final class DomainManagerTest extends TestCase {
self::assertTrue($manager->matchesAuth('app.example.com.br'));
}
public function testMatchesAuthWithCoNzTld(): void {
public function testMatchesAuthWithCoNzTld(): void
{
// co.nz is NOT in the TLD table (nz => [co,net,org], so *.co.nz IS multi-part)
$manager = $this->createManager(true, 'auth.example.co.nz');
self::assertSame('example.co.nz', $manager->authBase());
self::assertTrue($manager->matchesAuth('sub.example.co.nz'));
}
public function testMatchesAuthWithComMxTld(): void {
public function testMatchesAuthWithComMxTld(): void
{
// com.mx is NOT in the TLD table (mx => [com,net,org], so *.com.mx IS multi-part)
$manager = $this->createManager(true, 'auth.example.com.mx');
self::assertSame('example.com.mx', $manager->authBase());
self::assertTrue($manager->matchesAuth('app.example.com.mx'));
}
public function testMatchesAuthWithCoInTld(): void {
public function testMatchesAuthWithCoInTld(): void
{
// co.in: in => [co,...], so *.co.in IS multi-part
$manager = $this->createManager(true, 'auth.example.co.in');
self::assertSame('example.co.in', $manager->authBase());
self::assertTrue($manager->matchesAuth('app.example.co.in'));
}
public function testMatchesAuthWithBrComTld(): void {
public function testMatchesAuthWithBrComTld(): void
{
// br.com: TLD table has com => [br], so *.br.com IS multi-part
$manager = $this->createManager(true, 'auth.example.br.com');
self::assertSame('example.br.com', $manager->authBase());
self::assertTrue($manager->matchesAuth('app.example.br.com'));
}
public function testSimpleTldNotTreatedAsMultiPart(): void {
public function testSimpleTldNotTreatedAsMultiPart(): void
{
// example.com is a standard 2-part domain, not multi-part
$manager = $this->createManager(true, 'auth.example.com');
self::assertSame('example.com', $manager->authBase());
@@ -208,7 +240,8 @@ final class DomainManagerTest extends TestCase {
/* ── baseDomain edge cases ───────────────────────────────────────────── */
public function testMatchesAuthWithSingleLabelHost(): void {
public function testMatchesAuthWithSingleLabelHost(): void
{
// a single-label domain (not localhost, not IP) has baseLength 1
// so 'myhost' has baseDomain 'myhost', while 'auth.local' has base 'auth.local'
// they won't match unless the auth subdomain itself is single-label
@@ -219,22 +252,26 @@ final class DomainManagerTest extends TestCase {
self::assertTrue($manager->matchesAuth('app.auth.local'));
}
public function testMatchesAuthWithEmptyStringHost(): void {
public function testMatchesAuthWithEmptyStringHost(): void
{
$manager = $this->createManager(true, 'auth.example.com');
self::assertFalse($manager->matchesAuth(''));
}
public function testValidReturnAcceptsUrlWithPort(): void {
public function testValidReturnAcceptsUrlWithPort(): void
{
$manager = $this->createManager(true, 'auth.example.com');
self::assertTrue($manager->validReturn('https://example.com:8080/path'));
}
public function testValidReturnAcceptsUrlWithoutPath(): void {
public function testValidReturnAcceptsUrlWithoutPath(): void
{
$manager = $this->createManager(true, 'auth.example.com');
self::assertTrue($manager->validReturn('https://example.com'));
}
public function testValidReturnRejectsDifferentDomainWithPort(): void {
public function testValidReturnRejectsDifferentDomainWithPort(): void
{
$manager = $this->createManager(true, 'auth.example.com');
self::assertFalse($manager->validReturn('https://evil.com:8080/path'));
}
+41 -20
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Service;
@@ -18,7 +19,8 @@ use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\HttpException;
final class LoginManagerTest extends TestCase {
final class LoginManagerTest extends TestCase
{
use TotpTestHelper;
use StringTrait;
@@ -62,7 +64,8 @@ final class LoginManagerTest extends TestCase {
}
/** Inject a nonce directly into the manager's nonce cache. */
private function insertNonce(LoginManager $manager, string $nonce): string {
private function insertNonce(LoginManager $manager, string $nonce): string
{
$reflection = new \ReflectionProperty(LoginManager::class, 'nonceCache');
$nonceCache = $reflection->getValue($manager);
@@ -74,7 +77,8 @@ final class LoginManagerTest extends TestCase {
return $nonce;
}
public function testCheckTokenReturnsNullForInvalidTotp(): void {
public function testCheckTokenReturnsNullForInvalidTotp(): void
{
$manager = $this->makeLoginManager();
$payload = $this->makePayloadWithNonce($manager, token: 'wrong-code');
@@ -85,7 +89,8 @@ final class LoginManagerTest extends TestCase {
self::assertNull($manager->checkToken($payload, $request));
}
public function testCheckTokenReturnsNullForSpentNonce(): void {
public function testCheckTokenReturnsNullForSpentNonce(): void
{
$manager = $this->makeLoginManager();
$payload = $this->makePayloadWithNonce($manager);
@@ -103,7 +108,8 @@ final class LoginManagerTest extends TestCase {
self::assertNull($manager->checkToken($payload, $request));
}
public function testCheckTokenReturnsNullForMissingNonce(): void {
public function testCheckTokenReturnsNullForMissingNonce(): void
{
$manager = $this->makeLoginManager();
$this->backupCodeManager->method('verifyAndConsume')->willReturn(false);
@@ -120,7 +126,8 @@ final class LoginManagerTest extends TestCase {
self::assertNull($manager->checkToken($payload, $request));
}
public function testSuccessfulTotpLoginWithCookieScopeReturnsRedirect(): void {
public function testSuccessfulTotpLoginWithCookieScopeReturnsRedirect(): void
{
$manager = $this->makeLoginManager();
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie);
@@ -136,7 +143,8 @@ final class LoginManagerTest extends TestCase {
self::assertTrue($response->headers->has('Set-Cookie'));
}
public function testSuccessfulLoginWithNoneScopeReturnsPlainResponse(): void {
public function testSuccessfulLoginWithNoneScopeReturnsPlainResponse(): void
{
$manager = $this->makeLoginManager();
$payload = $this->makePayloadWithNonce($manager, scope: Scope::None);
@@ -154,7 +162,8 @@ final class LoginManagerTest extends TestCase {
self::assertFalse($response->headers->has('Location'));
}
public function testSuccessfulLoginSetsRemoteUserHeader(): void {
public function testSuccessfulLoginSetsRemoteUserHeader(): void
{
$manager = $this->makeLoginManager();
$payload = $this->makePayloadWithNonce($manager, id: 'alice', scope: Scope::None);
@@ -168,7 +177,8 @@ final class LoginManagerTest extends TestCase {
self::assertSame('alice', $response->headers->get('Remote-User'));
}
public function testSuccessfulLoginJsonResponse(): void {
public function testSuccessfulLoginJsonResponse(): void
{
$manager = $this->makeLoginManager();
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie, token: null);
$payload->json = true;
@@ -185,7 +195,8 @@ final class LoginManagerTest extends TestCase {
self::assertSame('Login successful', $body['message']);
}
public function testSuccessfulLoginHtmlResponse(): void {
public function testSuccessfulLoginHtmlResponse(): void
{
$manager = $this->makeLoginManager();
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie);
$payload->json = false;
@@ -200,7 +211,8 @@ final class LoginManagerTest extends TestCase {
self::assertSame('text/html', $response->headers->get('Content-Type'));
}
public function testSuccessfulLoginWithReturnUrl(): void {
public function testSuccessfulLoginWithReturnUrl(): void
{
$manager = $this->makeLoginManager();
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie);
@@ -214,7 +226,8 @@ final class LoginManagerTest extends TestCase {
self::assertSame('https://example.com/app', $response->headers->get('Location'));
}
public function testSuccessfulLoginWithInvalidReturnFallsBackToPath(): void {
public function testSuccessfulLoginWithInvalidReturnFallsBackToPath(): void
{
$manager = $this->makeLoginManager();
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie);
@@ -229,7 +242,8 @@ final class LoginManagerTest extends TestCase {
self::assertStringStartsWith('/login', $location);
}
public function testIpScopeDowngradesToCookieWhenIpAccessDisabled(): void {
public function testIpScopeDowngradesToCookieWhenIpAccessDisabled(): void
{
$manager = $this->makeLoginManager(ipTtl: 0);
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Ip);
@@ -244,7 +258,8 @@ final class LoginManagerTest extends TestCase {
self::assertTrue($response->headers->has('Set-Cookie'));
}
public function testIpScopeWhenEnabledSetsIpSession(): void {
public function testIpScopeWhenEnabledSetsIpSession(): void
{
$manager = $this->makeLoginManager(ipTtl: 1800);
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Ip);
@@ -264,7 +279,8 @@ final class LoginManagerTest extends TestCase {
self::assertTrue($sessionCache->hasItem('ip_1.2.3.4'));
}
public function testBackupCodeAuthentication(): void {
public function testBackupCodeAuthentication(): void
{
$manager = $this->makeLoginManager();
$payload = $this->makePayloadWithNonce($manager, token: 'backup-code-123');
@@ -278,7 +294,8 @@ final class LoginManagerTest extends TestCase {
self::assertSame(303, $response->getStatusCode());
}
public function testNonceIsConsumedAfterSuccessfulLogin(): void {
public function testNonceIsConsumedAfterSuccessfulLogin(): void
{
$manager = $this->makeLoginManager();
$payload = $this->makePayloadWithNonce($manager);
@@ -296,7 +313,8 @@ final class LoginManagerTest extends TestCase {
self::assertFalse($nonceItem->get());
}
public function testUlidCollisionThrowsHttpException(): void {
public function testUlidCollisionThrowsHttpException(): void
{
// Use a stub pool where every cookie_ key is already a hit (collision)
$pool = $this->createStub(CacheItemPoolInterface::class);
$item = $this->createStub(CacheItemInterface::class);
@@ -358,7 +376,8 @@ final class LoginManagerTest extends TestCase {
$manager->checkToken($payload, $request);
}
public function testCookieScopeWithCentralAuthSetsDomainOnMatchingHost(): void {
public function testCookieScopeWithCentralAuthSetsDomainOnMatchingHost(): void
{
$manager = $this->makeLoginManager(
subdomainRedirect: true,
authSubdomain: 'auth.example.com',
@@ -381,7 +400,8 @@ final class LoginManagerTest extends TestCase {
self::assertSame('__Http-Domain-Preauth', $cookies[0]->getName());
}
public function testCookieScopeWithCentralAuthOnNonMatchingHostUsesNullDomain(): void {
public function testCookieScopeWithCentralAuthOnNonMatchingHostUsesNullDomain(): void
{
$manager = $this->makeLoginManager(
subdomainRedirect: true,
authSubdomain: 'auth.example.com',
@@ -404,7 +424,8 @@ final class LoginManagerTest extends TestCase {
self::assertSame('__Http-Domain-Preauth', $cookies[0]->getName());
}
public function testCheckTokenWithEmptyReturnParameterFallsBackToPath(): void {
public function testCheckTokenWithEmptyReturnParameterFallsBackToPath(): void
{
$manager = $this->makeLoginManager();
$payload = $this->makePayloadWithNonce($manager, scope: Scope::Cookie);
+9 -4
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Trait;
@@ -6,18 +7,22 @@ namespace App\Tests\Unit\Trait;
use App\Trait\CookieNameTrait;
use PHPUnit\Framework\TestCase;
final class CookieNameTraitTest extends TestCase {
final class CookieNameTraitTest extends TestCase
{
use CookieNameTrait;
public function testCookieName(): void {
public function testCookieName(): void
{
self::assertSame('__Host-Http-Preauth', $this->cookieName());
}
public function testAuthCookieName(): void {
public function testAuthCookieName(): void
{
self::assertSame('__Http-Domain-Preauth', $this->authCookieName());
}
public function testHeaderName(): void {
public function testHeaderName(): void
{
self::assertSame('X-Preauth', $this->headerName());
}
}
+36 -15
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Trait;
@@ -10,20 +11,24 @@ use OTPHP\TOTPInterface;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpKernel\Exception\HttpException;
final class GetTotpTraitTest extends TestCase {
final class GetTotpTraitTest extends TestCase
{
use TotpTestHelper;
private function makeObject(): object {
return new class {
private function makeObject(): object
{
return new class () {
use GetTotpTrait;
public function publicGetTotp(): TOTPInterface {
public function publicGetTotp(): TOTPInterface
{
return $this->getTotp();
}
};
}
public function testSetConfigSetsProperty(): void {
public function testSetConfigSetsProperty(): void
{
$obj = $this->makeObject();
$config = $this->makeConfig();
@@ -33,7 +38,8 @@ final class GetTotpTraitTest extends TestCase {
self::assertSame($config, $reflection->getValue($obj));
}
public function testGetTotpReturnsTotpInterface(): void {
public function testGetTotpReturnsTotpInterface(): void
{
$obj = $this->makeObject();
$obj->setConfig($this->makeConfig());
@@ -42,7 +48,8 @@ final class GetTotpTraitTest extends TestCase {
self::assertInstanceOf(TOTPInterface::class, $totp);
}
public function testGetTotpReturnsValidCode(): void {
public function testGetTotpReturnsValidCode(): void
{
$obj = $this->makeObject();
$obj->setConfig($this->makeConfig());
@@ -52,14 +59,21 @@ final class GetTotpTraitTest extends TestCase {
self::assertSame($this->validTotpCode(), $totp->now());
}
public function testGetTotpThrowsOnInvalidUri(): void {
public function testGetTotpThrowsOnInvalidUri(): void
{
$obj = $this->makeObject();
$clock = $this->frozenClock();
$utilities = $this->createUtilities($clock);
$config = new ConfigBag(
$utilities, $clock,
3600, 'not-a-valid-uri', 0, false,
'Error', 'Teapot', 'Too Many'
$utilities,
$clock,
3600,
'not-a-valid-uri',
0,
false,
'Error',
'Teapot',
'Too Many'
);
$obj->setConfig($config);
@@ -70,16 +84,23 @@ final class GetTotpTraitTest extends TestCase {
$obj->publicGetTotp();
}
public function testGetTotpThrowsHttpExceptionWhenNotTotpType(): void {
public function testGetTotpThrowsHttpExceptionWhenNotTotpType(): void
{
// A HOTP URI loads successfully as an OTPInterface but is NOT a TOTPInterface,
// so the instanceof check in getTotp() should throw an HttpException(500)
$obj = $this->makeObject();
$clock = $this->frozenClock();
$utilities = $this->createUtilities($clock);
$config = new ConfigBag(
$utilities, $clock,
3600, 'otpauth://hotp/Test-HOTP?secret=JBSWY3DPEHPK3PXP&counter=0', 0, false,
'Error', 'Teapot', 'Too Many'
$utilities,
$clock,
3600,
'otpauth://hotp/Test-HOTP?secret=JBSWY3DPEHPK3PXP&counter=0',
0,
false,
'Error',
'Teapot',
'Too Many'
);
$obj->setConfig($config);
+5 -2
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Trait;
@@ -7,10 +8,12 @@ use App\Trait\HasLoggerTrait;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
final class HasLoggerTraitTest extends TestCase {
final class HasLoggerTraitTest extends TestCase
{
use HasLoggerTrait;
public function testSetLogger(): void {
public function testSetLogger(): void
{
$logger = $this->createStub(LoggerInterface::class);
$this->setLogger($logger);
self::assertSame($logger, $this->logger);
+89 -31
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Trait;
@@ -15,22 +16,27 @@ use Symfony\Component\HttpKernel\Exception\HttpException;
* Wraps the trait in a concrete class with public proxies so the protected
* methods can be exercised from test scope.
*/
final class MakeNonceTraitTest extends TestCase {
private function makeObject(): object {
return new class {
final class MakeNonceTraitTest extends TestCase
{
private function makeObject(): object
{
return new class () {
use MakeNonceTrait;
public function publicMakeNonce(int $retries = 3): string {
public function publicMakeNonce(int $retries = 3): string
{
return $this->makeNonce($retries);
}
public function publicMakeCacheKey(string $name): string {
public function publicMakeCacheKey(string $name): string
{
return $this->makeCacheKey($name);
}
};
}
public function testMakeNonceReturnsBase64UrlString(): void {
public function testMakeNonceReturnsBase64UrlString(): void
{
$obj = $this->makeObject();
$obj->setLogger(new NullLogger());
$obj->setNonceCache(new ArrayAdapter());
@@ -44,7 +50,8 @@ final class MakeNonceTraitTest extends TestCase {
self::assertMatchesRegularExpression('/^[A-Za-z0-9_-]+$/', $nonce);
}
public function testMakeNonceStoresNonceInCache(): void {
public function testMakeNonceStoresNonceInCache(): void
{
$pool = new ArrayAdapter();
$obj = $this->makeObject();
$obj->setLogger(new NullLogger());
@@ -59,7 +66,8 @@ final class MakeNonceTraitTest extends TestCase {
self::assertTrue($item->get());
}
public function testMakeNonceSetsExpiry(): void {
public function testMakeNonceSetsExpiry(): void
{
$pool = new ArrayAdapter();
$obj = $this->makeObject();
$obj->setLogger(new NullLogger());
@@ -74,7 +82,8 @@ final class MakeNonceTraitTest extends TestCase {
self::assertGreaterThan(time(), (int) $expiry);
}
public function testTwoNoncesAreDifferent(): void {
public function testTwoNoncesAreDifferent(): void
{
$pool = new ArrayAdapter();
$obj = $this->makeObject();
$obj->setLogger(new NullLogger());
@@ -86,7 +95,8 @@ final class MakeNonceTraitTest extends TestCase {
self::assertNotSame($nonce1, $nonce2);
}
public function testMakeNonceThrowsAfterMaxRetries(): void {
public function testMakeNonceThrowsAfterMaxRetries(): void
{
// Create a stub pool that always reports every key as a hit (collision)
$pool = $this->createStub(CacheItemPoolInterface::class);
$item = $this->createStub(CacheItemInterface::class);
@@ -105,46 +115,93 @@ final class MakeNonceTraitTest extends TestCase {
$obj->publicMakeNonce();
}
public function testMakeNonceRetriesAndSucceedsAfterCollision(): void {
public function testMakeNonceRetriesAndSucceedsAfterCollision(): void
{
// Use a spy pool that returns isHit=true on the first getItem call
// (simulating a collision), then delegates to a real ArrayAdapter for
// subsequent calls so the retry succeeds.
$realPool = new ArrayAdapter();
$collisionCount = 0;
$spyPool = new class($realPool, $collisionCount) implements CacheItemPoolInterface {
$spyPool = new class ($realPool, $collisionCount) implements CacheItemPoolInterface {
private int $hits = 0;
public function __construct(
private CacheItemPoolInterface $inner,
private int &$hitCounter,
) {}
) {
}
public function getItem(string $key): CacheItemInterface {
public function getItem(string $key): CacheItemInterface
{
$item = $this->inner->getItem($key);
// pretend the first requested key is already a hit (collision)
if ($this->hits === 0) {
$this->hits++;
$this->hitCounter++;
return new class($key) implements CacheItemInterface {
public function __construct(private string $key) {}
public function getKey(): string { return $this->key; }
public function get(): mixed { return true; }
public function isHit(): bool { return true; }
public function set(mixed $value): static { return $this; }
public function expiresAt(?\DateTimeInterface $expiration): static { return $this; }
public function expiresAfter(int|\DateInterval|null $time): static { return $this; }
return new class ($key) implements CacheItemInterface {
public function __construct(private string $key)
{
}
public function getKey(): string
{
return $this->key;
}
public function get(): mixed
{
return true;
}
public function isHit(): bool
{
return true;
}
public function set(mixed $value): static
{
return $this;
}
public function expiresAt(?\DateTimeInterface $expiration): static
{
return $this;
}
public function expiresAfter(int|\DateInterval|null $time): static
{
return $this;
}
};
}
return $item;
}
public function getItems(array $keys = []): iterable { return $this->inner->getItems($keys); }
public function hasItem(string $key): bool { return $this->inner->hasItem($key); }
public function clear(): bool { return $this->inner->clear(); }
public function deleteItem(string $key): bool { return $this->inner->deleteItem($key); }
public function deleteItems(array $keys): bool { return $this->inner->deleteItems($keys); }
public function save(CacheItemInterface $item): bool { return $this->inner->save($item); }
public function saveDeferred(CacheItemInterface $item): bool { return $this->inner->saveDeferred($item); }
public function commit(): bool { return $this->inner->commit(); }
public function getItems(array $keys = []): iterable
{
return $this->inner->getItems($keys);
}
public function hasItem(string $key): bool
{
return $this->inner->hasItem($key);
}
public function clear(): bool
{
return $this->inner->clear();
}
public function deleteItem(string $key): bool
{
return $this->inner->deleteItem($key);
}
public function deleteItems(array $keys): bool
{
return $this->inner->deleteItems($keys);
}
public function save(CacheItemInterface $item): bool
{
return $this->inner->save($item);
}
public function saveDeferred(CacheItemInterface $item): bool
{
return $this->inner->saveDeferred($item);
}
public function commit(): bool
{
return $this->inner->commit();
}
};
$obj = $this->makeObject();
@@ -158,7 +215,8 @@ final class MakeNonceTraitTest extends TestCase {
self::assertSame(1, $collisionCount, 'Expected exactly one collision before success');
}
public function testMakeNonceThrowsImmediatelyWithZeroRetries(): void {
public function testMakeNonceThrowsImmediatelyWithZeroRetries(): void
{
$pool = $this->createStub(CacheItemPoolInterface::class);
$item = $this->createStub(CacheItemInterface::class);
$item->method('isHit')->willReturn(true);
+19 -9
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Trait;
@@ -6,30 +7,36 @@ namespace App\Tests\Unit\Trait;
use App\Trait\StringTrait;
use PHPUnit\Framework\TestCase;
final class StringTraitTest extends TestCase {
final class StringTraitTest extends TestCase
{
use StringTrait;
public function testMakeCacheKeySanitizesInvalidChars(): void {
public function testMakeCacheKeySanitizesInvalidChars(): void
{
self::assertSame('hello_world', $this->makeCacheKey('hello world'));
self::assertSame('hello_world', $this->makeCacheKey('hello!world'));
self::assertSame('a_b_c_d', $this->makeCacheKey('a/b@c#d'));
}
public function testMakeCacheKeyPreservesValidChars(): void {
public function testMakeCacheKeyPreservesValidChars(): void
{
self::assertSame('ABC_123.abc', $this->makeCacheKey('ABC_123.abc'));
}
public function testMakeCacheKeyTruncatesLongNames(): void {
public function testMakeCacheKeyTruncatesLongNames(): void
{
$long = str_repeat('a', 300);
$result = $this->makeCacheKey($long);
self::assertSame(128, mb_strlen($result));
}
public function testMakeCacheKeyEmptyString(): void {
public function testMakeCacheKeyEmptyString(): void
{
self::assertSame('', $this->makeCacheKey(''));
}
public function testMakeCacheKeyWithOnlyInvalidChars(): void {
public function testMakeCacheKeyWithOnlyInvalidChars(): void
{
// preg_replace with + collapses consecutive invalid chars into one _
self::assertSame('_', $this->makeCacheKey('!!!'));
self::assertSame('_', $this->makeCacheKey(' '));
@@ -37,7 +44,8 @@ final class StringTraitTest extends TestCase {
self::assertSame('_', $this->makeCacheKey('!@ #'));
}
public function testMakeCacheKeyTruncatesToExactly128(): void {
public function testMakeCacheKeyTruncatesToExactly128(): void
{
$input = str_repeat('a', 128);
self::assertSame(128, mb_strlen($this->makeCacheKey($input)));
self::assertSame($input, $this->makeCacheKey($input));
@@ -46,14 +54,16 @@ final class StringTraitTest extends TestCase {
self::assertSame(128, mb_strlen($this->makeCacheKey($input129)));
}
public function testMakeCacheKeyWithMultibyteChars(): void {
public function testMakeCacheKeyWithMultibyteChars(): void
{
// multibyte chars are replaced with a single underscore
$result = $this->makeCacheKey('héllo wörld');
// é and ö are not in [A-Za-z0-9_.] so they become _
self::assertSame('h_llo_w_rld', $result);
}
public function testMakeCacheKeyWithEmoji(): void {
public function testMakeCacheKeyWithEmoji(): void
{
$result = $this->makeCacheKey('a🎉b');
self::assertSame('a_b', $result);
}
+13 -6
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit;
@@ -8,14 +9,17 @@ use PHPUnit\Framework\TestCase;
use Psr\Clock\ClockInterface;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
final class UtilitiesTest extends TestCase {
private function makeUtilities(?ArrayAdapter $pool = null, ?ClockInterface $clock = null): Utilities {
final class UtilitiesTest extends TestCase
{
private function makeUtilities(?ArrayAdapter $pool = null, ?ClockInterface $clock = null): Utilities
{
$pool ??= new ArrayAdapter();
$clock ??= $this->createStub(ClockInterface::class);
return new Utilities($clock, $pool);
}
public function testLoadTotpReturnsCachedValueWhenPresent(): void {
public function testLoadTotpReturnsCachedValueWhenPresent(): void
{
$pool = new ArrayAdapter();
$item = $pool->getItem('totp');
$item->set('otpauth://totp/cached?secret=ABCDEFGH');
@@ -28,7 +32,8 @@ final class UtilitiesTest extends TestCase {
self::assertSame('otpauth://totp/cached?secret=ABCDEFGH', $result);
}
public function testLoadTotpGeneratesAndStoresWhenMissing(): void {
public function testLoadTotpGeneratesAndStoresWhenMissing(): void
{
$pool = new ArrayAdapter();
$utilities = $this->makeUtilities($pool);
@@ -43,7 +48,8 @@ final class UtilitiesTest extends TestCase {
self::assertSame($result, $cached->get());
}
public function testLoadTotpSetsFarFutureExpiry(): void {
public function testLoadTotpSetsFarFutureExpiry(): void
{
$pool = new ArrayAdapter();
$utilities = $this->makeUtilities($pool);
@@ -55,7 +61,8 @@ final class UtilitiesTest extends TestCase {
self::assertGreaterThan((new \DateTimeImmutable('+10 years'))->getTimestamp(), (int) $expiry);
}
public function testLoadTotpIsIdempotentAfterGeneration(): void {
public function testLoadTotpIsIdempotentAfterGeneration(): void
{
$pool = new ArrayAdapter();
$utilities = $this->makeUtilities($pool);