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
+3
View File
@@ -29,5 +29,8 @@ jobs:
- name: Install dependencies
run: composer install --prefer-dist --no-progress
- name: Run php-cs-fixer
run: vendor/bin/php-cs-fixer fix --dry-run --diff
- name: Run tests
run: XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-text
+5
View File
@@ -16,3 +16,8 @@
###> project-specific ###
/config/reference.php
###< project-specific ###
###> friendsofphp/php-cs-fixer ###
/.php-cs-fixer.php
/.php-cs-fixer.cache
###< friendsofphp/php-cs-fixer ###
+18
View File
@@ -0,0 +1,18 @@
<?php
$finder = (new PhpCsFixer\Finder())
->in(__DIR__)
->exclude('var')
->exclude('vendor')
->notPath([
'config/bundles.php',
'config/reference.php',
])
;
return (new PhpCsFixer\Config())
->setRules([
'@PSR12' => true,
])
->setFinder($finder)
;
+1
View File
@@ -75,6 +75,7 @@
}
},
"require-dev": {
"friendsofphp/php-cs-fixer": "*",
"phpunit/phpunit": "^13.2",
"symfony/browser-kit": "7.4.*",
"symfony/css-selector": "7.4.*"
Generated
+1304 -2
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
use App\Kernel;
+20
View File
@@ -7,6 +7,26 @@ So, I built a simple authentication gateway, which eventually turned into this p
It sits between your reverse proxy and web service to add extra protection, while still being easy to access from anywhere.
## Development
### Code Style
This project follows [PSR-12](https://www.php-fig.org/psr/psr-12/) and includes `php-cs-fixer` as a dev dependency.
```bash
# Check for style violations
vendor/bin/php-cs-fixer fix --dry-run --diff
# Auto-fix
vendor/bin/php-cs-fixer fix
```
### Running Tests
```bash
vendor/bin/phpunit
```
## Requirements
* Docker
+5 -2
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App;
@@ -8,8 +9,10 @@ use Psr\Clock\ClockInterface;
use Symfony\Component\DependencyInjection\Attribute\AsAlias;
#[AsAlias(ClockInterface::class)]
final readonly class Clock implements ClockInterface {
public function now(): DateTimeImmutable {
final readonly class Clock implements ClockInterface
{
public function now(): DateTimeImmutable
{
return new DateTimeImmutable();
}
}
+7 -3
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Command;
@@ -13,7 +14,8 @@ use Symfony\Component\Console\Output\OutputInterface;
/** simple console command to generate backup codes
* usage: php bin/console app:generate-backup-codes [count] */
final class GenerateBackupCodesCommand extends Command {
final class GenerateBackupCodesCommand extends Command
{
public function __construct(
private readonly BackupCodeInterface $manager,
private readonly PersistCache $persistCache,
@@ -21,14 +23,16 @@ final class GenerateBackupCodesCommand extends Command {
parent::__construct();
}
protected function configure(): void {
protected function configure(): void
{
$this->setName('app:generate-backup-codes');
$this->setDescription('Generate singleuse backup codes')
->addArgument('count', InputArgument::OPTIONAL, 'Number of codes to generate', 10);
}
/** @throws InvalidArgumentException */
protected function execute(InputInterface $input, OutputInterface $output): int {
protected function execute(InputInterface $input, OutputInterface $output): int
{
/* since Kernel::terminate() does not get called, we must boot and persist explicitly */
$this->persistCache->boot();
$count = (int) $input->getArgument('count');
+19 -9
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App;
@@ -7,7 +8,8 @@ use Psr\Cache\InvalidArgumentException;
use Psr\Clock\ClockInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
final readonly class ConfigBag {
final readonly class ConfigBag
{
private ClockInterface $clock;
private int $cookieTtl;
private string $totpUri;
@@ -39,35 +41,43 @@ final readonly class ConfigBag {
$this->tooManyTitle = $tooManyTitle;
}
public function clock(): ClockInterface {
public function clock(): ClockInterface
{
return $this->clock;
}
public function cookieTtl(): int {
public function cookieTtl(): int
{
return $this->cookieTtl;
}
public function totpUri(): string {
public function totpUri(): string
{
return $this->totpUri;
}
public function ipTtl(): ?int {
public function ipTtl(): ?int
{
return $this->ipTtl;
}
public function teapot(): bool {
public function teapot(): bool
{
return $this->teapot;
}
public function errorMessage(): string {
public function errorMessage(): string
{
return $this->errorMessage;
}
public function teapotTitle(): string {
public function teapotTitle(): string
{
return $this->teapotTitle;
}
public function tooManyTitle(): string {
public function tooManyTitle(): string
{
return $this->tooManyTitle;
}
}
+13 -6
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Data;
@@ -7,14 +8,16 @@ use App\Enum\Scope;
use Symfony\Component\HttpFoundation\InputBag;
/** when scope is IP but ip-access is disabled, scope is to be considered cookie */
final class Payload {
final class Payload
{
public string $id; /* session name, identifying who is logging in */
public string $token; /* TOTP, typically six digits */
public string $nonce; /* random unique string, to block duplicate submissions */
public bool $json; /* should we return json (for the login page) */
public Scope $scope; /* type of access being requested */
public static function decode(string $base64url): ?Payload {
public static function decode(string $base64url): ?Payload
{
/* convert the base64url into json string */
$base64 = strtr($base64url, '-_', '+/');
$base64 .= str_repeat('=', (4 - strlen($base64) % 4) % 4);
@@ -29,7 +32,8 @@ final class Payload {
return null;
}
public static function load(InputBag $input): ?Payload {
public static function load(InputBag $input): ?Payload
{
/* convert form data into real data */
if ($input->has('username') && $input->has('nonce') && $input->has('totp')) {
return Payload::create((object)[
@@ -42,7 +46,8 @@ final class Payload {
return null;
}
public static function create(object $data): ?Payload {
public static function create(object $data): ?Payload
{
/* if missing required fields id, nonce, or token */
if (strlen(trim($data->id ?? '')) < 1 ||
strlen(trim($data->nonce ?? '')) < 1 ||
@@ -63,11 +68,13 @@ final class Payload {
return Payload::constrict($payload);
}
public function toString(): string {
public function toString(): string
{
return json_encode($this);
}
private static function constrict(Payload $payload): Payload {
private static function constrict(Payload $payload): Payload
{
/* When scope is None, json will be considered false. */
if ($payload->scope === Scope::None) {
$payload->json = false;
+3 -1
View File
@@ -1,10 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Enum;
/** scope defines the context of how a session is persisted */
enum Scope: string {
enum Scope: string
{
case Cookie = 'cookie';
case Ip = 'ip';
case None = 'none';
+7 -3
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App;
@@ -9,13 +10,15 @@ use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
class Kernel extends BaseKernel {
class Kernel extends BaseKernel
{
use MicroKernelTrait;
private PersistCache $persistCache;
/** @throws InvalidArgumentException */
public function boot(): void {
public function boot(): void
{
parent::boot();
$this->persistCache = $this->container->get(PersistCache::class);
@@ -23,7 +26,8 @@ class Kernel extends BaseKernel {
}
/** @throws InvalidArgumentException */
public function terminate(Request $request, Response $response): void {
public function terminate(Request $request, Response $response): void
{
$this->persistCache->persist();
parent::terminate($request, $response);
+8 -4
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Listener;
@@ -13,7 +14,8 @@ use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
final readonly class AcceptListener {
final readonly class AcceptListener
{
use CookieNameTrait;
use HasLoggerTrait;
use StringTrait;
@@ -21,13 +23,15 @@ final readonly class AcceptListener {
public function __construct(
private CacheItemPoolInterface $sessionCache,
private DomainInterface $domainManager,
) {}
) {
}
/** @throws InvalidArgumentException */
#[AsEventListener(priority: 99)]
public function onKernelRequest(RequestEvent $event): void {
public function onKernelRequest(RequestEvent $event): void
{
/* check if they sent the correct preauth cookie */
$cookieName = $this->domainManager->authBase() ?$this->authCookieName() : $this->cookieName();
$cookieName = $this->domainManager->authBase() ? $this->authCookieName() : $this->cookieName();
if ($event->getRequest()->cookies->has($cookieName)) {
$cookie = $event->getRequest()->cookies->get($cookieName);
$cookieKey = $this->makeCacheKey("cookie_$cookie");
+7 -3
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Listener;
@@ -12,18 +13,21 @@ use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
final readonly class AllowListener {
final readonly class AllowListener
{
use HasLoggerTrait;
use StringTrait;
public function __construct(
private CacheItemPoolInterface $sessionCache,
private ConfigBag $config,
) {}
) {
}
/** @throws InvalidArgumentException */
#[AsEventListener(priority: 88)]
public function onKernelRequest(RequestEvent $event): void {
public function onKernelRequest(RequestEvent $event): void
{
if ($this->config->ipTtl() > 0) {
$ipKey = $this->makeCacheKey("ip_{$event->getRequest()->getClientIp()}");
if ($this->sessionCache->hasItem($ipKey)) {
+16 -7
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Listener;
@@ -18,7 +19,8 @@ use Twig\Error\LoaderError;
use Twig\Error\RuntimeError;
use Twig\Error\SyntaxError;
final readonly class InterceptListener {
final readonly class InterceptListener
{
use CookieNameTrait;
use HasLoggerTrait;
use MakeNonceTrait;
@@ -27,11 +29,13 @@ final readonly class InterceptListener {
private ConfigBag $config,
private DomainInterface $domainManager,
private Environment $twig,
) {}
) {
}
/** @throws InvalidArgumentException|RuntimeError|SyntaxError|LoaderError */
#[AsEventListener(priority: 55)]
public function onKernelRequest(RequestEvent $event): void {
public function onKernelRequest(RequestEvent $event): void
{
/* by this point, we know that the request we have is:
* not already authorized, nor already rate-limited,
* nor submitting login credentials; so redirect or present the login page now */
@@ -40,7 +44,9 @@ final readonly class InterceptListener {
) {
/* host matches base-domain of auth, but not on auth subdomain, redirect */
$query = http_build_query(['return' => $event->getRequest()->getUri()]);
$event->setResponse(new Response('', Response::HTTP_SEE_OTHER,
$event->setResponse(new Response(
'',
Response::HTTP_SEE_OTHER,
['Location' => "https://{$this->domainManager->getAuthSubdomain()}/?$query"]
));
} else {
@@ -52,13 +58,16 @@ final readonly class InterceptListener {
$hasCookie = (bool) $event->getRequest()->cookies->get(
$this->domainManager->authBase() ? $this->authCookieName() : $this->cookieName()
);
$event->setResponse($this->pruneInvalidCookie(new Response($content,
Response::HTTP_UNAUTHORIZED, ['Content-Type' => 'text/html']
$event->setResponse($this->pruneInvalidCookie(new Response(
$content,
Response::HTTP_UNAUTHORIZED,
['Content-Type' => 'text/html']
), $hasCookie, $event->getRequest()->getHost()));
}
}
private function pruneInvalidCookie(Response $response, bool $hasCookie, string $host): Response {
private function pruneInvalidCookie(Response $response, bool $hasCookie, string $host): Response
{
if ($hasCookie) {
/* input here must match LoginListener::setCookie() */
$response->headers->clearCookie(
+19 -11
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Listener;
@@ -23,7 +24,8 @@ use Twig\Error\LoaderError;
use Twig\Error\RuntimeError;
use Twig\Error\SyntaxError;
final readonly class LoginListener {
final readonly class LoginListener
{
use CookieNameTrait;
use HasLoggerTrait;
use MakeNonceTrait;
@@ -32,18 +34,19 @@ final readonly class LoginListener {
private RateLimiterFactoryInterface $rateLimiter;
public function __construct(
private Environment $twig,
private Environment $twig,
#[Target('login_limiter')] RateLimiterFactoryInterface $rateLimiter,
private DomainInterface $domainManager,
private LoginInterface $loginManager,
private ConfigBag $config,
private DomainInterface $domainManager,
private LoginInterface $loginManager,
private ConfigBag $config,
) {
$this->rateLimiter = $rateLimiter;
}
/** @throws InvalidArgumentException|LoaderError|RuntimeError|SyntaxError */
#[AsEventListener(priority: 66)]
public function onKernelRequest(RequestEvent $event): void {
public function onKernelRequest(RequestEvent $event): void
{
$payload = null;
$response = null;
@@ -51,7 +54,7 @@ final readonly class LoginListener {
/* if request contains our "X-Preauth" header */
$data = $event->getRequest()->headers->get($this->headerName());
$payload = Payload::decode($data);
} else if ($event->getRequest()->isMethod(Request::METHOD_POST) &&
} elseif ($event->getRequest()->isMethod(Request::METHOD_POST) &&
$this->domainManager->getAuthSubdomain() === $event->getRequest()->getHost()
) {
/* if request is a POST to the auth-subdomain */
@@ -76,18 +79,23 @@ final readonly class LoginListener {
$limitReached = $this->logFailure($event->getRequest());
$this->logger->debug("logging failure for: {$event->getRequest()->getClientIp()}");
$event->setResponse($this->makeFailedResponse($limitReached, $payload->json ?? true,
$event->getRequest()->getHost(), $this->makeCacheKey($payload ? $payload->id : '')
$event->setResponse($this->makeFailedResponse(
$limitReached,
$payload->json ?? true,
$event->getRequest()->getHost(),
$this->makeCacheKey($payload ? $payload->id : '')
));
}
private function logFailure(Request $request): bool {
private function logFailure(Request $request): bool
{
$limiter = $this->rateLimiter->create($request->getClientIp());
return ($limiter->consume(1)->getRemainingTokens() < 1);
}
/** @throws InvalidArgumentException|RuntimeError|SyntaxError|LoaderError */
private function makeFailedResponse(bool $limited, bool $json, string $host, string $username): Response {
private function makeFailedResponse(bool $limited, bool $json, string $host, string $username): Response
{
if ($limited) {
$status = $this->config->teapot() ? Response::HTTP_I_AM_A_TEAPOT
: Response::HTTP_TOO_MANY_REQUESTS;
+10 -5
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Listener;
@@ -16,15 +17,16 @@ use Twig\Error\LoaderError;
use Twig\Error\RuntimeError;
use Twig\Error\SyntaxError;
final readonly class RejectListener {
final readonly class RejectListener
{
use HasLoggerTrait;
use StringTrait;
private RateLimiterFactoryInterface $rateLimiter;
public function __construct(
private ConfigBag $config,
private Environment $twig,
private ConfigBag $config,
private Environment $twig,
#[Target('login_limiter')] RateLimiterFactoryInterface $rateLimiter,
) {
$this->rateLimiter = $rateLimiter;
@@ -32,13 +34,16 @@ final readonly class RejectListener {
/** @throws SyntaxError|RuntimeError|LoaderError */
#[AsEventListener(priority: 77)]
public function onKernelRequest(RequestEvent $event): void {
public function onKernelRequest(RequestEvent $event): void
{
/* check if they have made too many failed login attempts */
$limiter = $this->rateLimiter->create($event->getRequest()->getClientIp());
if ($limiter->consume(0)->getRemainingTokens() < 1) {
$this->logger->debug("already blocked: {$event->getRequest()->getClientIp()}");
$html = $this->twig->render('error.html.twig');
$event->setResponse(new Response($html, ($this->config->teapot()
$event->setResponse(new Response(
$html,
($this->config->teapot()
? Response::HTTP_I_AM_A_TEAPOT : Response::HTTP_TOO_MANY_REQUESTS),
['Content-Type' => 'text/html']
));
+41 -21
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App;
@@ -10,7 +11,8 @@ use Psr\Cache\InvalidArgumentException;
/* we must *NOT* store the key-list item or values within this object
* because it can change from outside this object instance */
final readonly class MonitorCacheKeys implements CacheItemPoolInterface {
final readonly class MonitorCacheKeys implements CacheItemPoolInterface
{
private const string KEY_LIST = '__key_list';
private const string CHANGE_LIST = '__chg_list';
public const int UPDATED = 1;
@@ -19,11 +21,12 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface {
private CacheItemPoolInterface $cache;
/** @throws InvalidArgumentException */
public function __construct(CacheItemPoolInterface $cache) {
public function __construct(CacheItemPoolInterface $cache)
{
$this->cache = $cache;
$items = $cache->getItems([self::KEY_LIST, self::CHANGE_LIST]);
foreach ($items as $item) {
if ( ! $item->isHit()) {
if (! $item->isHit()) {
$this->initialize();
break;
}
@@ -31,7 +34,8 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface {
}
/** @throws InvalidArgumentException */
private function initialize(): void {
private function initialize(): void
{
$keyList = $this->cache->getItem(self::KEY_LIST);
$changeList = $this->cache->getItem(self::CHANGE_LIST);
$keyList->set([]);
@@ -42,42 +46,49 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface {
}
/** @throws InvalidArgumentException */
public function getKeys(): array {
public function getKeys(): array
{
$keyList = $this->cache->getItem(self::KEY_LIST);
return array_keys($keyList->get() ?? []);
}
/** @throws InvalidArgumentException */
public function getChanges(): array {
public function getChanges(): array
{
$changeList = $this->cache->getItem(self::CHANGE_LIST);
return $changeList->get() ?? [];
}
/** @throws InvalidArgumentException */
public function markClean(): void {
public function markClean(): void
{
$changeList = $this->cache->getItem(self::CHANGE_LIST);
$changeList->set([]);
$this->cache->save($changeList);
}
public function getItem(string $key): CacheItemInterface {
public function getItem(string $key): CacheItemInterface
{
return $this->cache->getItem($key);
}
/** @return CacheItemInterface[]
* @throws InvalidArgumentException */
public function getItems(array $keys = []): iterable {
public function getItems(array $keys = []): iterable
{
return $this->cache->getItems($keys);
}
public function hasItem(string $key): bool {
public function hasItem(string $key): bool
{
return $this->cache->hasItem($key);
}
/** @throws InvalidArgumentException */
public function clear(): bool {
public function clear(): bool
{
/* only bother clearing the pool if it is not empty */
if ( ! empty($this->getKeys())) {
if (! empty($this->getKeys())) {
$response = $this->cache->clear();
$this->initialize();
@@ -86,7 +97,8 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface {
return true;
}
public function deleteItem(string $key): bool {
public function deleteItem(string $key): bool
{
$this->isValid($key);
$keyList = $this->cache->getItem(self::KEY_LIST);
$keyValues = $keyList->get();
@@ -101,7 +113,8 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface {
return $this->cache->deleteItem($key);
}
public function deleteItems(array $keys): bool {
public function deleteItems(array $keys): bool
{
$this->allValid($keys);
$keyList = $this->cache->getItem(self::KEY_LIST);
$keyValues = $keyList->get();
@@ -119,23 +132,27 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface {
}
/** @throws InvalidArgumentException */
public function save(CacheItemInterface $item): bool {
public function save(CacheItemInterface $item): bool
{
$this->update($item);
return $this->cache->save($item);
}
/** @throws InvalidArgumentException */
public function saveDeferred(CacheItemInterface $item): bool {
public function saveDeferred(CacheItemInterface $item): bool
{
$this->update($item);
return $this->cache->saveDeferred($item);
}
public function commit(): bool {
public function commit(): bool
{
return $this->cache->commit();
}
/** @throws InvalidArgumentException|OutOfBoundsException */
private function update(CacheItemInterface $item): void {
private function update(CacheItemInterface $item): void
{
$this->isValid($item->getKey());
$keyList = $this->cache->getItem(self::KEY_LIST);
$keyValues = $keyList->get();
@@ -147,7 +164,8 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface {
}
/** @throws OutOfBoundsException */
private function isValid(string $key): void {
private function isValid(string $key): void
{
if ($key === self::KEY_LIST || $key === self::CHANGE_LIST) {
throw new OutOfBoundsException(
'Can not modify the private key or change lists'
@@ -156,7 +174,8 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface {
}
/** @throws OutOfBoundsException */
private function allValid(array $keys): void {
private function allValid(array $keys): void
{
if (in_array(self::KEY_LIST, $keys, true) ||
in_array(self::CHANGE_LIST, $keys, true)
) {
@@ -167,7 +186,8 @@ final readonly class MonitorCacheKeys implements CacheItemPoolInterface {
}
/** @throws InvalidArgumentException */
private function logChange(string $key, int $code = MonitorCacheKeys::UPDATED): void {
private function logChange(string $key, int $code = MonitorCacheKeys::UPDATED): void
{
$changeList = $this->cache->getItem(self::CHANGE_LIST);
$changeValues = $changeList->get();
$changeValues[$key] = $code;
+7 -3
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App;
@@ -9,7 +10,8 @@ use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
/* need autoconfigure so we get it from the service container in Kernel->boot() */
#[Autoconfigure(public: true)]
final readonly class PersistCache {
final readonly class PersistCache
{
private MonitorCacheKeys $sessionCache;
private MonitorCacheKeys $sessionStorage;
@@ -23,7 +25,8 @@ final readonly class PersistCache {
}
/** @throws InvalidArgumentException */
public function boot(): void {
public function boot(): void
{
/* the caches are considered warm as soon as they are not empty */
if (empty($this->sessionCache->getKeys())) {
$items = $this->sessionStorage->getItems($this->sessionStorage->getKeys());
@@ -36,7 +39,8 @@ final readonly class PersistCache {
}
/** @throws InvalidArgumentException */
public function persist(): void {
public function persist(): void
{
/* we only need to persist the changes made to the cache (if any) */
$changes = $this->sessionCache->getChanges();
if ($changes) {
+2 -2
View File
@@ -2,13 +2,13 @@
namespace App\Service;
use Exception;
use Psr\Cache\InvalidArgumentException;
/** backup-codes are caseinsensitive alphanumeric strings
* they are single-use and marked as used after successful authentication */
interface BackupCodeInterface {
interface BackupCodeInterface
{
/** generate a set of backup-codes and return them
* @param int $count Number of codes to generate
* @return string[] Generated backup codes
+18 -9
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Service;
@@ -14,19 +15,21 @@ use App\Trait\GetTotpTrait;
/** backup-codes are caseinsensitive alphanumeric strings
* they are single-use and marked as used after successful authentication */
final readonly class BackupCodeManager implements BackupCodeInterface {
final readonly class BackupCodeManager implements BackupCodeInterface
{
use GetTotpTrait;
use HasLoggerTrait;
use StringTrait;
private const int DEFAULT_COUNT = 10;
/* php base_convert() will break if given too long of an input */
const int MAX_LENGTH = 64;
public const int MAX_LENGTH = 64;
private CacheItemPoolInterface $sessionCache;
/** @throws InvalidArgumentException */
public function __construct(CacheItemPoolInterface $sessionCache) {
public function __construct(CacheItemPoolInterface $sessionCache)
{
$this->sessionCache = new MonitorCacheKeys($sessionCache);
}
@@ -34,7 +37,8 @@ final readonly class BackupCodeManager implements BackupCodeInterface {
* @param int $count Number of codes to generate
* @return string[] Generated backup codes
* @throws InvalidArgumentException|Exception */
public function generate(int $count = self::DEFAULT_COUNT): array {
public function generate(int $count = self::DEFAULT_COUNT): array
{
$length = min($this->getTotp()->getDigits() + 2, self::MAX_LENGTH);
$codes = [];
for ($i = 0; $i < $count; $i++) {
@@ -49,7 +53,8 @@ final readonly class BackupCodeManager implements BackupCodeInterface {
}
/** @throws InvalidArgumentException */
public function expire(): void {
public function expire(): void
{
$itemsToRemove = [];
foreach ($this->sessionCache->getKeys() as $key) {
if (str_starts_with($key, 'backup_')) {
@@ -65,7 +70,8 @@ final readonly class BackupCodeManager implements BackupCodeInterface {
* @param string $code Code supplied by the client
* @return bool true if the code is valid and unused
* @throws InvalidArgumentException */
public function verifyAndConsume(string $code): bool {
public function verifyAndConsume(string $code): bool
{
/* remove unallowed characters, since backup codes are case-insensitive alphanumeric */
$backupKey = 'backup_' . preg_replace('/[^a-z0-9]+/', '', strtolower($code));
$backupItem = $this->sessionCache->getItem($this->makeCacheKey($backupKey));
@@ -77,7 +83,8 @@ final readonly class BackupCodeManager implements BackupCodeInterface {
/* per PSR6, if no expiration is set, implementation may set a default,
* we want this to keep forever, so a few hundred years should do it */
$backupItem->expiresAt(DateTimeImmutable::createFromFormat(
'Y-m-d', '2999-12-31'
'Y-m-d',
'2999-12-31'
));
$this->sessionCache->save($backupItem);
@@ -87,7 +94,8 @@ final readonly class BackupCodeManager implements BackupCodeInterface {
}
/** @throws InvalidArgumentException */
private function saveCodes(array $codes): void {
private function saveCodes(array $codes): void
{
foreach ($codes as $code) {
$backupItem = $this->sessionCache->getItem($this->makeCacheKey(strtolower("backup_$code")));
/* mark backup code as ready */
@@ -95,7 +103,8 @@ final readonly class BackupCodeManager implements BackupCodeInterface {
/* per PSR6, if no expiration is set, implementation may set a default,
* we want this to keep forever, so a few hundred years should do it */
$backupItem->expiresAt(DateTimeImmutable::createFromFormat(
'Y-m-d', '2999-12-31'
'Y-m-d',
'2999-12-31'
));
$this->sessionCache->saveDeferred($backupItem);
}
+2 -1
View File
@@ -2,7 +2,8 @@
namespace App\Service;
interface DomainInterface {
interface DomainInterface
{
/** IE: "auth.example.com" or null if not using a separate subdomain
* @return ?string Returns auth subdomain if configured, otherwise null */
public function getAuthSubdomain(): ?string;
+17 -9
View File
@@ -1,11 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Service;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
final readonly class DomainManager implements DomainInterface {
final readonly class DomainManager implements DomainInterface
{
/* top-level-domains which are known to have multiple parts */
private const array TLD = [
'ai' => ['com','net','off','org'],
@@ -38,7 +40,8 @@ final readonly class DomainManager implements DomainInterface {
/** IE: "auth.example.com" or null if not using a separate subdomain
* @return ?string Returns auth subdomain if configured, otherwise null */
public function getAuthSubdomain(): ?string {
public function getAuthSubdomain(): ?string
{
if ($this->authBase()) {
return $this->authSubdomain;
}
@@ -48,7 +51,8 @@ final readonly class DomainManager implements DomainInterface {
/** check if given url is an acceptable url for redirection
* @param string $url Where we are thinking of sending the user
* @return bool Returns true if it is acceptable to send the user there */
public function validReturn(string $url): bool {
public function validReturn(string $url): bool
{
/* ensure url is valid and, when using an auth subdomain,
* that the url host matches the base domain */
if (!filter_var($url, FILTER_VALIDATE_URL)) {
@@ -70,7 +74,8 @@ final readonly class DomainManager implements DomainInterface {
/** check if host-base matches auth-base
* @param string $host
* @return bool returns true if and only if host matches base domain of auth */
public function matchesAuth(string $host): bool {
public function matchesAuth(string $host): bool
{
$hostBase = $this->baseDomain($host);
$authBase = $this->baseDomain($this->authSubdomain);
return $this->subdomainRedirect && $this->authSubdomain &&
@@ -79,7 +84,8 @@ final readonly class DomainManager implements DomainInterface {
/** IE: "example.com" if central auth is something like "auth.example.com"
* @return string|null returns base domain if we are doing central auth */
public function authBase(): ?string {
public function authBase(): ?string
{
if ($this->subdomainRedirect && $this->authSubdomain && $this->baseDomain($this->authSubdomain)) {
return $this->baseDomain($this->authSubdomain);
}
@@ -91,7 +97,8 @@ final readonly class DomainManager implements DomainInterface {
* things like "localhost" and "8.8.8.8" will return null
* @param string $host ip, localhost, or domain with zero or more subdomains
* @return ?string returns null if host is ip or localhost otherwise domain with all subdomains removed */
private function baseDomain(string $host): ?string {
private function baseDomain(string $host): ?string
{
/* if host is an ip address (or localhost), leave it as is */
if (filter_var($host, FILTER_VALIDATE_IP) || $host === 'localhost') {
return null;
@@ -106,12 +113,13 @@ final readonly class DomainManager implements DomainInterface {
/** IE: ["www", "example", "com"] or ["www", "example", "co", "uk"]
* @param string[] $parts pieces of a domain split by "." dot
* @return int typically 2 but sometimes 3 */
private function baseLength(array $parts): int {
private function baseLength(array $parts): int
{
$length = count($parts);
$baseLength = min(2, $length);
/* check if host should retain 3 parts, due to TLD */
if (count($parts) > 2 && isset(self::TLD[$parts[$length-1]]) &&
in_array($parts[$length-2], self::TLD[$parts[$length-1]], true)
if (count($parts) > 2 && isset(self::TLD[$parts[$length - 1]]) &&
in_array($parts[$length - 2], self::TLD[$parts[$length - 1]], true)
) {
$baseLength = min(3, $length);
}
+2 -1
View File
@@ -7,7 +7,8 @@ use Psr\Cache\InvalidArgumentException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
interface LoginInterface {
interface LoginInterface
{
/** @throws InvalidArgumentException */
public function checkToken(Payload $payload, Request $request): ?Response;
}
+11 -6
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Service;
@@ -18,7 +19,8 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\Uid\Ulid;
final readonly class LoginManager implements LoginInterface {
final readonly class LoginManager implements LoginInterface
{
use CookieNameTrait;
use GetTotpTrait;
use MakeNonceTrait;
@@ -28,7 +30,7 @@ final readonly class LoginManager implements LoginInterface {
/** @throws InvalidArgumentException */
public function __construct(
CacheItemPoolInterface $sessionCache,
CacheItemPoolInterface $sessionCache,
private BackupCodeInterface $backupCodeManager,
private DomainInterface $domainManager,
) {
@@ -36,7 +38,8 @@ final readonly class LoginManager implements LoginInterface {
}
/** @throws InvalidArgumentException */
public function checkToken(Payload $payload, Request $request): ?Response {
public function checkToken(Payload $payload, Request $request): ?Response
{
/* when scope is IP but ip-access is disabled, scope is to be considered cookie */
if ($payload->scope === Scope::Ip && ! $this->config->ipTtl()) {
/* requested to grant ip access, but that is not enabled */
@@ -69,7 +72,7 @@ final readonly class LoginManager implements LoginInterface {
/* grant access based on the requested scope */
if ($payload->scope === Scope::Cookie) {
$response->headers->setCookie($this->setCookie($cleanId, $request->getHost()));
} else if ($payload->scope === Scope::Ip) {
} elseif ($payload->scope === Scope::Ip) {
$this->setIp($cleanId, $request->getClientIp());
}
@@ -104,7 +107,8 @@ final readonly class LoginManager implements LoginInterface {
}
/** @throws InvalidArgumentException */
private function setCookie(string $id, string $host): Cookie {
private function setCookie(string $id, string $host): Cookie
{
/* successful auth with token, store session and set the cookie */
$ulid = new Ulid();
$sessionCookie = $this->sessionCache->getItem(
@@ -136,7 +140,8 @@ final readonly class LoginManager implements LoginInterface {
}
/** @throws InvalidArgumentException */
private function setIp(string $id, string $ip): void {
private function setIp(string $id, string $ip): void
{
/* successful auth with token, requested scope of ip (and ip access enabled) */
$ipKey = $this->makeCacheKey("ip_$ip");
+9 -4
View File
@@ -1,22 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Trait;
trait CookieNameTrait {
trait CookieNameTrait
{
private const string COOKIE_NAME = '__Host-Http-Preauth';
private const string AUTH_COOKIE_NAME = '__Http-Domain-Preauth';
private const string HEADER_NAME = 'X-Preauth';
final protected function cookieName(): string {
final protected function cookieName(): string
{
return static::COOKIE_NAME;
}
final protected function authCookieName(): string {
final protected function authCookieName(): string
{
return static::AUTH_COOKIE_NAME;
}
final protected function headerName(): string {
final protected function headerName(): string
{
return static::HEADER_NAME;
}
}
+9 -4
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Trait;
@@ -9,17 +10,21 @@ use OTPHP\TOTPInterface;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Contracts\Service\Attribute\Required;
trait GetTotpTrait {
trait GetTotpTrait
{
protected readonly ConfigBag $config;
#[Required]
public function setConfig(ConfigBag $config): void {
public function setConfig(ConfigBag $config): void
{
$this->config = $config;
}
protected function getTotp(): TOTPInterface {
protected function getTotp(): TOTPInterface
{
$otp = Factory::loadFromProvisioningUri(
$this->config->totpUri(), $this->config->clock()
$this->config->totpUri(),
$this->config->clock()
);
if ($otp instanceof TOTPInterface) {
return $otp;
+5 -2
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Trait;
@@ -6,11 +7,13 @@ namespace App\Trait;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\Service\Attribute\Required;
trait HasLoggerTrait {
trait HasLoggerTrait
{
protected readonly LoggerInterface $logger;
#[Required]
public function setLogger(LoggerInterface $logger): void {
public function setLogger(LoggerInterface $logger): void
{
$this->logger = $logger;
}
}
+7 -3
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App\Trait;
@@ -10,7 +11,8 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Contracts\Service\Attribute\Required;
trait MakeNonceTrait {
trait MakeNonceTrait
{
use HasLoggerTrait;
use StringTrait;
@@ -21,12 +23,14 @@ trait MakeNonceTrait {
protected readonly CacheItemPoolInterface $nonceCache;
#[Required]
public function setNonceCache(CacheItemPoolInterface $nonceCache): void {
public function setNonceCache(CacheItemPoolInterface $nonceCache): void
{
$this->nonceCache = $nonceCache;
}
/** @throws InvalidArgumentException|Exception */
protected function makeNonce(int $retries = 3): string {
protected function makeNonce(int $retries = 3): string
{
/* convert raw binary into base64url */
$nonce = rtrim(strtr(base64_encode(random_bytes(
static::NONCE_LENGTH
+5 -2
View File
@@ -1,13 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Trait;
trait StringTrait {
trait StringTrait
{
/* cache keys can safely use alphanumeric, "_", and ".", remove the rest */
private const string KEY_REGEX = '/[^A-Za-z0-9_.]+/';
public function makeCacheKey(string $name): string {
public function makeCacheKey(string $name): string
{
return mb_substr(preg_replace(static::KEY_REGEX, '_', $name), 0, 128);
}
}
+17 -8
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
namespace App;
@@ -11,14 +12,17 @@ use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
use Psr\Clock\ClockInterface;
final readonly class Utilities {
final readonly class Utilities
{
public function __construct(
private ClockInterface $clock,
private CacheItemPoolInterface $appPool,
) {}
) {
}
/** @throws InvalidArgumentException */
public function loadTotp(): string {
public function loadTotp(): string
{
/* user forgot to set their TOTP_URI in the environment */
if ($this->appPool->hasItem('totp')) {
$totp = $this->appPool->getItem('totp')->get();
@@ -31,7 +35,8 @@ final readonly class Utilities {
}
/** @throws InvalidArgumentException */
private function makeTotp(): string {
private function makeTotp(): string
{
/* we have not stored a totp into the app cache yet */
$totpObj = TOTP::generate($this->clock);
$totpObj->setLabel('Preauth-TOTP');
@@ -41,21 +46,25 @@ final readonly class Utilities {
/* per PSR6, if no expiration is set, implementation may set a default,
* we want this to keep forever, so a few hundred years should do it */
$totpItem->expiresAt(DateTimeImmutable::createFromFormat(
'Y-m-d', '2999-12-31'
'Y-m-d',
'2999-12-31'
));
$this->appPool->save($totpItem);
return $totp;
}
private function showTotp(string $totp): void {
private function showTotp(string $totp): void
{
$writer = new Writer(new PlainTextRenderer());
file_put_contents(
'php://stderr', <<<RAW
'php://stderr',
<<<RAW
{$writer->writeString($totp)}
$totp
loading TOTP, because the env is not set, please copy above into TOTP_URI
RAW, FILE_APPEND
RAW,
FILE_APPEND
);
}
}
+12
View File
@@ -1,4 +1,16 @@
{
"friendsofphp/php-cs-fixer": {
"version": "3.95",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "3.39",
"ref": "97aaf9026490db73b86c23d49e5774bc89d2b232"
},
"files": [
".php-cs-fixer.dist.php"
]
},
"phpunit/phpunit": {
"version": "13.2",
"recipe": {
+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);