From dbd511031588ce233a356f28f7df30fcee4ed9b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Orlando=20Th=C3=B6ny?= Date: Mon, 15 Dec 2025 15:00:28 +0100 Subject: [PATCH] feat: add possibility to fail open on storage error Changelog: * Add possiblity to configure to skip rate limiting on storage error. Can be configured globally by setting the `noxlogic_rate_limit.fail_open` parameter and/or per `#[RateLimit(failOpen: true)]` Attribute * Added `RateLimitStorageExceptionInterface` as possible exception to `StorageInterface` methods --- Attribute/RateLimit.php | 15 +- DependencyInjection/Configuration.php | 5 + .../NoxlogicRateLimitExtension.php | 2 + EventListener/RateLimitAnnotationListener.php | 52 ++-- .../CreateRateRateLimitStorageException.php | 15 ++ .../GetRateInfoRateLimitStorageException.php | 15 ++ .../LimitRateRateLimitStorageException.php | 15 ++ .../Storage/RateLimitStorageException.php | 21 ++ .../RateLimitStorageExceptionInterface.php | 12 + .../ResetRateRateLimitStorageException.php | 15 ++ README.md | 48 ++++ Resources/config/services.xml | 4 + Service/RateLimitService.php | 7 +- Service/Storage/DoctrineCache.php | 36 ++- Service/Storage/Memcache.php | 42 ++- Service/Storage/PhpRedis.php | 43 ++- Service/Storage/PsrCache.php | 45 +++- Service/Storage/Redis.php | 44 +++- Service/Storage/SimpleCache.php | 39 ++- Service/Storage/StorageInterface.php | 11 +- Tests/Attribute/RateLimitTest.php | 25 +- .../DependencyInjection/ConfigurationTest.php | 33 +++ .../NoxlogicRateLimitExtensionTest.php | 24 +- Tests/EventListener/MockController.php | 4 +- .../MockControllerWithAttributes.php | 8 +- Tests/EventListener/MockStorage.php | 21 +- .../RateLimitAnnotationListenerTest.php | 246 ++++++++++++------ Tests/Service/Storage/DoctrineCacheTest.php | 86 +++++- Tests/Service/Storage/PsrCacheTest.php | 119 +++++++-- Tests/Service/Storage/RedisTest.php | 106 ++++++-- Tests/Service/Storage/SimpleCacheTest.php | 85 +++++- phpstan-baseline.neon | 6 - phpstan.dist.neon | 2 + 33 files changed, 1035 insertions(+), 216 deletions(-) create mode 100644 Exception/Storage/CreateRateRateLimitStorageException.php create mode 100644 Exception/Storage/GetRateInfoRateLimitStorageException.php create mode 100644 Exception/Storage/LimitRateRateLimitStorageException.php create mode 100644 Exception/Storage/RateLimitStorageException.php create mode 100644 Exception/Storage/RateLimitStorageExceptionInterface.php create mode 100644 Exception/Storage/ResetRateRateLimitStorageException.php diff --git a/Attribute/RateLimit.php b/Attribute/RateLimit.php index a4cf08a..fb81400 100644 --- a/Attribute/RateLimit.php +++ b/Attribute/RateLimit.php @@ -26,7 +26,15 @@ public function __construct( /** * @var mixed Generic payload */ - public mixed $payload = null + public mixed $payload = null, + + /** + * @var bool|null Defines if the rate limiter blocks the request when a technical problem occurs (default). + * For example, when the Redis database which is used as a rate limit storage is down. + * If set to `false`, the request is allowed to proceed even if the rate limiter cannot determine if the rate limit has been exceeded. + * `null` means that the globally-configured default should be used + */ + public ?bool $failOpen = null ) { // @RateLimit annotation used to support single method passed as string, keep that for retrocompatibility if (!is_array($methods)) { @@ -75,4 +83,9 @@ public function setPayload(mixed $payload): void { $this->payload = $payload; } + + public function setFailOpen(bool $failOpen): void + { + $this->failOpen = $failOpen; + } } diff --git a/DependencyInjection/Configuration.php b/DependencyInjection/Configuration.php index b16a16e..9dc325d 100755 --- a/DependencyInjection/Configuration.php +++ b/DependencyInjection/Configuration.php @@ -135,6 +135,11 @@ public function getConfigTreeBuilder(): TreeBuilder ->end() ->end() ->end() + ->booleanNode('fail_open') + ->defaultFalse() + ->treatNullLike(false) + ->info('Defines if the rate limiter blocks the request when a technical problem occurs') + ->end() ->booleanNode('fos_oauth_key_listener') ->defaultTrue() ->info('Enabled the FOS OAuthServerBundle listener') diff --git a/DependencyInjection/NoxlogicRateLimitExtension.php b/DependencyInjection/NoxlogicRateLimitExtension.php index 077a399..f79a9a8 100755 --- a/DependencyInjection/NoxlogicRateLimitExtension.php +++ b/DependencyInjection/NoxlogicRateLimitExtension.php @@ -45,6 +45,8 @@ private function loadServices(ContainerBuilder $container, array $config): void $container->setParameter('noxlogic_rate_limit.path_limits', $config['path_limits']); + $container->setParameter('noxlogic_rate_limit.fail_open', $config['fail_open']); + switch ($config['storage_engine']) { case 'memcache': $container->setParameter('noxlogic_rate_limit.storage.class', 'Noxlogic\RateLimitBundle\Service\Storage\Memcache'); diff --git a/EventListener/RateLimitAnnotationListener.php b/EventListener/RateLimitAnnotationListener.php index 7ea8440..56766b5 100644 --- a/EventListener/RateLimitAnnotationListener.php +++ b/EventListener/RateLimitAnnotationListener.php @@ -7,6 +7,7 @@ use Noxlogic\RateLimitBundle\Events\GenerateKeyEvent; use Noxlogic\RateLimitBundle\Events\RateLimitEvents; use Noxlogic\RateLimitBundle\Exception\RateLimitExceptionInterface; +use Noxlogic\RateLimitBundle\Exception\Storage\RateLimitStorageExceptionInterface; use Noxlogic\RateLimitBundle\Service\RateLimitService; use Noxlogic\RateLimitBundle\Util\PathLimitProcessor; use Symfony\Component\HttpFoundation\Request; @@ -33,6 +34,9 @@ public function __construct( $this->pathLimitProcessor = $pathLimitProcessor; } + /** + * @throws RateLimitStorageExceptionInterface + */ public function onKernelController(ControllerEvent $event): void { // Skip if the bundle isn't enabled (for instance in test environment) @@ -68,31 +72,39 @@ public function onKernelController(ControllerEvent $event): void $key = $this->getKey($event, $rateLimit, $rateLimits); - // Ratelimit the call - $rateLimitInfo = $this->rateLimitService->limitRate($key); - if (! $rateLimitInfo) { - // Create new rate limit entry for this call - $rateLimitInfo = $this->rateLimitService->createRate($key, $rateLimit->getLimit(), $rateLimit->getPeriod()); + $shouldFailOpenOnStorageError = $rateLimit->failOpen ?? $this->getParameter('fail_open', false); + try { + // Ratelimit the call + $rateLimitInfo = $this->rateLimitService->limitRate($key); if (! $rateLimitInfo) { - // @codeCoverageIgnoreStart - return; - // @codeCoverageIgnoreEnd + // Create new rate limit entry for this call + $rateLimitInfo = $this->rateLimitService->createRate($key, $rateLimit->getLimit(), $rateLimit->getPeriod()); + if (! $rateLimitInfo) { + // @codeCoverageIgnoreStart + return; + // @codeCoverageIgnoreEnd + } } - } - - // Store the current rating info in the request attributes - $request->attributes->set('rate_limit_info', $rateLimitInfo); - - // Reset the rate limits - if(time() >= $rateLimitInfo->getResetTimestamp()) { - $this->rateLimitService->resetRate($key); - $rateLimitInfo = $this->rateLimitService->createRate($key, $rateLimit->getLimit(), $rateLimit->getPeriod()); - if (! $rateLimitInfo) { - // @codeCoverageIgnoreStart + // Store the current rating info in the request attributes + $request->attributes->set('rate_limit_info', $rateLimitInfo); + + // Reset the rate limits + if(time() >= $rateLimitInfo->getResetTimestamp()) { + $this->rateLimitService->resetRate($key); + $rateLimitInfo = $this->rateLimitService->createRate($key, $rateLimit->getLimit(), $rateLimit->getPeriod()); + if (! $rateLimitInfo) { + // @codeCoverageIgnoreStart + return; + // @codeCoverageIgnoreEnd + } + } + } catch (RateLimitStorageExceptionInterface $storageException) { + if ($shouldFailOpenOnStorageError) { return; - // @codeCoverageIgnoreEnd } + + throw $storageException; } // When we exceeded our limit, return a custom error response diff --git a/Exception/Storage/CreateRateRateLimitStorageException.php b/Exception/Storage/CreateRateRateLimitStorageException.php new file mode 100644 index 0000000..b7b36e5 --- /dev/null +++ b/Exception/Storage/CreateRateRateLimitStorageException.php @@ -0,0 +1,15 @@ +getMessage()) { + $message .= \sprintf(': %s', $previous->getMessage()); + } + + parent::__construct($message, previous: $previous); + } +} \ No newline at end of file diff --git a/Exception/Storage/RateLimitStorageExceptionInterface.php b/Exception/Storage/RateLimitStorageExceptionInterface.php new file mode 100644 index 0000000..e0cfc17 --- /dev/null +++ b/Exception/Storage/RateLimitStorageExceptionInterface.php @@ -0,0 +1,12 @@ + If you set `$failOpen` to `true` or `false`, it takes precedence over the globally configured value (`noxlogic_rate_limit.fail_open`). + +```php +enabled %noxlogic_rate_limit.enabled% + + fail_open + %noxlogic_rate_limit.fail_open% + rate_response_code %noxlogic_rate_limit.rate_response_code% diff --git a/Service/RateLimitService.php b/Service/RateLimitService.php index eced9f8..3d9b0cc 100644 --- a/Service/RateLimitService.php +++ b/Service/RateLimitService.php @@ -2,6 +2,7 @@ namespace Noxlogic\RateLimitBundle\Service; +use Noxlogic\RateLimitBundle\Exception\Storage\RateLimitStorageExceptionInterface; use Noxlogic\RateLimitBundle\Service\Storage\StorageInterface; class RateLimitService @@ -32,7 +33,7 @@ public function getStorage() } /** - * + * @throws RateLimitStorageExceptionInterface */ public function limitRate($key) { @@ -40,7 +41,7 @@ public function limitRate($key) } /** - * + * @throws RateLimitStorageExceptionInterface */ public function createRate($key, $limit, $period) { @@ -48,7 +49,7 @@ public function createRate($key, $limit, $period) } /** - * + * @throws RateLimitStorageExceptionInterface */ public function resetRate($key) { diff --git a/Service/Storage/DoctrineCache.php b/Service/Storage/DoctrineCache.php index 3adf5f3..7dddd37 100644 --- a/Service/Storage/DoctrineCache.php +++ b/Service/Storage/DoctrineCache.php @@ -3,6 +3,10 @@ namespace Noxlogic\RateLimitBundle\Service\Storage; use Doctrine\Common\Cache\Cache; +use Noxlogic\RateLimitBundle\Exception\Storage\CreateRateRateLimitStorageException; +use Noxlogic\RateLimitBundle\Exception\Storage\GetRateInfoRateLimitStorageException; +use Noxlogic\RateLimitBundle\Exception\Storage\LimitRateRateLimitStorageException; +use Noxlogic\RateLimitBundle\Exception\Storage\ResetRateRateLimitStorageException; use Noxlogic\RateLimitBundle\Service\RateLimitInfo; class DoctrineCache implements StorageInterface { @@ -19,7 +23,12 @@ public function __construct(Cache $client) public function getRateInfo($key) { - $info = $this->client->fetch($key); + try { + $info = $this->client->fetch($key); + } catch (\Throwable $e) { + throw new GetRateInfoRateLimitStorageException($e); + } + if ($info === false || !array_key_exists('limit', $info)) { return false; } @@ -29,7 +38,12 @@ public function getRateInfo($key) public function limitRate($key) { - $info = $this->client->fetch($key); + try { + $info = $this->client->fetch($key); + } catch (\Throwable $e) { + throw new LimitRateRateLimitStorageException($e); + } + if ($info === false || !array_key_exists('limit', $info)) { return false; } @@ -38,7 +52,11 @@ public function limitRate($key) $expire = $info['reset'] - time(); - $this->client->save($key, $info, $expire); + try { + $this->client->save($key, $info, $expire); + } catch (\Throwable $e) { + throw new LimitRateRateLimitStorageException($e); + } return $this->createRateInfo($info); } @@ -50,14 +68,22 @@ public function createRate($key, $limit, $period) $info['calls'] = 1; $info['reset'] = time() + $period; - $this->client->save($key, $info, $period); + try { + $this->client->save($key, $info, $period); + } catch (\Throwable $e) { + throw new CreateRateRateLimitStorageException($e); + } return $this->createRateInfo($info); } public function resetRate($key) { - $this->client->delete($key); + try { + $this->client->delete($key); + } catch (\Throwable $e) { + throw new ResetRateRateLimitStorageException($e); + } return true; } diff --git a/Service/Storage/Memcache.php b/Service/Storage/Memcache.php index 707fd30..566b48c 100644 --- a/Service/Storage/Memcache.php +++ b/Service/Storage/Memcache.php @@ -2,6 +2,10 @@ namespace Noxlogic\RateLimitBundle\Service\Storage; +use Noxlogic\RateLimitBundle\Exception\Storage\CreateRateRateLimitStorageException; +use Noxlogic\RateLimitBundle\Exception\Storage\GetRateInfoRateLimitStorageException; +use Noxlogic\RateLimitBundle\Exception\Storage\LimitRateRateLimitStorageException; +use Noxlogic\RateLimitBundle\Exception\Storage\ResetRateRateLimitStorageException; use Noxlogic\RateLimitBundle\Service\RateLimitInfo; class Memcache implements StorageInterface @@ -18,7 +22,11 @@ public function __construct(\Memcached $client) public function getRateInfo($key) { - $info = $this->client->get($key); + try { + $info = $this->client->get($key); + } catch (\Throwable $e) { + throw new GetRateInfoRateLimitStorageException($e); + } return $this->createRateInfo($info); } @@ -29,18 +37,31 @@ public function limitRate($key) $i = 0; do { if (defined('Memcached::GET_EXTENDED')) { - $_o = $this->client->get($key, null, \Memcached::GET_EXTENDED); + try { + $_o = $this->client->get($key, null, \Memcached::GET_EXTENDED); + } catch (\Throwable $e) { + throw new LimitRateRateLimitStorageException($e); + } + $info = $_o['value'] ?? null; $cas = $_o['cas'] ?? null; } else { - $info = $this->client->get($key, null, $cas); + try { + $info = $this->client->get($key, null, $cas); + } catch (\Throwable $e) { + throw new LimitRateRateLimitStorageException($e); + } } if (!$info) { return false; } $info['calls']++; - $this->client->cas($cas, $key, $info); + try { + $this->client->cas($cas, $key, $info); + } catch (\Throwable $e) { + throw new LimitRateRateLimitStorageException($e); + } } while ($this->client->getResultCode() == \Memcached::RES_DATA_EXISTS && $i++ < 5); return $this->createRateInfo($info); @@ -53,14 +74,23 @@ public function createRate($key, $limit, $period) $info['calls'] = 1; $info['reset'] = time() + $period; - $this->client->set($key, $info, $period); + try { + $this->client->set($key, $info, $period); + } catch (\Throwable $e) { + throw new CreateRateRateLimitStorageException($e); + } return $this->createRateInfo($info); } public function resetRate($key) { - $this->client->delete($key); + try { + $this->client->delete($key); + } catch (\Throwable $e) { + throw new ResetRateRateLimitStorageException($e); + } + return true; } diff --git a/Service/Storage/PhpRedis.php b/Service/Storage/PhpRedis.php index dc1e018..ae80b91 100644 --- a/Service/Storage/PhpRedis.php +++ b/Service/Storage/PhpRedis.php @@ -4,6 +4,10 @@ namespace Noxlogic\RateLimitBundle\Service\Storage; +use Noxlogic\RateLimitBundle\Exception\Storage\CreateRateRateLimitStorageException; +use Noxlogic\RateLimitBundle\Exception\Storage\GetRateInfoRateLimitStorageException; +use Noxlogic\RateLimitBundle\Exception\Storage\LimitRateRateLimitStorageException; +use Noxlogic\RateLimitBundle\Exception\Storage\ResetRateRateLimitStorageException; use Noxlogic\RateLimitBundle\Service\RateLimitInfo; class PhpRedis implements StorageInterface @@ -22,7 +26,12 @@ public function getRateInfo($key) { $key = $this->sanitizeRedisKey($key); - $info = $this->client->hgetall($key); + try { + $info = $this->client->hgetall($key); + } catch (\Throwable $e) { + throw new GetRateInfoRateLimitStorageException($e); + } + if (!isset($info['limit']) || !isset($info['calls']) || !isset($info['reset'])) { return false; } @@ -39,12 +48,22 @@ public function limitRate($key) { $key = $this->sanitizeRedisKey($key); - $info = $this->getRateInfo($key); + try { + $info = $this->getRateInfo($key); + // We want to make sure we throw the exception with the proper class + } catch (GetRateInfoRateLimitStorageException $e) { + throw new LimitRateRateLimitStorageException($e->getPrevious()); + } + if (!$info) { return false; } - $calls = $this->client->hincrby($key, 'calls', 1); + try { + $calls = $this->client->hincrby($key, 'calls', 1); + } catch (\Throwable $e) { + throw new LimitRateRateLimitStorageException($e); + } $info->setCalls($calls); return $info; @@ -56,10 +75,14 @@ public function createRate($key, $limit, $period) $reset = time() + $period; - $this->client->hset($key, 'limit', $limit); - $this->client->hset($key, 'calls', 1); - $this->client->hset($key, 'reset', $reset); - $this->client->expire($key, $period); + try { + $this->client->hset($key, 'limit', $limit); + $this->client->hset($key, 'calls', 1); + $this->client->hset($key, 'reset', $reset); + $this->client->expire($key, $period); + } catch (\Throwable $e) { + throw new CreateRateRateLimitStorageException($e); + } $rateLimitInfo = new RateLimitInfo(); $rateLimitInfo->setLimit($limit); @@ -73,7 +96,11 @@ public function resetRate($key) { $key = $this->sanitizeRedisKey($key); - $this->client->del($key); + try { + $this->client->del($key); + } catch (\Throwable $e) { + throw new ResetRateRateLimitStorageException($e); + } return true; } diff --git a/Service/Storage/PsrCache.php b/Service/Storage/PsrCache.php index 0d2b8a7..763ef93 100644 --- a/Service/Storage/PsrCache.php +++ b/Service/Storage/PsrCache.php @@ -2,8 +2,11 @@ namespace Noxlogic\RateLimitBundle\Service\Storage; +use Noxlogic\RateLimitBundle\Exception\Storage\CreateRateRateLimitStorageException; +use Noxlogic\RateLimitBundle\Exception\Storage\GetRateInfoRateLimitStorageException; +use Noxlogic\RateLimitBundle\Exception\Storage\LimitRateRateLimitStorageException; +use Noxlogic\RateLimitBundle\Exception\Storage\ResetRateRateLimitStorageException; use Noxlogic\RateLimitBundle\Service\RateLimitInfo; -use Noxlogic\RateLimitBundle\Service\Storage\StorageInterface; use Psr\Cache\CacheItemPoolInterface; class PsrCache implements StorageInterface @@ -20,7 +23,12 @@ public function __construct(CacheItemPoolInterface $client) public function getRateInfo($key) { - $item = $this->client->getItem($key); + try { + $item = $this->client->getItem($key); + } catch (\Throwable $e) { + throw new GetRateInfoRateLimitStorageException($e); + } + if (!$item->isHit()) { return false; } @@ -30,7 +38,12 @@ public function getRateInfo($key) public function limitRate($key) { - $item = $this->client->getItem($key); + try { + $item = $this->client->getItem($key); + } catch (\Throwable $e) { + throw new LimitRateRateLimitStorageException($e); + } + if (!$item->isHit()) { return false; } @@ -41,7 +54,11 @@ public function limitRate($key) $item->set($info); $item->expiresAfter($info['reset'] - time()); - $this->client->save($item); + try { + $this->client->save($item); + } catch (\Throwable $e) { + throw new LimitRateRateLimitStorageException($e); + } return $this->createRateInfo($info); } @@ -53,18 +70,32 @@ public function createRate($key, $limit, $period) 'calls' => 1, 'reset' => time() + $period, ]; - $item = $this->client->getItem($key); + + try { + $item = $this->client->getItem($key); + } catch (\Throwable $e) { + throw new CreateRateRateLimitStorageException($e); + } + $item->set($info); $item->expiresAfter($period); - $this->client->save($item); + try { + $this->client->save($item); + } catch (\Throwable $e) { + throw new CreateRateRateLimitStorageException($e); + } return $this->createRateInfo($info); } public function resetRate($key) { - $this->client->deleteItem($key); + try { + $this->client->deleteItem($key); + } catch (\Throwable $e) { + throw new ResetRateRateLimitStorageException($e); + } return true; } diff --git a/Service/Storage/Redis.php b/Service/Storage/Redis.php index d510f5b..26b7a1b 100644 --- a/Service/Storage/Redis.php +++ b/Service/Storage/Redis.php @@ -2,6 +2,10 @@ namespace Noxlogic\RateLimitBundle\Service\Storage; +use Noxlogic\RateLimitBundle\Exception\Storage\CreateRateRateLimitStorageException; +use Noxlogic\RateLimitBundle\Exception\Storage\GetRateInfoRateLimitStorageException; +use Noxlogic\RateLimitBundle\Exception\Storage\LimitRateRateLimitStorageException; +use Noxlogic\RateLimitBundle\Exception\Storage\ResetRateRateLimitStorageException; use Noxlogic\RateLimitBundle\Service\RateLimitInfo; use Predis\ClientInterface; @@ -21,7 +25,12 @@ public function getRateInfo($key) { $key = $this->sanitizeRedisKey($key); - $info = $this->client->hgetall($key); + try { + $info = $this->client->hgetall($key); + } catch (\Throwable $e) { + throw new GetRateInfoRateLimitStorageException($e); + } + if (!isset($info['limit']) || !isset($info['calls']) || !isset($info['reset'])) { return false; } @@ -38,12 +47,23 @@ public function limitRate($key) { $key = $this->sanitizeRedisKey($key); - $info = $this->getRateInfo($key); + try { + $info = $this->getRateInfo($key); + // We want to make sure we throw the exception with the proper class + } catch (GetRateInfoRateLimitStorageException $e) { + throw new LimitRateRateLimitStorageException($e->getPrevious()); + } + if (!$info) { return false; } - $calls = $this->client->hincrby($key, 'calls', 1); + try { + $calls = $this->client->hincrby($key, 'calls', 1); + } catch (\Throwable $e) { + throw new LimitRateRateLimitStorageException($e); + } + $info->setCalls($calls); return $info; @@ -55,10 +75,14 @@ public function createRate($key, $limit, $period) $reset = time() + $period; - $this->client->hset($key, 'limit', (string) $limit); - $this->client->hset($key, 'calls', '1'); - $this->client->hset($key, 'reset', (string) $reset); - $this->client->expire($key, $period); + try { + $this->client->hset($key, 'limit', (string) $limit); + $this->client->hset($key, 'calls', '1'); + $this->client->hset($key, 'reset', (string) $reset); + $this->client->expire($key, $period); + } catch (\Throwable $e) { + throw new CreateRateRateLimitStorageException($e); + } $rateLimitInfo = new RateLimitInfo(); $rateLimitInfo->setLimit($limit); @@ -72,7 +96,11 @@ public function resetRate($key) { $key = $this->sanitizeRedisKey($key); - $this->client->del($key); + try { + $this->client->del($key); + } catch (\Throwable $e) { + throw new ResetRateRateLimitStorageException($e); + } return true; } diff --git a/Service/Storage/SimpleCache.php b/Service/Storage/SimpleCache.php index 7821633..f443aa9 100644 --- a/Service/Storage/SimpleCache.php +++ b/Service/Storage/SimpleCache.php @@ -2,6 +2,10 @@ namespace Noxlogic\RateLimitBundle\Service\Storage; +use Noxlogic\RateLimitBundle\Exception\Storage\CreateRateRateLimitStorageException; +use Noxlogic\RateLimitBundle\Exception\Storage\GetRateInfoRateLimitStorageException; +use Noxlogic\RateLimitBundle\Exception\Storage\LimitRateRateLimitStorageException; +use Noxlogic\RateLimitBundle\Exception\Storage\ResetRateRateLimitStorageException; use Noxlogic\RateLimitBundle\Service\RateLimitInfo; use Psr\SimpleCache\CacheInterface; @@ -19,7 +23,12 @@ public function __construct(CacheInterface $client) public function getRateInfo($key) { - $info = $this->client->get($key); + try { + $info = $this->client->get($key); + } catch (\Throwable $e) { + throw new GetRateInfoRateLimitStorageException($e); + } + if ($info === null || !array_key_exists('limit', $info)) { return false; } @@ -29,7 +38,12 @@ public function getRateInfo($key) public function limitRate($key) { - $info = $this->client->get($key); + try { + $info = $this->client->get($key); + } catch (\Throwable $e) { + throw new LimitRateRateLimitStorageException($e); + } + if ($info === null || !array_key_exists('limit', $info)) { return false; } @@ -37,7 +51,11 @@ public function limitRate($key) $info['calls']++; $ttl = $info['reset'] - time(); - $this->client->set($key, $info, $ttl); + try { + $this->client->set($key, $info, $ttl); + } catch (\Throwable $e) { + throw new LimitRateRateLimitStorageException($e); + } return $this->createRateInfo($info); } @@ -49,19 +67,28 @@ public function createRate($key, $limit, $period) 'calls' => 1, 'reset' => time() + $period, ]; - $this->client->set($key, $info, $period); + + try { + $this->client->set($key, $info, $period); + } catch (\Throwable $e) { + throw new CreateRateRateLimitStorageException($e); + } return $this->createRateInfo($info); } public function resetRate($key) { - $this->client->delete($key); + try { + $this->client->delete($key); + } catch (\Throwable $e) { + throw new ResetRateRateLimitStorageException($e); + } return true; } - private function createRateInfo(array $info) + private function createRateInfo(array $info): RateLimitInfo { $rateLimitInfo = new RateLimitInfo(); $rateLimitInfo->setLimit($info['limit']); diff --git a/Service/Storage/StorageInterface.php b/Service/Storage/StorageInterface.php index ec93424..571483c 100644 --- a/Service/Storage/StorageInterface.php +++ b/Service/Storage/StorageInterface.php @@ -2,6 +2,7 @@ namespace Noxlogic\RateLimitBundle\Service\Storage; +use Noxlogic\RateLimitBundle\Exception\Storage\RateLimitStorageExceptionInterface; use Noxlogic\RateLimitBundle\Service\RateLimitInfo; interface StorageInterface @@ -12,7 +13,9 @@ interface StorageInterface * @param string $key * @return RateLimitInfo|bool Rate limit information * @todo: Replace return type with RateLimitInfo|false when PHP 8.2 is the minimum version - */ + * + * @throws RateLimitStorageExceptionInterface + */ public function getRateInfo($key); /** @@ -21,6 +24,8 @@ public function getRateInfo($key); * @param string $key * @return RateLimitInfo|bool Rate limit info * @todo: Replace return type with RateLimitInfo|false when PHP 8.2 is the minimum version + * + * @throws RateLimitStorageExceptionInterface */ public function limitRate($key); @@ -30,6 +35,8 @@ public function limitRate($key); * @param string $key * @param integer $limit * @param integer $period + * + * @throws RateLimitStorageExceptionInterface */ public function createRate($key, $limit, $period); @@ -37,6 +44,8 @@ public function createRate($key, $limit, $period); * Reset the rating * * @param $key + * + * @throws RateLimitStorageExceptionInterface */ public function resetRate($key); } diff --git a/Tests/Attribute/RateLimitTest.php b/Tests/Attribute/RateLimitTest.php index ddda5fd..3243f68 100644 --- a/Tests/Attribute/RateLimitTest.php +++ b/Tests/Attribute/RateLimitTest.php @@ -11,9 +11,10 @@ public function testConstruction(): void { $attribute = new RateLimit(); - $this->assertEquals(-1, $attribute->limit); - $this->assertEmpty($attribute->methods); - $this->assertEquals(3600, $attribute->period); + self::assertSame(-1, $attribute->limit); + self::assertEmpty($attribute->methods); + self::assertSame(3600, $attribute->period); + self::assertNull($attribute->failOpen); } public function testConstructionWithValues(): void @@ -21,19 +22,23 @@ public function testConstructionWithValues(): void $attribute = new RateLimit( [], 1234, - 1000 + 1000, + failOpen: true ); - $this->assertEquals(1234, $attribute->limit); - $this->assertEquals(1000, $attribute->period); + self::assertSame(1234, $attribute->limit); + self::assertSame(1000, $attribute->period); + self::assertTrue($attribute->failOpen); $attribute = new RateLimit( ['POST'], 1234, - 1000 + 1000, + failOpen: false ); - $this->assertEquals(1234, $attribute->limit); - $this->assertEquals(1000, $attribute->period); - $this->assertEquals(['POST'], $attribute->methods); + self::assertSame(1234, $attribute->limit); + self::assertSame(1000, $attribute->period); + self::assertSame(['POST'], $attribute->methods); + self::assertFalse($attribute->failOpen); } public function testConstructionWithMethods(): void diff --git a/Tests/DependencyInjection/ConfigurationTest.php b/Tests/DependencyInjection/ConfigurationTest.php index b881ce4..3577965 100644 --- a/Tests/DependencyInjection/ConfigurationTest.php +++ b/Tests/DependencyInjection/ConfigurationTest.php @@ -49,6 +49,7 @@ public function testUnconfiguredConfiguration(): void 'reset' => 'X-RateLimit-Reset', ), 'path_limits' => array(), + 'fail_open' => false, 'fos_oauth_key_listener' => true ), $configuration); } @@ -177,4 +178,36 @@ public function testMustBeBasedOnExceptionOrNull(): void # no exception triggered is ok. $this->expectNotToPerformAssertions(); } + + public function testFailOpen(): void + { + $config = $this->getConfigs(['fail_open' => true]); + + self::assertTrue($config['fail_open']); + } + + public function testFailOpen_false(): void + { + $config = $this->getConfigs(['fail_open' => false]); + + self::assertFalse($config['fail_open']); + } + + public function testFailOpen_nullShouldBeTreatedAsFalse(): void + { + $config = $this->getConfigs(['fail_open' => null]); + + self::assertFalse($config['fail_open']); + } + + /** + * @testWith ["not-a-boolean"] + * [123] + */ + public function testFailOpen_mustBeBool(mixed $value): void + { + $this->expectException(InvalidConfigurationException::class); + + $this->getConfigs(['fail_open' => $value]); + } } diff --git a/Tests/DependencyInjection/NoxlogicRateLimitExtensionTest.php b/Tests/DependencyInjection/NoxlogicRateLimitExtensionTest.php index c55e94e..71068dd 100644 --- a/Tests/DependencyInjection/NoxlogicRateLimitExtensionTest.php +++ b/Tests/DependencyInjection/NoxlogicRateLimitExtensionTest.php @@ -22,10 +22,11 @@ public function testAreParametersSet() $containerBuilder = new ContainerBuilder(new ParameterBag()); $extension->load(array(), $containerBuilder); - $this->assertEquals($containerBuilder->getParameter('noxlogic_rate_limit.enabled'), true); - $this->assertEquals($containerBuilder->getParameter('noxlogic_rate_limit.rate_response_code'), 429); - $this->assertEquals($containerBuilder->getParameter('noxlogic_rate_limit.display_headers'), true); - $this->assertEquals($containerBuilder->getParameter('noxlogic_rate_limit.headers.reset.name'), 'X-RateLimit-Reset'); + self::assertTrue($containerBuilder->getParameter('noxlogic_rate_limit.enabled')); + self::assertSame(429, $containerBuilder->getParameter('noxlogic_rate_limit.rate_response_code')); + self::assertTrue($containerBuilder->getParameter('noxlogic_rate_limit.display_headers')); + self::assertSame('X-RateLimit-Reset', $containerBuilder->getParameter('noxlogic_rate_limit.headers.reset.name')); + self::assertFalse($containerBuilder->getParameter('noxlogic_rate_limit.fail_open')); } public function testStorageEngineParameterProvider() @@ -62,7 +63,7 @@ public function testStorageEngineParameterService() $this->assertEquals('my.redis_cache', (string)($storageDef->getArgument(0))); } - public function testParametersWhenDisabled() + public function testParametersWhenDisabled(): void { $extension = new NoxlogicRateLimitExtension(); $containerBuilder = new ContainerBuilder(new ParameterBag()); @@ -84,8 +85,17 @@ public function testPathLimitsParameter() $extension = new NoxlogicRateLimitExtension(); $containerBuilder = new ContainerBuilder(new ParameterBag()); - $extension->load(array(array('path_limits' => $pathLimits)), $containerBuilder); + $extension->load([['path_limits' => $pathLimits]], $containerBuilder); - $this->assertEquals($containerBuilder->getParameter('noxlogic_rate_limit.path_limits'), $pathLimits); + self::assertSame($pathLimits, $containerBuilder->getParameter('noxlogic_rate_limit.path_limits')); + } + + public function testFailOpenParameter(): void + { + $extension = new NoxlogicRateLimitExtension(); + $containerBuilder = new ContainerBuilder(new ParameterBag()); + $extension->load([['fail_open' => true]], $containerBuilder); + + self::assertTrue($containerBuilder->getParameter('noxlogic_rate_limit.fail_open')); } } diff --git a/Tests/EventListener/MockController.php b/Tests/EventListener/MockController.php index c906b05..c5e7d92 100644 --- a/Tests/EventListener/MockController.php +++ b/Tests/EventListener/MockController.php @@ -3,5 +3,7 @@ namespace Noxlogic\RateLimitBundle\Tests\EventListener; class MockController { - function mockAction() { } + public const RATE_LIMIT_KEY = 'Noxlogic.RateLimitBundle.Tests.EventListener.MockController.mockAction'; + + public function mockAction(): void { } } \ No newline at end of file diff --git a/Tests/EventListener/MockControllerWithAttributes.php b/Tests/EventListener/MockControllerWithAttributes.php index 5f455db..31c2a5b 100644 --- a/Tests/EventListener/MockControllerWithAttributes.php +++ b/Tests/EventListener/MockControllerWithAttributes.php @@ -8,5 +8,11 @@ class MockControllerWithAttributes extends MockController { #[RateLimit(limit: 10, period: 100)] - function mockAction() { } + public function mockAction(): void { } + + #[RateLimit(limit: 10, period: 100, failOpen: true)] + public function failOpenMockAction(): void { } + + #[RateLimit(limit: 10, period: 100, failOpen: false)] + public function doNotFailOpenMockAction(): void { } } \ No newline at end of file diff --git a/Tests/EventListener/MockStorage.php b/Tests/EventListener/MockStorage.php index 56d8a92..4472a36 100644 --- a/Tests/EventListener/MockStorage.php +++ b/Tests/EventListener/MockStorage.php @@ -2,6 +2,7 @@ namespace Noxlogic\RateLimitBundle\Tests\EventListener; +use Noxlogic\RateLimitBundle\Exception\Storage\RateLimitStorageExceptionInterface; use Noxlogic\RateLimitBundle\Service\RateLimitInfo; use Noxlogic\RateLimitBundle\Service\Storage\StorageInterface; @@ -19,6 +20,10 @@ public function getRateInfo($key) { $info = $this->rates[$key]; + if ($info instanceof RateLimitStorageExceptionInterface) { + throw $info; + } + $rateLimitInfo = new RateLimitInfo(); $rateLimitInfo->setCalls($info['calls']); $rateLimitInfo->setResetTimestamp($info['reset']); @@ -38,6 +43,10 @@ public function limitRate($key) return null; } + if ($this->rates[$key] instanceof RateLimitStorageExceptionInterface) { + throw $this->rates[$key]; + } + $this->rates[$key]['calls']++; return $this->getRateInfo($key); } @@ -66,9 +75,19 @@ public function resetRate($key) unset($this->rates[$key]); } - public function createMockRate($key, $limit, $period, $calls) + public function resetAll(): void + { + $this->rates = []; + } + + public function createMockRate($key, $limit, $period, $calls): RateLimitInfo { $this->rates[$key] = array('calls' => $calls, 'limit' => $limit, 'reset' => (time() + $period)); return $this->getRateInfo($key); } + + public function createStorageErrorMockRate($key, RateLimitStorageExceptionInterface $exception): void + { + $this->rates[$key] = $exception; + } } diff --git a/Tests/EventListener/RateLimitAnnotationListenerTest.php b/Tests/EventListener/RateLimitAnnotationListenerTest.php index c4585a6..233504f 100644 --- a/Tests/EventListener/RateLimitAnnotationListenerTest.php +++ b/Tests/EventListener/RateLimitAnnotationListenerTest.php @@ -5,34 +5,40 @@ use Noxlogic\RateLimitBundle\Attribute\RateLimit; use Noxlogic\RateLimitBundle\EventListener\RateLimitAnnotationListener; use Noxlogic\RateLimitBundle\Events\RateLimitEvents; +use Noxlogic\RateLimitBundle\Exception\Storage\CreateRateRateLimitStorageException; use Noxlogic\RateLimitBundle\Service\RateLimitService; use Noxlogic\RateLimitBundle\Tests\TestCase; +use PHPUnit\Framework\MockObject\MockObject; use ReflectionMethod; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Event\ControllerEvent; use Symfony\Component\HttpKernel\HttpKernelInterface; use Symfony\Contracts\EventDispatcher\Event; +use Noxlogic\RateLimitBundle\Util\PathLimitProcessor; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; +use Noxlogic\RateLimitBundle\Tests\Exception\TestException; class RateLimitAnnotationListenerTest extends TestCase { - /** - * @var MockStorage - */ - protected $mockStorage; + protected MockStorage $mockStorage; - /** @var \PHPUnit\Framework\MockObject\MockObject */ - protected $mockPathLimitProcessor; + protected MockObject $mockPathLimitProcessor; protected function setUp(): void { $this->mockStorage = new MockStorage(); - $this->mockPathLimitProcessor = $this->getMockBuilder('Noxlogic\RateLimitBundle\Util\PathLimitProcessor') + $this->mockPathLimitProcessor = $this->getMockBuilder(PathLimitProcessor::class) ->disableOriginalConstructor() ->getMock(); } - protected function getMockStorage() + protected function tearDown(): void + { + $this->mockStorage->resetAll(); + } + + protected function getMockStorage(): MockStorage { return $this->mockStorage; } @@ -40,7 +46,7 @@ protected function getMockStorage() public function testReturnedWhenNotEnabled(): void { - $listener = $this->createListener($this->never()); + $listener = $this->createListener(self::never()); $listener->setParameter('enabled', false); $event = $this->createEvent(); @@ -50,7 +56,7 @@ public function testReturnedWhenNotEnabled(): void public function testReturnedWhenNotAMasterRequest(): void { - $listener = $this->createListener($this->never()); + $listener = $this->createListener(self::never()); $event = $this->createEvent(HttpKernelInterface::SUB_REQUEST); $listener->onKernelController($event); @@ -59,9 +65,9 @@ public function testReturnedWhenNotAMasterRequest(): void public function testReturnedWhenNoControllerFound(): void { - $listener = $this->createListener($this->once()); + $listener = $this->createListener(self::once()); - $kernel = $this->getMockBuilder('Symfony\\Component\\HttpKernel\\HttpKernelInterface')->getMock(); + $kernel = $this->getMockBuilder(HttpKernelInterface::class)->getMock(); $request = new Request(); $event = new ControllerEvent($kernel, static function() {}, $request, HttpKernelInterface::MAIN_REQUEST); @@ -72,7 +78,7 @@ public function testReturnedWhenNoControllerFound(): void public function testReturnedWhenNoAttributesFound(): void { - $listener = $this->createListener($this->once()); + $listener = $this->createListener(self::once()); $event = $this->createEvent(); $listener->onKernelController($event); @@ -83,9 +89,9 @@ public function testDelegatesToPathLimitProcessorWhenNoAttributesFound(): void $request = new Request(); $event = $this->createEvent(HttpKernelInterface::MAIN_REQUEST, $request); - $listener = $this->createListener($this->once()); + $listener = $this->createListener(self::once()); - $this->mockPathLimitProcessor->expects($this->once()) + $this->mockPathLimitProcessor->expects(self::once()) ->method('getRateLimit') ->with($request); @@ -94,7 +100,7 @@ public function testDelegatesToPathLimitProcessorWhenNoAttributesFound(): void public function testDispatchIsCalled(): void { - $listener = $this->createListener($this->exactly(2)); + $listener = $this->createListener(self::exactly(2)); $event = $this->createEvent(); $event->getRequest()->attributes->set('_x-rate-limit', array( @@ -106,7 +112,7 @@ public function testDispatchIsCalled(): void public function testDispatchIsCalledWithAttributes(): void { - $listener = $this->createListener($this->exactly(2)); + $listener = $this->createListener(self::exactly(2)); $event = $this->createEvent( HttpKernelInterface::MAIN_REQUEST, @@ -121,7 +127,7 @@ public function testDispatchIsCalledIfThePathLimitProcessorReturnsARateLimit(): { $event = $this->createEvent(HttpKernelInterface::MAIN_REQUEST); - $listener = $this->createListener($this->exactly(2)); + $listener = $this->createListener(self::exactly(2)); $rateLimit = new RateLimit( [], 100, @@ -129,7 +135,6 @@ public function testDispatchIsCalledIfThePathLimitProcessorReturnsARateLimit(): ); $this->mockPathLimitProcessor - ->expects($this->any()) ->method('getRateLimit') ->willReturn($rateLimit); @@ -138,7 +143,7 @@ public function testDispatchIsCalledIfThePathLimitProcessorReturnsARateLimit(): public function testIsRateLimitSetInRequest(): void { - $listener = $this->createListener($this->any()); + $listener = $this->createListener(self::any()); $event = $this->createEvent(); $event->getRequest()->attributes->set('_x-rate-limit', array( @@ -146,20 +151,20 @@ public function testIsRateLimitSetInRequest(): void )); - $this->assertNull($event->getRequest()->attributes->get('rate_limit_info')); + self::assertNull($event->getRequest()->attributes->get('rate_limit_info')); // Create initial ratelimit in storage $listener->onKernelController($event); - $this->assertArrayHasKey('rate_limit_info', $event->getRequest()->attributes->all()); + self::assertArrayHasKey('rate_limit_info', $event->getRequest()->attributes->all()); // Add second ratelimit in storage $listener->onKernelController($event); - $this->assertArrayHasKey('rate_limit_info', $event->getRequest()->attributes->all()); + self::assertArrayHasKey('rate_limit_info', $event->getRequest()->attributes->all()); } public function testRateLimit(): void { - $listener = $this->createListener($this->any()); + $listener = $this->createListener(self::any()); $event = $this->createEvent(); $event->getRequest()->attributes->set('_x-rate-limit', array( @@ -186,23 +191,24 @@ public function testRateLimit(): void public function testRateLimitThrottling(): void { - $listener = $this->createListener($this->any()); + $listener = $this->createListener(self::any()); $event = $this->createEvent(); - $event->getRequest()->attributes->set('_x-rate-limit', array( + $event->getRequest()->attributes->set('_x-rate-limit', [ new RateLimit([], 5, 3), - )); + ]); // Throttled $storage = $this->getMockStorage(); - $storage->createMockRate('Noxlogic.RateLimitBundle.Tests.EventListener.MockController.mockAction', 5, 10, 6); + $storage->createMockRate(MockController::RATE_LIMIT_KEY, 5, 10, 6); $listener->onKernelController($event); + self::assertIsNotArray($event->getController()); } public function testRateLimitExpiring(): void { - $listener = $this->createListener($this->any()); + $listener = $this->createListener(self::any()); $event = $this->createEvent(); $event->getRequest()->attributes->set('_x-rate-limit', array( @@ -211,14 +217,14 @@ public function testRateLimitExpiring(): void // Expired $storage = $this->getMockStorage(); - $storage->createMockRate('Noxlogic.RateLimitBundle.Tests.EventListener.MockController.mockAction', 5, -10, 12); + $storage->createMockRate(MockController::RATE_LIMIT_KEY, 5, -10, 12); $listener->onKernelController($event); self::assertIsArray($event->getController()); } public function testBestMethodMatch(): void { - $listener = $this->createListener($this->any()); + $listener = $this->createListener(self::any()); $method = new ReflectionMethod(get_class($listener), 'findBestMethodMatch'); $method->setAccessible(true); @@ -232,30 +238,29 @@ public function testBestMethodMatch(): void // Find the method that matches the string $request->setMethod('GET'); - $this->assertEquals( + self::assertSame( $annotations[1], $method->invoke($listener, $request, $annotations) ); // Method not found, use the default one $request->setMethod('DELETE'); - $this->assertEquals( + self::assertSame( $annotations[0], $method->invoke($listener, $request, $annotations) ); // Find best match based in methods in array $request->setMethod('PUT'); - $this->assertEquals( + self::assertSame( $annotations[2], $method->invoke($listener, $request, $annotations) ); } - public function testFindNoAttributes(): void { - $listener = $this->createListener($this->any()); + $listener = $this->createListener(self::any()); $method = new ReflectionMethod(get_class($listener), 'findBestMethodMatch'); $method->setAccessible(true); @@ -264,16 +269,15 @@ public function testFindNoAttributes(): void $annotations = array(); $request->setMethod('PUT'); - $this->assertNull($method->invoke($listener, $request, $annotations)); + self::assertNull($method->invoke($listener, $request, $annotations)); $request->setMethod('GET'); - $this->assertNull($method->invoke($listener, $request, $annotations)); + self::assertNull($method->invoke($listener, $request, $annotations)); } - public function testFindBestMethodMatchNotMatchingAnnotations(): void { - $listener = $this->createListener($this->any()); + $listener = $this->createListener(self::any()); $method = new ReflectionMethod(get_class($listener), 'findBestMethodMatch'); $method->setAccessible(true); @@ -284,10 +288,10 @@ public function testFindBestMethodMatchNotMatchingAnnotations(): void ); $request->setMethod('PUT'); - $this->assertNull($method->invoke($listener, $request, $annotations)); + self::assertNull($method->invoke($listener, $request, $annotations)); $request->setMethod('GET'); - $this->assertEquals( + self::assertSame( $annotations[0], $method->invoke($listener, $request, $annotations) ); @@ -296,7 +300,7 @@ public function testFindBestMethodMatchNotMatchingAnnotations(): void public function testFindBestMethodMatchMatchingMultipleAnnotations(): void { - $listener = $this->createListener($this->any()); + $listener = $this->createListener(self::any()); $method = new ReflectionMethod(get_class($listener), 'findBestMethodMatch'); $method->setAccessible(true); @@ -308,34 +312,33 @@ public function testFindBestMethodMatchMatchingMultipleAnnotations(): void ); $request->setMethod('PUT'); - $this->assertEquals($annotations[1], $method->invoke($listener, $request, $annotations)); + self::assertSame($annotations[1], $method->invoke($listener, $request, $annotations)); $request->setMethod('GET'); - $this->assertEquals($annotations[1], $method->invoke($listener, $request, $annotations)); + self::assertSame($annotations[1], $method->invoke($listener, $request, $annotations)); } - protected function createEvent( + private function createEvent( int $requestType = HttpKernelInterface::MAIN_REQUEST, ?Request $request = null, ?MockController $controller = null, + string $controllerAction = 'mockAction' ): ControllerEvent { - $kernel = $this->getMockBuilder('Symfony\\Component\\HttpKernel\\HttpKernelInterface')->getMock(); + $kernel = $this->getMockBuilder(HttpKernelInterface::class)->getMock(); $controller = $controller ?? new MockController(); - $action = 'mockAction'; $request = $request ?? new Request(); - return new ControllerEvent($kernel, array($controller, $action), $request, $requestType); + return new ControllerEvent($kernel, [$controller, $controllerAction], $request, $requestType); } - - protected function createListener($expects): RateLimitAnnotationListener + private function createListener($eventDispatcherExpects): RateLimitAnnotationListener { - $mockDispatcher = $this->getMockBuilder('Symfony\\Contracts\\EventDispatcher\\EventDispatcherInterface')->getMock(); + $mockDispatcher = $this->getMockBuilder(EventDispatcherInterface::class)->getMock(); $mockDispatcher - ->expects($expects) + ->expects($eventDispatcherExpects) ->method('dispatch'); $rateLimitService = new RateLimitService(); @@ -357,19 +360,19 @@ public function testRateLimitKeyGenerationEventHasPayload(): void )); $generated = false; - $mockDispatcher = $this->getMockBuilder('Symfony\\Contracts\\EventDispatcher\\EventDispatcherInterface')->getMock(); + $mockDispatcher = $this->getMockBuilder(EventDispatcherInterface::class)->getMock(); $generatedCallback = function ($name, $event) use ($request, &$generated) { if ($name !== RateLimitEvents::GENERATE_KEY) { return; } $generated = true; - $this->assertSame(RateLimitEvents::GENERATE_KEY, $name); - $this->assertSame($request, $event->getRequest()); - $this->assertSame(['foo'], $event->getPayload()); - $this->assertSame('Noxlogic.RateLimitBundle.Tests.EventListener.MockController.mockAction', $event->getKey()); + self::assertSame(RateLimitEvents::GENERATE_KEY, $name); + self::assertSame($request, $event->getRequest()); + self::assertSame(['foo'], $event->getPayload()); + self::assertSame(MockController::RATE_LIMIT_KEY, $event->getKey()); }; $mockDispatcher - ->expects($this->any()) + ->expects(self::any()) ->method('dispatch') ->willReturnCallback(function ($arg1, $arg2) use ($generatedCallback) { if ($arg1 instanceof Event) { @@ -384,19 +387,19 @@ public function testRateLimitKeyGenerationEventHasPayload(): void $storage = $this->getMockStorage(); $storage->createMockRate('test-key', 5, 10, 1); - $rateLimitService = $this->getMockBuilder('Noxlogic\RateLimitBundle\Service\RateLimitService') + $rateLimitService = $this->getMockBuilder(RateLimitService::class) ->getMock(); $listener = new RateLimitAnnotationListener($mockDispatcher, $rateLimitService, $this->mockPathLimitProcessor); $listener->onKernelController($event); - $this->assertTrue($generated, 'Generate key event not dispatched'); + self::assertTrue($generated, 'Generate key event not dispatched'); } public function testRateLimitThrottlingWithExceptionAndPayload(): void { - $listener = $this->createListener($this->any()); - $listener->setParameter('rate_response_exception', 'Noxlogic\RateLimitBundle\Tests\Exception\TestException'); + $listener = $this->createListener(self::any()); + $listener->setParameter('rate_response_exception', TestException::class); $listener->setParameter('rate_response_code', 123); $listener->setParameter('rate_response_message', 'a message'); @@ -407,27 +410,118 @@ public function testRateLimitThrottlingWithExceptionAndPayload(): void // Throttled $storage = $this->getMockStorage(); - $storage->createMockRate('Noxlogic.RateLimitBundle.Tests.EventListener.MockController.mockAction', 5, 10, 6); + $storage->createMockRate(MockController::RATE_LIMIT_KEY, 5, 10, 6); try { $listener->onKernelController($event); - $this->assertFalse(true, 'Exception not being thrown'); + self::fail('Exception not being thrown'); } catch (\Exception $e) { - $this->assertInstanceOf('Noxlogic\RateLimitBundle\Tests\Exception\TestException', $e); - $this->assertSame(123, $e->getCode()); - $this->assertSame('a message', $e->getMessage()); - $this->assertSame(['foo'], $e->payload); + self::assertInstanceOf(TestException::class, $e); + self::assertSame(123, $e->getCode()); + self::assertSame('a message', $e->getMessage()); + self::assertSame(['foo'], $e->payload); } } + public function testRateLimitThrottling_failOpenFalse_exceptionShouldHappenOnStorageError(): void + { + $listener = $this->createListener(self::any()); + $listener->setParameter('rate_response_exception', TestException::class); + $listener->setParameter('fail_open', false); + + $event = $this->createEvent(); + $event->getRequest()->attributes->set('_x-rate-limit', [ + new RateLimit([], 5, 3), + ]); + + // Throttled + $this->getMockStorage()->createStorageErrorMockRate( + MockController::RATE_LIMIT_KEY, + new CreateRateRateLimitStorageException(new \Exception('A storage error happened')) + ); + + $this->expectException(CreateRateRateLimitStorageException::class); + + $listener->onKernelController($event); + } + + public function testRateLimitThrottling_failOpenTrueViaConfig_noExceptionShouldHappenOnStorageError(): void + { + $listener = $this->createListener(self::any()); + $listener->setParameter('rate_response_exception', TestException::class); + $listener->setParameter('fail_open', true); + + $event = $this->createEvent(); + $event->getRequest()->attributes->set('_x-rate-limit', [ + new RateLimit([], 5, 3), + ]); + + // Throttled + $this->getMockStorage()->createStorageErrorMockRate( + MockController::RATE_LIMIT_KEY, + new CreateRateRateLimitStorageException(new \Exception('A storage error happened')) + ); + + $listener->onKernelController($event); + + $this->expectNotToPerformAssertions(); + } + + public function testRateLimitThrottling_failOpenTrueViaAttribute_noExceptionShouldHappenOnStorageError(): void + { + $listener = $this->createListener(self::any()); + $listener->setParameter('rate_response_exception', TestException::class); + // Fail open is configured to be disabled globally, but the attribute overrides it + $listener->setParameter('fail_open', false); + + $event = $this->createEvent( + controller: new MockControllerWithAttributes(), + controllerAction: 'failOpenMockAction' + ); + + // Throttled + $this->getMockStorage()->createStorageErrorMockRate( + MockController::RATE_LIMIT_KEY, + new CreateRateRateLimitStorageException(new \Exception('A storage error happened')) + ); + + $listener->onKernelController($event); + + $this->expectNotToPerformAssertions(); + } + + public function testRateLimitThrottling_failOpenFalseViaAttribute_noExceptionShouldHappenOnStorageError(): void + { + $listener = $this->createListener(self::any()); + $listener->setParameter('rate_response_exception', TestException::class); + // Fail open is configured to be enabled globally, but the attribute overrides it + $listener->setParameter('fail_open', true); + + $event = $this->createEvent( + controller: new MockControllerWithAttributes(), + controllerAction: 'doNotFailOpenMockAction' + ); + + // Throttled + $this->getMockStorage()->createStorageErrorMockRate( + MockController::RATE_LIMIT_KEY, + new CreateRateRateLimitStorageException(new \Exception('A storage error happened')) + ); + + $listener->onKernelController($event); + + $this->expectNotToPerformAssertions(); + } + public function testRateLimitThrottlingWithException(): void { $this->expectException(\BadFunctionCallException::class); $this->expectExceptionCode(123); $this->expectExceptionMessage('a message'); - $listener = $this->createListener($this->any()); - $listener->setParameter('rate_response_exception', '\BadFunctionCallException'); + + $listener = $this->createListener(self::any()); + $listener->setParameter('rate_response_exception', \BadFunctionCallException::class); $listener->setParameter('rate_response_code', 123); $listener->setParameter('rate_response_message', 'a message'); @@ -438,13 +532,13 @@ public function testRateLimitThrottlingWithException(): void // Throttled $storage = $this->getMockStorage(); - $storage->createMockRate('Noxlogic.RateLimitBundle.Tests.EventListener.MockController.mockAction', 5, 10, 6); + $storage->createMockRate(MockController::RATE_LIMIT_KEY, 5, 10, 6); $listener->onKernelController($event); } public function testRateLimitThrottlingWithMessages(): void { - $listener = $this->createListener($this->any()); + $listener = $this->createListener(self::any()); $listener->setParameter('rate_response_code', 123); $listener->setParameter('rate_response_message', 'a message'); @@ -455,7 +549,7 @@ public function testRateLimitThrottlingWithMessages(): void // Throttled $storage = $this->getMockStorage(); - $storage->createMockRate('Noxlogic.RateLimitBundle.Tests.EventListener.MockController.mockAction', 5, 10, 6); + $storage->createMockRate(MockController::RATE_LIMIT_KEY, 5, 10, 6); /** @var Response $response */ $listener->onKernelController($event); @@ -464,7 +558,7 @@ public function testRateLimitThrottlingWithMessages(): void $a = $event->getController(); $response = $a(); - $this->assertEquals($response->getStatusCode(), 123); - $this->assertEquals($response->getContent(), "a message"); + self::assertSame(123, $response->getStatusCode()); + self::assertSame("a message", $response->getContent()); } } diff --git a/Tests/Service/Storage/DoctrineCacheTest.php b/Tests/Service/Storage/DoctrineCacheTest.php index d326c91..f0f2199 100644 --- a/Tests/Service/Storage/DoctrineCacheTest.php +++ b/Tests/Service/Storage/DoctrineCacheTest.php @@ -1,10 +1,15 @@ assertEquals(1234, $rli->getResetTimestamp()); } - public function testCreateRate() + public function testGetRateInfo_exception() { - $client = $this->getMockBuilder('Doctrine\\Common\\Cache\\Cache') + $client = $this->getMockBuilder(Cache::class) + ->getMock(); + $client->expects($this->once()) + ->method('fetch') + ->with('foo') + ->willThrowException(new \Exception('Storage error')); + + $storage = new DoctrineCache($client); + + $this->expectException(GetRateInfoRateLimitStorageException::class); + $this->expectExceptionMessage('Rate limit storage: Failed to get rate limit info: Storage error'); + + $storage->getRateInfo('foo'); + } + + public function testCreateRate(): void + { + $client = $this->getMockBuilder(Cache::class) ->getMock(); $client->expects($this->once()) ->method('save'); @@ -37,9 +59,25 @@ public function testCreateRate() } - public function testLimitRateNoKey() + public function testCreateRate_exception() { - $client = $this->getMockBuilder('Doctrine\\Common\\Cache\\Cache') + $client = $this->getMockBuilder(Cache::class) + ->getMock(); + $client->expects($this->once()) + ->method('save') + ->willThrowException(new \Exception('Storage error')); + + $storage = new DoctrineCache($client); + + $this->expectException(CreateRateRateLimitStorageException::class); + $this->expectExceptionMessage('Rate limit storage: Failed to create rate limit: Storage error'); + + $storage->createRate('foo', 100, 123); + } + + public function testLimitRateNoKey(): void + { + $client = $this->getMockBuilder(Cache::class) ->getMock(); $client->expects($this->once()) ->method('fetch') @@ -72,9 +110,26 @@ public function testLimitRateWithKey() - public function testResetRate() + public function testLimitRate_exception() { - $client = $this->getMockBuilder('Doctrine\\Common\\Cache\\Cache') + $client = $this->getMockBuilder(Cache::class) + ->getMock(); + $client->expects($this->once()) + ->method('fetch') + ->with('foo') + ->willThrowException(new \Exception('Storage error')); + + $storage = new DoctrineCache($client); + + $this->expectException(LimitRateRateLimitStorageException::class); + $this->expectExceptionMessage('Rate limit storage: Failed to apply rate limit: Storage error'); + + $storage->limitRate('foo'); + } + + public function testResetRate(): void + { + $client = $this->getMockBuilder(Cache::class) ->getMock(); $client->expects($this->once()) ->method('delete') @@ -83,4 +138,21 @@ public function testResetRate() $storage = new DoctrineCache($client); $this->assertTrue($storage->resetRate('foo')); } + + public function testResetRate_exception(): void + { + $client = $this->getMockBuilder(Cache::class) + ->getMock(); + $client->expects($this->once()) + ->method('delete') + ->with('foo') + ->willThrowException(new \Exception('Storage error')); + + $storage = new DoctrineCache($client); + + $this->expectException(ResetRateRateLimitStorageException::class); + $this->expectExceptionMessage('Rate limit storage: Failed to reset rate: Storage error'); + + $storage->resetRate('foo'); + } } diff --git a/Tests/Service/Storage/PsrCacheTest.php b/Tests/Service/Storage/PsrCacheTest.php index b97d33b..bc2120d 100644 --- a/Tests/Service/Storage/PsrCacheTest.php +++ b/Tests/Service/Storage/PsrCacheTest.php @@ -1,15 +1,25 @@ getMockBuilder('Psr\\Cache\\CacheItemInterface') + $item = $this->getMockBuilder(CacheItemInterface::class) ->getMock(); $item->expects($this->once()) ->method('isHit') @@ -18,31 +28,50 @@ public function testGetRateInfo() ->method('get') ->willReturn(array('limit' => 100, 'calls' => 50, 'reset' => 1234)); - $client = $this->getMockBuilder('Psr\\Cache\\CacheItemPoolInterface') + $client = $this->getMockBuilder(CacheItemPoolInterface::class) ->getMock(); $client->expects($this->once()) ->method('getItem') ->with('foo') - ->will($this->returnValue($item)); + ->willReturn($item); $storage = new PsrCache($client); + $rli = $storage->getRateInfo('foo'); - $this->assertInstanceOf('Noxlogic\\RateLimitBundle\\Service\\RateLimitInfo', $rli); + + $this->assertInstanceOf(RateLimitInfo::class, $rli); $this->assertEquals(100, $rli->getLimit()); $this->assertEquals(50, $rli->getCalls()); $this->assertEquals(1234, $rli->getResetTimestamp()); } - public function testCreateRate() + public function testGetRateInfo_exception(): void + { + $client = $this->getMockBuilder(CacheItemPoolInterface::class) + ->getMock(); + $client->expects($this->once()) + ->method('getItem') + ->with('foo') + ->willThrowException(new \Exception('Storage error')); + + $storage = new PsrCache($client); + + $this->expectException(GetRateInfoRateLimitStorageException::class); + $this->expectExceptionMessage('Rate limit storage: Failed to get rate limit info: Storage error'); + + $storage->getRateInfo('foo'); + } + + public function testCreateRate(): void { - $item = $this->getMockBuilder('Psr\\Cache\\CacheItemInterface') + $item = $this->getMockBuilder(CacheItemInterface::class) ->getMock(); /** * psr/cache 3.0 changed the return type of set() and expiresAfter() to return self. * @TODO NEXT_MAJOR: Remove this check and the first conditional block when psr/cache <3 support is dropped. */ - $psrCacheVersion = \Composer\InstalledVersions::getVersion('psr/cache'); + $psrCacheVersion = InstalledVersions::getVersion('psr/cache'); if (version_compare($psrCacheVersion, '3.0', '<')) { $item->expects($this->once()) ->method('set') @@ -59,12 +88,12 @@ public function testCreateRate() ->willReturnSelf(); } - $client = $this->getMockBuilder('Psr\\Cache\\CacheItemPoolInterface') + $client = $this->getMockBuilder(CacheItemPoolInterface::class) ->getMock(); $client->expects($this->once()) ->method('getItem') ->with('foo') - ->will($this->returnValue($item)); + ->willReturn($item); $client->expects($this->once()) ->method('save') ->with($item) @@ -74,29 +103,62 @@ public function testCreateRate() $storage->createRate('foo', 100, 123); } + public function testCreateRate_exception(): void + { + $client = $this->getMockBuilder(CacheItemPoolInterface::class) + ->getMock(); + $client->expects($this->once()) + ->method('getItem') + ->with('foo') + ->willThrowException(new \Exception('Storage error')); + + $storage = new PsrCache($client); + + $this->expectException(CreateRateRateLimitStorageException::class); + $this->expectExceptionMessage('Rate limit storage: Failed to create rate limit: Storage error'); + + $storage->createRate('foo', 100, 123); + } - public function testLimitRateNoKey() + public function testLimitRateNoKey(): void { - $item = $this->getMockBuilder('Psr\\Cache\\CacheItemInterface') + $item = $this->getMockBuilder(CacheItemInterface::class) ->getMock(); $item->expects($this->once()) ->method('isHit') ->willReturn(false); - $client = $this->getMockBuilder('Psr\\Cache\\CacheItemPoolInterface') + $client = $this->getMockBuilder(CacheItemPoolInterface::class) ->getMock(); $client->expects($this->once()) ->method('getItem') ->with('foo') - ->will($this->returnValue($item)); + ->willReturn($item); $storage = new PsrCache($client); $this->assertFalse($storage->limitRate('foo')); } - public function testLimitRateWithKey() + public function testLimitRate_exception(): void { - $item = $this->getMockBuilder('Psr\\Cache\\CacheItemInterface') + $client = $this->getMockBuilder(CacheItemPoolInterface::class) + ->getMock(); + $client->expects($this->once()) + ->method('getItem') + ->with('foo') + ->willThrowException(new \Exception('Storage error')); + + $storage = new PsrCache($client); + + $this->expectException(LimitRateRateLimitStorageException::class); + $this->expectExceptionMessage('Rate limit storage: Failed to apply rate limit: Storage error'); + + $storage->limitRate('foo'); + } + + public function testLimitRateWithKey(): void + { + $item = $this->getMockBuilder(CacheItemInterface::class) ->getMock(); $item->expects($this->once()) ->method('isHit') @@ -109,12 +171,12 @@ public function testLimitRateWithKey() $item->expects($this->once()) ->method('expiresAfter'); - $client = $this->getMockBuilder('Psr\\Cache\\CacheItemPoolInterface') + $client = $this->getMockBuilder(CacheItemPoolInterface::class) ->getMock(); $client->expects($this->once()) ->method('getItem') ->with('foo') - ->will($this->returnValue($item)); + ->willReturn($item); $client->expects($this->once()) ->method('save') ->with($item) @@ -124,9 +186,9 @@ public function testLimitRateWithKey() $storage->limitRate('foo'); } - public function testResetRate() + public function testResetRate(): void { - $client = $this->getMockBuilder('Psr\\Cache\\CacheItemPoolInterface') + $client = $this->getMockBuilder(CacheItemPoolInterface::class) ->getMock(); $client->expects($this->once()) ->method('deleteItem') @@ -136,4 +198,21 @@ public function testResetRate() $storage = new PsrCache($client); $this->assertTrue($storage->resetRate('foo')); } + + public function testResetRate_exception(): void + { + $client = $this->getMockBuilder(CacheItemPoolInterface::class) + ->getMock(); + $client->expects($this->once()) + ->method('deleteItem') + ->with('foo') + ->willThrowException(new \Exception('Storage error')); + + $storage = new PsrCache($client); + + $this->expectException(ResetRateRateLimitStorageException::class); + $this->expectExceptionMessage('Rate limit storage: Failed to reset rate: Storage error'); + + $storage->resetRate('foo'); + } } diff --git a/Tests/Service/Storage/RedisTest.php b/Tests/Service/Storage/RedisTest.php index acbdb9a..1fd7fb0 100644 --- a/Tests/Service/Storage/RedisTest.php +++ b/Tests/Service/Storage/RedisTest.php @@ -2,8 +2,13 @@ namespace Noxlogic\RateLimitBundle\Tests\Service\Storage; +use Noxlogic\RateLimitBundle\Exception\Storage\CreateRateRateLimitStorageException; +use Noxlogic\RateLimitBundle\Exception\Storage\GetRateInfoRateLimitStorageException; +use Noxlogic\RateLimitBundle\Exception\Storage\LimitRateRateLimitStorageException; +use Noxlogic\RateLimitBundle\Exception\Storage\ResetRateRateLimitStorageException; use Noxlogic\RateLimitBundle\Service\Storage\Redis; use Noxlogic\RateLimitBundle\Tests\TestCase; +use Predis\Client; class RedisTest extends TestCase { @@ -25,10 +30,28 @@ public function testgetRateInfo() $this->assertEquals(1234, $rli->getResetTimestamp()); } - public function testcreateRate() + public function testGetRateInfo_exception() { - $client = $this->getMockBuilder('Predis\\Client') - ->setMethods(array('hset', 'expire', 'hgetall')) + $client = $this->getMockBuilder(Client::class) + ->addMethods(['hgetall']) + ->getMock(); + $client->expects($this->once()) + ->method('hgetall') + ->with('foo') + ->willThrowException(new \Exception('Storage error')); + + $storage = new Redis($client); + + $this->expectException(GetRateInfoRateLimitStorageException::class); + $this->expectExceptionMessage('Rate limit storage: Failed to get rate limit info: Storage error'); + + $storage->getRateInfo('foo'); + } + + public function testCreateRate(): void + { + $client = $this->getMockBuilder(Client::class) + ->addMethods(array('hset', 'expire', 'hgetall')) ->getMock(); $client->expects($this->once()) ->method('expire') @@ -46,10 +69,27 @@ public function testcreateRate() } - public function testLimitRateNoKey() + public function testLimitRateNoKey_exception() { - $client = $this->getMockBuilder('Predis\\Client') - ->setMethods(array('hgetall')) + $client = $this->getMockBuilder(Client::class) + ->addMethods(array('hset', 'expire', 'hgetall')) + ->getMock(); + $client->expects($this->once()) + ->method('hset') + ->willThrowException(new \Exception('Storage error')); + + $storage = new Redis($client); + + $this->expectException(CreateRateRateLimitStorageException::class); + $this->expectExceptionMessage('Rate limit storage: Failed to create rate limit: Storage error'); + + $storage->createRate('foo', 100, 123); + } + + public function testLimitRateNoKey(): void + { + $client = $this->getMockBuilder(Client::class) + ->addMethods(['hgetall']) ->getMock(); $client->expects($this->once()) ->method('hgetall') @@ -60,23 +100,41 @@ public function testLimitRateNoKey() $this->assertFalse($storage->limitRate('foo')); } - public function testLimitRateWithKey() + public function testLimitRate_exception(): void { - $client = $this->getMockBuilder('Predis\\Client') - ->setMethods(array('hexists', 'hincrby', 'hgetall')) + $client = $this->getMockBuilder(Client::class) + ->addMethods(['hgetall']) ->getMock(); $client->expects($this->once()) ->method('hgetall') ->with('foo') - ->will($this->returnValue([ + ->willThrowException(new \Exception('Storage error')); + + $storage = new Redis($client); + + $this->expectException(LimitRateRateLimitStorageException::class); + $this->expectExceptionMessage('Rate limit storage: Failed to apply rate limit: Storage error'); + + $this->assertFalse($storage->limitRate('foo')); + } + + public function testLimitRateWithKey(): void + { + $client = $this->getMockBuilder(Client::class) + ->addMethods(array('hexists', 'hincrby', 'hgetall')) + ->getMock(); + $client->expects($this->once()) + ->method('hgetall') + ->with('foo') + ->willReturn([ 'limit' => 1, 'calls' => 1, 'reset' => 1, - ])); + ]); $client->expects($this->once()) ->method('hincrby') ->with('foo', 'calls', 1) - ->will($this->returnValue(2)); + ->willReturn(2); $storage = new Redis($client); $storage->limitRate('foo'); @@ -97,10 +155,28 @@ public function testresetRate() $this->assertTrue($storage->resetRate('foo')); } - public function testSanitizeKey() + public function testResetRate_exception(): void { - $client = $this->getMockBuilder('Predis\\Client') - ->setMethods(array('del')) + $client = $this->getMockBuilder(Client::class) + ->addMethods(['del']) + ->getMock(); + $client->expects($this->once()) + ->method('del') + ->with('foo') + ->willThrowException(new \Exception('Storage error')); + + $storage = new Redis($client); + + $this->expectException(ResetRateRateLimitStorageException::class); + $this->expectExceptionMessage('Rate limit storage: Failed to reset rate: Storage error'); + + $storage->resetRate('foo'); + } + + public function testSanitizeKey(): void + { + $client = $this->getMockBuilder(Client::class) + ->addMethods(array('del')) ->getMock(); $client->expects($this->once()) ->method('del') diff --git a/Tests/Service/Storage/SimpleCacheTest.php b/Tests/Service/Storage/SimpleCacheTest.php index 1e2ae9d..046ae53 100644 --- a/Tests/Service/Storage/SimpleCacheTest.php +++ b/Tests/Service/Storage/SimpleCacheTest.php @@ -2,8 +2,13 @@ namespace Noxlogic\RateLimitBundle\Tests\Service\Storage; +use Noxlogic\RateLimitBundle\Exception\Storage\CreateRateRateLimitStorageException; +use Noxlogic\RateLimitBundle\Exception\Storage\GetRateInfoRateLimitStorageException; +use Noxlogic\RateLimitBundle\Exception\Storage\LimitRateRateLimitStorageException; +use Noxlogic\RateLimitBundle\Exception\Storage\ResetRateRateLimitStorageException; use Noxlogic\RateLimitBundle\Service\Storage\SimpleCache; use Noxlogic\RateLimitBundle\Tests\TestCase; +use Psr\SimpleCache\CacheInterface; class SimpleCacheTest extends TestCase { @@ -24,9 +29,26 @@ public function testGetRateInfo() $this->assertEquals(1234, $rli->getResetTimestamp()); } - public function testCreateRate() + public function testGetRateInfo_exception(): void { - $client = $this->getMockBuilder('Psr\\SimpleCache\\CacheInterface') + $client = $this->getMockBuilder(CacheInterface::class) + ->getMock(); + $client->expects($this->once()) + ->method('get') + ->with('foo') + ->willThrowException(new \Exception('Storage error')); + + $storage = new SimpleCache($client); + + $this->expectException(GetRateInfoRateLimitStorageException::class); + $this->expectExceptionMessage('Rate limit storage: Failed to get rate limit info: Storage error'); + + $storage->getRateInfo('foo'); + } + + public function testCreateRate(): void + { + $client = $this->getMockBuilder(CacheInterface::class) ->getMock(); $client->expects($this->once()) ->method('set'); @@ -35,10 +57,25 @@ public function testCreateRate() $storage->createRate('foo', 100, 123); } + public function testCreateRate_exception(): void + { + $client = $this->getMockBuilder(CacheInterface::class) + ->getMock(); + $client->expects($this->once()) + ->method('set') + ->willThrowException(new \Exception('Storage error'));; + + $storage = new SimpleCache($client); + + $this->expectException(CreateRateRateLimitStorageException::class); + $this->expectExceptionMessage('Rate limit storage: Failed to create rate limit: Storage error'); - public function testLimitRateNoKey() + $storage->createRate('foo', 100, 123); + } + + public function testLimitRateNoKey(): void { - $client = $this->getMockBuilder('Psr\\SimpleCache\\CacheInterface') + $client = $this->getMockBuilder(CacheInterface::class) ->getMock(); $client->expects($this->once()) ->method('get') @@ -49,9 +86,26 @@ public function testLimitRateNoKey() $this->assertFalse($storage->limitRate('foo')); } - public function testLimitRateWithKey() + public function testLimitRate_exception(): void { - $client = $this->getMockBuilder('Psr\\SimpleCache\\CacheInterface') + $client = $this->getMockBuilder(CacheInterface::class) + ->getMock(); + $client->expects($this->once()) + ->method('get') + ->with('foo') + ->willThrowException(new \Exception('Storage error')); + + $storage = new SimpleCache($client); + + $this->expectException(LimitRateRateLimitStorageException::class); + $this->expectExceptionMessage('Rate limit storage: Failed to apply rate limit: Storage error'); + + $storage->limitRate('foo'); + } + + public function testLimitRateWithKey(): void + { + $client = $this->getMockBuilder(CacheInterface::class) ->getMock(); $info['limit'] = 100; @@ -80,4 +134,21 @@ public function testResetRate() $storage = new SimpleCache($client); $this->assertTrue($storage->resetRate('foo')); } -} + + public function testResetRate_exception(): void + { + $client = $this->getMockBuilder(CacheInterface::class) + ->getMock(); + $client->expects($this->once()) + ->method('delete') + ->with('foo') + ->willThrowException(new \Exception('Storage error')); + + $storage = new SimpleCache($client); + + $this->expectException(ResetRateRateLimitStorageException::class); + $this->expectExceptionMessage('Rate limit storage: Failed to reset rate: Storage error'); + + $storage->resetRate('foo'); + } +} diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 34a01ef..02d0e2c 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -342,12 +342,6 @@ parameters: count: 1 path: Service/Storage/SimpleCache.php - - - message: '#^Method Noxlogic\\RateLimitBundle\\Service\\Storage\\SimpleCache\:\:createRateInfo\(\) has no return type specified\.$#' - identifier: missingType.return - count: 1 - path: Service/Storage/SimpleCache.php - - message: '#^Method Noxlogic\\RateLimitBundle\\Service\\Storage\\SimpleCache\:\:createRateInfo\(\) has parameter \$info with no value type specified in iterable type array\.$#' identifier: missingType.iterableValue diff --git a/phpstan.dist.neon b/phpstan.dist.neon index 8704079..5d2d7d4 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -1,5 +1,7 @@ parameters: level: 6 + treatPhpDocTypesAsCertain: false + reportUnmatchedIgnoredErrors: false paths: - Attribute/ - DependencyInjection/