chore: add php-cs-fixer with PSR-12 config and CI check
- 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:
@@ -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
|
||||
|
||||
@@ -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 ###
|
||||
|
||||
@@ -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)
|
||||
;
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Kernel;
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 single‑use 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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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,13 +2,13 @@
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
|
||||
use Exception;
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
|
||||
/** backup-codes are case‑insensitive 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
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
@@ -14,19 +15,21 @@ use App\Trait\GetTotpTrait;
|
||||
|
||||
/** backup-codes are case‑insensitive 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,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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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(''));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user