From 6338a8749290d60eb25edf90367c1e8c899d6d8c Mon Sep 17 00:00:00 2001 From: Arif Hoque Date: Sat, 3 Oct 2026 11:50:45 +0600 Subject: [PATCH] Cache: rate limiting fixes, Redis/prefix safety, Cache::store(), Cache::tags() --- src/Phaseolies/Cache/CacheStore.php | 321 +++++++++-- src/Phaseolies/Cache/RateLimiter.php | 4 - src/Phaseolies/Cache/TaggedCache.php | 193 +++++++ .../Console/Commands/ClearCacheCommand.php | 12 +- src/Phaseolies/Launchers/CacheLauncher.php | 91 +++- .../Middleware/ThrottleRequests.php | 35 +- src/Phaseolies/Support/Facades/Cache.php | 4 + tests/Cache/CacheLauncherTest.php | 313 +++++++++++ tests/Cache/CacheStoreFeaturesTest.php | 505 ++++++++++++++++++ tests/Cache/ThrottleRequestsTest.php | 180 +++++++ tests/Requests/RateLimiterTest.php | 14 +- 11 files changed, 1589 insertions(+), 83 deletions(-) create mode 100644 src/Phaseolies/Cache/TaggedCache.php create mode 100644 tests/Cache/CacheLauncherTest.php create mode 100644 tests/Cache/CacheStoreFeaturesTest.php create mode 100644 tests/Cache/ThrottleRequestsTest.php diff --git a/src/Phaseolies/Cache/CacheStore.php b/src/Phaseolies/Cache/CacheStore.php index 3e3d0e8a..be620428 100644 --- a/src/Phaseolies/Cache/CacheStore.php +++ b/src/Phaseolies/Cache/CacheStore.php @@ -2,10 +2,14 @@ namespace Phaseolies\Cache; -use DateTime; use Symfony\Component\Cache\Adapter\AdapterInterface; +use Symfony\Component\Cache\Adapter\TagAwareAdapter; +use Symfony\Component\Cache\CacheItem; use Phaseolies\Cache\Lock\AtomicLock; +use Psr\Cache\CacheItemInterface; use Symfony\Contracts\Cache\CacheInterface as ContractsCacheInterface; +use Symfony\Contracts\Cache\ItemInterface; +use Symfony\Contracts\Cache\NamespacedPoolInterface; class CacheStore implements IncrementableCacheInterface { @@ -23,17 +27,47 @@ class CacheStore implements IncrementableCacheInterface */ protected string $prefix; + /** + * Seconds an item lives when no ttl is given + * + * @var int|null + */ + protected ?int $defaultTtl; + + /** + * Resolves another configured store by name, see store(). + * + * @var \Closure|null + */ + protected ?\Closure $storeResolver = null; + + /** + * The name this store was configured under, if known. + * + * @var string|null + */ + protected ?string $name = null; + + /** + * Pool that holds tagged items, created on first use. + * + * @var TagAwareAdapter|null + */ + protected ?TagAwareAdapter $taggedPool = null; + /** * Create a new cache store instance. * * @param AdapterInterface $adapter * @param string|null $prefix + * @param int|null $defaultTtl * @return void */ - public function __construct(AdapterInterface $adapter, ?string $prefix = null) + public function __construct(AdapterInterface $adapter, ?string $prefix = null, ?int $defaultTtl = null) { $this->adapter = $adapter; $this->prefix = (string) ($prefix ?? config('caching.prefix')); + $this->defaultTtl = $defaultTtl; } /** @@ -92,8 +126,10 @@ public function set($key, $value, $ttl = null): bool $item = $this->adapter->getItem($key); $item->set($value); - if ($ttl !== null) { - $item->expiresAfter($this->convertTtlToSeconds($ttl)); + $seconds = $this->ttlFor($ttl); + + if ($seconds !== null) { + $item->expiresAfter($seconds); } return $this->adapter->save($item); @@ -121,7 +157,14 @@ public function delete($key): bool #[\Override] public function clear(): bool { - return $this->adapter->clear($this->prefix); + $cleared = $this->adapter->clear($this->prefix); + + // Tagged items live in their own namespace and are not reached by the call above. + if ($pool = $this->taggedPool()) { + $cleared = $pool->clear() && $cleared; + } + + return $cleared; } /** @@ -169,7 +212,7 @@ public function setMultiple($values, $ttl = null): bool $values = $this->normalizeValueMap($values); $success = true; - $ttl = $this->convertTtlToSeconds($ttl); + $ttl = $this->ttlFor($ttl); foreach ($values as $key => $value) { $prefixedKey = $this->prefixedValidatedKey($key); @@ -237,13 +280,9 @@ public function increment($key, $value = 1): int|bool return false; } - $current = (int) $item->get(); - $new = $current + $value; + $new = (int) $item->get() + $value; $item->set($new); - - if ($item->getMetadata()['expiry'] ?? null) { - $item->expiresAt(DateTime::createFromFormat('U', (string) $item->getMetadata()['expiry'])); - } + $this->keepExpiry($item); return $this->adapter->save($item) ? $new : false; }); @@ -267,13 +306,9 @@ public function decrement($key, $value = 1): int|bool return false; } - $current = (int) $item->get(); - $new = $current - $value; + $new = (int) $item->get() - $value; $item->set($new); - - if ($item->getMetadata()['expiry'] ?? null) { - $item->expiresAt(DateTime::createFromFormat('U', (string) $item->getMetadata()['expiry'])); - } + $this->keepExpiry($item); return $this->adapter->save($item) ? $new : false; }); @@ -290,7 +325,7 @@ public function decrement($key, $value = 1): int|bool public function add($key, $value, $ttl = null): bool { $key = $this->prefixedValidatedKey($key); - $seconds = $this->convertTtlToSeconds($ttl); + $seconds = $this->ttlFor($ttl); if ($this->adapter instanceof ContractsCacheInterface) { $created = false; @@ -404,6 +439,43 @@ protected function validateKeys($keys): array return $validated; } + /** + * The ttl an item is saved with: the given one, or the store default. + * + * @param null|int|\DateInterval $ttl + * @return int|null + */ + protected function ttlFor($ttl): ?int + { + return $this->convertTtlToSeconds($ttl) ?? $this->defaultTtl; + } + + /** + * The ttl an item is saved with, in seconds: the given one, or the store default. + * + * @param null|int|\DateInterval $ttl + * @return int|null + */ + public function ttlSeconds($ttl): ?int + { + return $this->ttlFor($ttl); + } + + /** + * Save an item with the expiry it already had. + * + * @param CacheItemInterface $item + * @return void + */ + protected function keepExpiry(CacheItemInterface $item): void + { + $expiry = $item instanceof CacheItem ? ($item->getMetadata()[ItemInterface::METADATA_EXPIRY] ?? null) : null; + + if ($expiry !== null) { + $item->expiresAt((new \DateTimeImmutable())->setTimestamp((int) ceil($expiry))); + } + } + /** * Convert TTL to seconds. * @@ -550,21 +622,13 @@ public function getAdapter(): AdapterInterface * Get an item from the cache, or execute the callback and store the result. * * @param string $key - * @param int|DateInterval $ttl + * @param int|\DateInterval|null $ttl * @param \Closure $callback * @return mixed */ public function stash(string $key, $ttl, \Closure $callback): mixed { - if ($this->has($key)) { - return $this->get($key); - } - - $value = $callback(); - - $this->set($key, $value, $ttl); - - return $value; + return $this->remember($key, $this->ttlFor($ttl), $callback); } /** @@ -576,15 +640,7 @@ public function stash(string $key, $ttl, \Closure $callback): mixed */ public function stashForever(string $key, \Closure $callback): mixed { - if ($this->has($key)) { - return $this->get($key); - } - - $value = $callback(); - - $this->forever($key, $value); - - return $value; + return $this->remember($key, null, $callback); } /** @@ -607,6 +663,195 @@ public function stashWhen(string $key, \Closure $callback, bool $condition, $ttl : $this->stash($key, $ttl, $callback); } + /** + * @param string $key + * @param int|null $seconds + * @param \Closure $callback + * @return mixed + */ + protected function remember(string $key, ?int $seconds, \Closure $callback): mixed + { + $key = $this->prefixedValidatedKey($key); + + if ($this->adapter instanceof ContractsCacheInterface) { + return $this->adapter->get($key, function (ItemInterface $item) use ($seconds, $callback) { + $item->expiresAfter($seconds); + + return $callback(); + }); + } + + $item = $this->adapter->getItem($key); + + if ($item->isHit()) { + return $item->get(); + } + + $value = $callback(); + $item->set($value); + + if ($seconds !== null) { + $item->expiresAfter($seconds); + } + + $this->adapter->save($item); + + return $value; + } + + /** + * Get an item and remove it in one step. + * + * @param mixed $key + * @param mixed $default + * @return mixed + */ + public function pull($key, $default = null): mixed + { + $key = $this->prefixedValidatedKey($key); + $item = $this->adapter->getItem($key); + + if (!$item->isHit()) { + return $default; + } + + $value = $item->get(); + $this->adapter->deleteItem($key); + + return $value; + } + + /** + * Determine whether an item is absent from the cache. + * + * @param mixed $key + * @return bool + */ + public function missing($key): bool + { + return !$this->has($key); + } + + /** + * Use another store from the `caching.stores` config + * + * @param string|null $name + * @return self + * @throws \RuntimeException When the store is not configured + */ + public function store(?string $name = null): self + { + if ($name === null || $name === $this->name) { + return $this; + } + + if ($this->storeResolver === null) { + throw new \RuntimeException("Cache store [{$name}] cannot be resolved: this store was not created from the cache config."); + } + + return ($this->storeResolver)($name); + } + + /** + * The key prefix this store was created with. + * + * @return string + */ + public function getPrefix(): string + { + return $this->prefix; + } + + /** + * Take over the settings of another store + * + * @param CacheStore $store + * @return static + */ + public function inheritSettingsFrom(CacheStore $store): static + { + $this->defaultTtl = $store->defaultTtl; + $this->name = $store->name; + $this->storeResolver = $store->storeResolver; + + return $this; + } + + /** + * Teach this store how to find the others. Used by the cache launcher. + * + * @param string $name + * @param \Closure $resolver fn(string $name): static + * @return static + */ + public function resolveStoresUsing(string $name, \Closure $resolver): static + { + $this->name = $name; + $this->storeResolver = $resolver; + + return $this; + } + + /** + * Get a cache whose items carry tags, so a whole group can be removed at once + * + * @param string|array $names + * @return TaggedCache + * @throws \RuntimeException + */ + public function tags(string|array $names): TaggedCache + { + $pool = $this->taggedPool(); + + if ($pool === null) { + throw new \RuntimeException('This cache store does not support tags.'); + } + + return new TaggedCache($this, $pool, array_values((array) $names)); + } + + /** + * Get the tagged pool, creating it if necessary. + * + * @return TagAwareAdapter|null + */ + protected function taggedPool(): ?TagAwareAdapter + { + if ($this->taggedPool === null && ($pool = $this->taggedNamespaceOf($this->adapter)) !== null) { + $this->taggedPool = new TagAwareAdapter($pool); + } + + return $this->taggedPool; + } + + /** + * The separate namespace tagged items are kept in, if the adapter can have one. + * + * @param object $adapter + * @return AdapterInterface|null + */ + private function taggedNamespaceOf(object $adapter): ?AdapterInterface + { + if (!$adapter instanceof NamespacedPoolInterface) { + return null; + } + + $pool = $adapter->withSubNamespace('tagged'); + + return $pool instanceof AdapterInterface ? $pool : null; + } + + /** + * Validate a key and return it as the adapter will see it. + * + * @param mixed $key + * @return string + */ + public function validatedKey($key): string + { + return $this->normalizeKey($key); + } + /** * Get a lock instance. * diff --git a/src/Phaseolies/Cache/RateLimiter.php b/src/Phaseolies/Cache/RateLimiter.php index 5bddf9cb..eee3efb4 100644 --- a/src/Phaseolies/Cache/RateLimiter.php +++ b/src/Phaseolies/Cache/RateLimiter.php @@ -51,8 +51,6 @@ public function attempt(string $key, int $maxAttempts, int $decaySeconds): RateL $this->cache->set($key, 1, $decaySeconds); $this->cache->set($timerKey, $resetAt, $decaySeconds); $hits = 1; - } elseif ($hits <= $maxAttempts) { - $this->cache->set($timerKey, $resetAt, $decaySeconds); } } @@ -147,8 +145,6 @@ public function hit(string $key, int $decaySeconds): int return 1; } - $this->cache->set($timerKey, $resetAt, $decaySeconds); - return $hits; } catch (InvalidArgumentException $e) { throw $e; diff --git a/src/Phaseolies/Cache/TaggedCache.php b/src/Phaseolies/Cache/TaggedCache.php new file mode 100644 index 00000000..cae82660 --- /dev/null +++ b/src/Phaseolies/Cache/TaggedCache.php @@ -0,0 +1,193 @@ + $tags + */ + public function __construct( + protected CacheStore $store, + protected TagAwareAdapter $pool, + protected array $tags + ) { + if ($this->tags === []) { + throw new \InvalidArgumentException('At least one cache tag is required.'); + } + } + + /** + * Get a tagged item. + * + * @param mixed $key + * @param mixed $default + * @return mixed + */ + public function get($key, $default = null): mixed + { + $item = $this->pool->getItem($this->store->validatedKey($key)); + + return $item->isHit() ? $item->get() : $default; + } + + /** + * Store an item with this cache's tags. + * + * @param mixed $key + * @param mixed $value + * @param null|int|\DateInterval $ttl + * @return bool + */ + public function set($key, $value, $ttl = null): bool + { + $item = $this->pool->getItem($this->store->validatedKey($key)); + $item->set($value)->tag($this->tags); + + $seconds = $this->store->ttlSeconds($ttl); + + if ($seconds !== null) { + $item->expiresAfter($seconds); + } + + return $this->pool->save($item); + } + + /** + * Store an item with this cache's tags until it is flushed. + * + * @param mixed $key + * @param mixed $value + * @return bool + */ + public function forever($key, $value): bool + { + $item = $this->pool->getItem($this->store->validatedKey($key)); + $item->set($value)->tag($this->tags)->expiresAfter(null); + + return $this->pool->save($item); + } + + /** + * Determine whether a tagged item exists. + * + * @param mixed $key + * @return bool + */ + public function has($key): bool + { + return $this->pool->hasItem($this->store->validatedKey($key)); + } + + /** + * Remove one tagged item. + * + * @param mixed $key + * @return bool + */ + public function delete($key): bool + { + return $this->pool->deleteItem($this->store->validatedKey($key)); + } + + /** + * Remove one tagged item. + * + * @param mixed $key + * @return bool + */ + public function forget($key): bool + { + return $this->delete($key); + } + + /** + * Get a tagged item and remove it in one step. + * + * @param mixed $key + * @param mixed $default + * @return mixed + */ + public function pull($key, $default = null): mixed + { + $key = $this->store->validatedKey($key); + $item = $this->pool->getItem($key); + + if (!$item->isHit()) { + return $default; + } + + $value = $item->get(); + $this->pool->deleteItem($key); + + return $value; + } + + /** + * Get a tagged item, or run the callback and store its result with these tags. + * + * @param string $key + * @param int|\DateInterval|null $ttl + * @param \Closure $callback + * @return mixed + */ + public function stash(string $key, $ttl, \Closure $callback): mixed + { + return $this->remember($key, $this->store->ttlSeconds($ttl), $callback); + } + + /** + * Get a tagged item, or run the callback and store its result until it is flushed. + * + * @param string $key + * @param \Closure $callback + * @return mixed + */ + public function stashForever(string $key, \Closure $callback): mixed + { + return $this->remember($key, null, $callback); + } + + /** + * Remove every item that has any of this cache's tags. + * + * @return bool + */ + public function flush(): bool + { + return $this->pool->invalidateTags($this->tags); + } + + /** + * The tags this cache writes with. + * + * @return array + */ + public function getTags(): array + { + return $this->tags; + } + + /** + * @param string $key + * @param int|null $seconds + * @param \Closure $callback + * @return mixed + */ + protected function remember(string $key, ?int $seconds, \Closure $callback): mixed + { + return $this->pool->get( + $this->store->validatedKey($key), + function (ItemInterface $item) use ($seconds, $callback) { + $item->tag($this->tags)->expiresAfter($seconds); + + return $callback(); + } + ); + } +} diff --git a/src/Phaseolies/Console/Commands/ClearCacheCommand.php b/src/Phaseolies/Console/Commands/ClearCacheCommand.php index 9754e3d8..100249ad 100644 --- a/src/Phaseolies/Console/Commands/ClearCacheCommand.php +++ b/src/Phaseolies/Console/Commands/ClearCacheCommand.php @@ -12,7 +12,7 @@ class ClearCacheCommand extends Command * * @var string */ - protected $name = 'cache:clear'; + protected $name = 'cache:clear {--store= : Clear only this store from the caching config, leaving the framework cache files alone}'; /** * The command description shown in the Pool command list. @@ -28,6 +28,16 @@ class ClearCacheCommand extends Command */ public function handle(): int { + $store = $this->option('store'); + + if ($store) { + return $this->withTiming(function () use ($store) { + Cache::store($store)->clear(); + + return Command::SUCCESS; + }, "Cache store [{$store}] has been cleared."); + } + return $this->withTiming(function() { $cacheDir = base_path('storage/framework/cache'); $this->deleteDirectoryContents($cacheDir); diff --git a/src/Phaseolies/Launchers/CacheLauncher.php b/src/Phaseolies/Launchers/CacheLauncher.php index 235a3974..3676497e 100644 --- a/src/Phaseolies/Launchers/CacheLauncher.php +++ b/src/Phaseolies/Launchers/CacheLauncher.php @@ -19,6 +19,11 @@ class CacheLauncher extends ServiceLauncher implements GhostableLauncher */ protected array $customAdapters = []; + /** + * @var CacheStore[] Stores that have been built, by name + */ + protected array $stores = []; + /** * Register the service provider. * @@ -26,14 +31,54 @@ class CacheLauncher extends ServiceLauncher implements GhostableLauncher */ public function register(): void { - $adapter = $this->createAdapter(config('caching.default', 'file')); - $cacheStore = new CacheStore($adapter, config('caching.prefix')); + $default = (string) config('caching.default', 'file'); + $cacheStore = $this->stores[$default] = $this->createStore($default); + $this->app->singleton(CacheStore::class, fn() => $cacheStore); $this->app->singleton(IncrementableCacheInterface::class, fn() => $cacheStore); $this->app->singleton(CacheInterface::class, fn() => $cacheStore); $this->app->singleton('cache', fn() => $cacheStore); } + /** + * Build the cache store for a name in `caching.stores`. Every store can reach + * the others, so `Cache::store('redis')` works from any of them. + * + * @param string $name + * @return CacheStore + */ + public function createStore(string $name): CacheStore + { + $config = config("caching.stores.{$name}"); + $config = is_array($config) ? $config : []; + + $store = new CacheStore( + $this->createAdapter($name), + $this->prefixFor($name), + isset($config['ttl']) && (int) $config['ttl'] > 0 ? (int) $config['ttl'] : null + ); + + return $store->resolveStoresUsing($name, function (string $other) { + return $this->stores[$other] ??= $this->createStore($other); + }); + } + + /** + * The key prefix of a store. It names the adapter's namespace too, so it is + * limited to characters every backend accepts, and it is never empty: with an + * empty namespace, clearing the cache would flush the whole Redis database. + * + * @param string $store + * @return string + */ + protected function prefixFor(string $store): string + { + $prefix = config("caching.stores.{$store}.prefix") ?? config('caching.prefix'); + $prefix = preg_replace('/[^-+_.A-Za-z0-9]/', '_', (string) $prefix); + + return $prefix === '' ? 'doppar_cache_' : $prefix; + } + /** * Create the cache adapter * @@ -43,11 +88,15 @@ public function register(): void public function createAdapter(string $store): mixed { $storeConfig = config("caching.stores.{$store}"); + $storeConfig = is_array($storeConfig) ? $storeConfig : []; + $prefix = $this->prefixFor($store); + // The default ttl is applied by CacheStore, not by the adapter, so that + // forever() really is forever. return match ($storeConfig['driver'] ?? null) { - 'apc' => new ApcuAdapter(config('caching.prefix')), + 'apc' => new ApcuAdapter($prefix), 'file' => new FilesystemAdapter( - config('caching.prefix'), + $prefix, 0, $storeConfig['path'] ?? storage_path('framework/cache/data') ), @@ -55,7 +104,7 @@ public function createAdapter(string $store): mixed 0, $storeConfig['serialize'] ?? false ), - 'redis' => $this->createRedisAdapter($storeConfig), + 'redis' => $this->createRedisAdapter($storeConfig, $prefix), default => $this->createCustomAdapter($store, $storeConfig) }; } @@ -66,24 +115,32 @@ public function createAdapter(string $store): mixed * @param array $config * @return RedisAdapter */ - protected function createRedisAdapter(array $config): RedisAdapter + protected function createRedisAdapter(array $config, ?string $prefix = null): RedisAdapter { $redis = new \Redis(); $dsn = $config['connection'] ?? 'redis://127.0.0.1:6379'; $parsed = parse_url($dsn); + $parameters = $config['options']['parameters'] ?? []; $host = $parsed['host'] ?? '127.0.0.1'; $port = $parsed['port'] ?? 6379; - $password = $parsed['pass'] ?? null; - $database = isset($parsed['path']) ? (int) substr($parsed['path'], 1) : 0; + + // The connection string wins; `options.parameters` fills in what it leaves out. + $username = isset($parsed['user']) && $parsed['user'] !== '' ? urldecode($parsed['user']) : null; + $password = isset($parsed['pass']) && $parsed['pass'] !== '' + ? urldecode($parsed['pass']) + : (($parameters['password'] ?? '') !== '' ? (string) $parameters['password'] : null); + + $path = trim($parsed['path'] ?? '', '/'); + $database = $path !== '' ? (int) $path : (int) ($parameters['database'] ?? 0); if (!$redis->connect($host, $port, 2.5)) { throw new \RuntimeException("Could not connect to Redis at {$host}:{$port}"); } if ($password !== null) { - $redis->auth($password); + $redis->auth($username !== null ? [$username, $password] : $password); } if ($database > 0) { @@ -97,11 +154,7 @@ protected function createRedisAdapter(array $config): RedisAdapter } } - return new RedisAdapter( - $redis, - config('caching.prefix'), - $config['ttl'] ?? 0 - ); + return new RedisAdapter($redis, $prefix ?? $this->prefixFor('redis'), 0); } /** @@ -136,7 +189,15 @@ protected function createCustomAdapter(string $store, array $config): mixed return $this->customAdapters[$store]($config); } - throw new \RuntimeException("Cache store [{$store}] is not defined."); + if ($config === []) { + throw new \RuntimeException("Cache store [{$store}] is not defined."); + } + + throw new \RuntimeException(sprintf( + 'Cache driver [%s] of store [%s] is not supported. Use apc, array, file or redis, or register it with extend().', + $config['driver'] ?? 'none', + $store + )); } /** diff --git a/src/Phaseolies/Middleware/ThrottleRequests.php b/src/Phaseolies/Middleware/ThrottleRequests.php index bf662c01..ca4d3449 100644 --- a/src/Phaseolies/Middleware/ThrottleRequests.php +++ b/src/Phaseolies/Middleware/ThrottleRequests.php @@ -40,24 +40,25 @@ public function __construct(RateLimiter $rateLimiter) */ public function __invoke(Request $request, Closure $next, $maxAttempts = 60, $decayMinutes = 1): Response { - $response = $next($request); - - $key = $this->resolveRequestSignature($request); + $key = $this->resolveRequestSignature($request, $maxAttempts, $decayMinutes); $maxAttempts = $this->resolveMaxAttempts($request, $maxAttempts); $decaySeconds = (int) ($decayMinutes * 60); if ($this->limiter->tooManyAttempts($key, $maxAttempts)) { - return $this->buildResponse($key, $maxAttempts, $next, $request); + $this->rejectIfStillLimited($key); } $this->limiter->hit($key, $decaySeconds); + // The handler only runs for requests that are allowed through. + $response = $next($request); + return $this->addHeaders( $response, $maxAttempts, $this->calculateRemainingAttempts($key, $maxAttempts), - $this->limiter->availableAt($decaySeconds) + $this->limiter->availableAt($this->limiter->availableIn($key) ?: $decaySeconds) ); } @@ -87,13 +88,13 @@ protected function resolveMaxAttempts(Request $request, $maxAttempts): int * @param \Phaseolies\Http\Request $request * @return string */ - protected function resolveRequestSignature(Request $request): string + protected function resolveRequestSignature(Request $request, $maxAttempts = 60, $decayMinutes = 1): string { - if ($request->user()) { - return sha1(auth()->id()); - } + $client = $request->user() ? 'user:' . auth()->id() : 'ip:' . $request->ip(); - return sha1($request->ip()); + // The limit is part of the key, so a strict limit on one route (login: 5 a minute) + // is not used up by traffic on a generous one (API: 60 a minute). + return sha1($client . '|' . $maxAttempts . '|' . $decayMinutes); } /** @@ -129,21 +130,21 @@ protected function addHeaders(Response $response, int $maxAttempts, int $remaini } /** - * Create a 'too many attempts' response. + * Stop a client that has used up its attempts, unless its window has ended. * * @param string $key - * @param int $maxAttempts - * @param \Closure $next - * @param Request $request - * @return \Phaseolies\Http\Response + * @return void + * @throws TooManyRequestsHttpException */ - protected function buildResponse(string $key, int $maxAttempts, \Closure $next, Request $request): Response + protected function rejectIfStillLimited(string $key): void { $retryAfter = $this->limiter->availableIn($key); + // The window is over, so the client starts again with a fresh count. if ($retryAfter <= 0) { $this->limiter->clear($key); - return $next($request); + + return; } $message = trans('validation.rate_limit.error', ['attribute' => $retryAfter]); diff --git a/src/Phaseolies/Support/Facades/Cache.php b/src/Phaseolies/Support/Facades/Cache.php index b51297d4..3feab369 100644 --- a/src/Phaseolies/Support/Facades/Cache.php +++ b/src/Phaseolies/Support/Facades/Cache.php @@ -22,6 +22,10 @@ * @method static mixed stash(string $key, $ttl, Closure $callback) * @method static mixed stashForever(string $key, Closure $callback) * @method static mixed stashWhen(string $key, Closure $callback, bool $condition, $ttl = null) + * @method static mixed pull($key, $default = null) + * @method static bool missing($key) + * @method static \Phaseolies\Cache\CacheStore store(?string $name = null) + * @method static \Phaseolies\Cache\TaggedCache tags(string|array $names) * @method static AtomicLock locked(string $name, int $seconds = 10, ?string $owner = null) * @method static AtomicLock restoreLock(string $name, string $owner) * @method static bool get() diff --git a/tests/Cache/CacheLauncherTest.php b/tests/Cache/CacheLauncherTest.php new file mode 100644 index 00000000..4a6dbb0f --- /dev/null +++ b/tests/Cache/CacheLauncherTest.php @@ -0,0 +1,313 @@ +container = new MockContainer(); + Container::setInstance($this->container); + $this->resetConfig(); + + Config::set('caching.default', 'array'); + Config::set('caching.prefix', 'app_cache_'); + Config::set('caching.stores', [ + 'array' => ['driver' => 'array'], + 'other' => ['driver' => 'array', 'ttl' => 90, 'prefix' => 'other_'], + 'file' => ['driver' => 'file', 'path' => sys_get_temp_dir() . '/doppar-launcher-' . bin2hex(random_bytes(4))], + ]); + } + + protected function tearDown(): void + { + $this->resetConfig(); + Container::forgetInstance(); + } + + private function resetConfig(): void + { + $reflection = new \ReflectionClass(Config::class); + + foreach (['config' => [], 'cacheFile' => null, 'loadedFromCache' => false, 'fileHashes' => []] as $name => $value) { + if ($reflection->hasProperty($name)) { + $reflection->getProperty($name)->setValue(null, $value); + } + } + } + + /** @var array What register() bound as singletons */ + private array $bindings = []; + + private function launcher(): CacheLauncher + { + $app = $this->createStub(Application::class); + $app->method('singleton')->willReturnCallback(function (string $abstract, $concrete = null) { + $this->bindings[$abstract] = $concrete; + }); + + $launcher = (new \ReflectionClass(CacheLauncher::class))->newInstanceWithoutConstructor(); + (new \ReflectionProperty($launcher, 'app'))->setValue($launcher, $app); + + return $launcher; + } + + private function bound(string $abstract): mixed + { + return ($this->bindings[$abstract])(); + } + + private function prefixFor(string $store): string + { + return (new \ReflectionMethod(CacheLauncher::class, 'prefixFor'))->invoke($this->launcher(), $store); + } + + // ----- prefix ----------------------------------------------------------------------- + + public function testAnEmptyPrefixFallsBackToASafeOne(): void + { + Config::set('caching.prefix', ''); + + $this->assertSame('doppar_cache_', $this->prefixFor('array')); + } + + public function testAPrefixIsMadeSafeForEveryBackend(): void + { + Config::set('caching.prefix', 'my app/v2:cache_'); + + $this->assertSame('my_app_v2_cache_', $this->prefixFor('array')); + } + + public function testAStoreCanHaveItsOwnPrefix(): void + { + $this->assertSame('other_', $this->prefixFor('other')); + $this->assertSame('app_cache_', $this->prefixFor('array')); + } + + public function testAFileStoreSurvivesAnAppNameWithSpaces(): void + { + Config::set('caching.prefix', strtolower('My App') . '_cache_'); + + $adapter = $this->launcher()->createAdapter('file'); + + $this->assertInstanceOf(FilesystemAdapter::class, $adapter); + } + + // ----- errors ----------------------------------------------------------------------- + + public function testAnUnknownStoreIsReportedClearly(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Cache store [memcached] is not defined.'); + + $this->launcher()->createAdapter('memcached'); + } + + public function testAnUnsupportedDriverIsReportedClearly(): void + { + Config::set('caching.stores.weird', ['driver' => 'memcached']); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Cache driver [memcached] of store [weird] is not supported'); + + $this->launcher()->createAdapter('weird'); + } + + public function testACustomDriverCanBeRegistered(): void + { + Config::set('caching.stores.custom', ['driver' => 'mine']); + $launcher = $this->launcher(); + $launcher->extend('custom', fn(array $config) => new ArrayAdapter()); + + $this->assertInstanceOf(ArrayAdapter::class, $launcher->createAdapter('custom')); + } + + // ----- stores ----------------------------------------------------------------------- + + public function testTheStoreDefaultTtlComesFromTheConfig(): void + { + $store = $this->launcher()->createStore('other'); + $store->set('k', 1); + $adapter = $store->getAdapter(); + + $expiry = $adapter->getItem('other_k')->getMetadata()['expiry']; + $this->assertEqualsWithDelta(time() + 90, $expiry, 2); + + $plain = $this->launcher()->createStore('array'); + $plain->set('k', 1); + $this->assertNull($plain->getAdapter()->getItem('app_cache_k')->getMetadata()['expiry'] ?? null); + } + + public function testRegisterBindsTheDefaultStoreAndOthersAreReachableThroughStore(): void + { + $launcher = $this->launcher(); + $launcher->register(); + + /** @var CacheStore $cache */ + $cache = $this->bound('cache'); + $this->assertSame($cache, $this->bound(CacheStore::class)); + + $this->assertSame($cache, $cache->store('array'), 'the default store resolves to the bound instance'); + + $other = $cache->store('other'); + $this->assertNotSame($cache, $other); + $this->assertSame($other, $cache->store('other'), 'a store is built once'); + $this->assertSame($cache, $other->store('array'), 'every store can reach the default one'); + + $cache->set('who', 'default'); + $other->set('who', 'other'); + $this->assertSame('default', $cache->get('who')); + $this->assertSame('other', $other->get('who')); + } + + public function testAskingForAnUnknownStoreThrows(): void + { + $launcher = $this->launcher(); + $launcher->register(); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Cache store [nope] is not defined.'); + + $this->bound('cache')->store('nope'); + } + + // ----- redis (opt-in) --------------------------------------------------------------- + + /** + * Needs a Redis server and a database you do not mind losing: + * + * DOPPAR_TEST_REDIS_HOST=127.0.0.1 DOPPAR_TEST_REDIS_DB=13 vendor/bin/phpunit --group redis + * + * The database must be empty; the tests flush it. + */ + private function redis(): \Redis + { + $host = getenv('DOPPAR_TEST_REDIS_HOST') ?: ''; + $database = getenv('DOPPAR_TEST_REDIS_DB'); + + if ($host === '' || $database === false || !extension_loaded('redis')) { + $this->markTestSkipped('Set DOPPAR_TEST_REDIS_HOST and DOPPAR_TEST_REDIS_DB (an empty scratch database) to run this test.'); + } + + $redis = new \Redis(); + $redis->connect($host, (int) (getenv('DOPPAR_TEST_REDIS_PORT') ?: 6379)); + $redis->select((int) $database); + + if ($redis->dbSize() !== 0) { + $this->markTestSkipped("Redis database {$database} is not empty, refusing to use it."); + } + + return $redis; + } + + private function redisConfig(array $overrides = []): array + { + $host = getenv('DOPPAR_TEST_REDIS_HOST'); + $port = getenv('DOPPAR_TEST_REDIS_PORT') ?: 6379; + + return array_merge(['driver' => 'redis', 'connection' => "redis://{$host}:{$port}"], $overrides); + } + + #[Group('redis')] + public function testForeverIsReallyForeverOnRedisWhileSetUsesTheConfiguredTtl(): void + { + $redis = $this->redis(); + Config::set('caching.stores.redis', $this->redisConfig([ + 'ttl' => 3600, + 'options' => ['parameters' => ['database' => getenv('DOPPAR_TEST_REDIS_DB')]], + ])); + + try { + $store = $this->launcher()->createStore('redis'); + $store->set('plain', 1); + $store->forever('perm', 2); + $store->stashForever('perm2', fn() => 3); + + $this->assertEqualsWithDelta(3600, $redis->ttl($redis->keys('*plain')[0]), 5); + $this->assertSame(-1, $redis->ttl($redis->keys('*perm')[0]), 'forever() must not expire'); + $this->assertSame(-1, $redis->ttl($redis->keys('*perm2')[0]), 'stashForever() must not expire'); + } finally { + $redis->flushDB(); + } + } + + #[Group('redis')] + public function testClearingAStoreWithNoConfiguredPrefixLeavesOtherKeysAlone(): void + { + $redis = $this->redis(); + Config::set('caching.prefix', ''); + Config::set('caching.stores.redis', $this->redisConfig([ + 'options' => ['parameters' => ['database' => getenv('DOPPAR_TEST_REDIS_DB')]], + ])); + + try { + $redis->set('someone_elses_session', 'keep me'); + + $store = $this->launcher()->createStore('redis'); + $store->set('mine', 1); + $store->clear(); + + $this->assertSame('keep me', $redis->get('someone_elses_session')); + $this->assertFalse($store->has('mine')); + } finally { + $redis->flushDB(); + } + } + + #[Group('redis')] + public function testTheDatabaseFromOptionsParametersIsUsed(): void + { + $redis = $this->redis(); + Config::set('caching.stores.redis', $this->redisConfig([ + 'options' => ['parameters' => ['database' => getenv('DOPPAR_TEST_REDIS_DB'), 'password' => '']], + ])); + + try { + $this->launcher()->createStore('redis')->set('where', 'here'); + + $this->assertCount(1, $redis->keys('*where'), 'written to the configured database'); + } finally { + $redis->flushDB(); + } + } + + #[Group('redis')] + public function testTagsWorkOnRedis(): void + { + $redis = $this->redis(); + Config::set('caching.stores.redis', $this->redisConfig([ + 'options' => ['parameters' => ['database' => getenv('DOPPAR_TEST_REDIS_DB')]], + ])); + + try { + $store = $this->launcher()->createStore('redis'); + $store->tags('users')->set('user.1', 'ada'); + $store->tags('posts')->set('post.1', 'hello'); + $store->set('plain', 'p'); + + $store->tags('users')->flush(); + + $this->assertFalse($store->tags('users')->has('user.1')); + $this->assertSame('hello', $store->tags('posts')->get('post.1')); + $this->assertSame('p', $store->get('plain')); + + $store->clear(); + $this->assertFalse($store->tags('posts')->has('post.1')); + } finally { + $redis->flushDB(); + } + } +} diff --git a/tests/Cache/CacheStoreFeaturesTest.php b/tests/Cache/CacheStoreFeaturesTest.php new file mode 100644 index 00000000..b26db981 --- /dev/null +++ b/tests/Cache/CacheStoreFeaturesTest.php @@ -0,0 +1,505 @@ +inner->getItem($key); + } + + public function getItems(array $keys = []): iterable + { + return $this->inner->getItems($keys); + } + + public function hasItem(string $key): bool + { + $this->hasItemCalls++; + + return $this->inner->hasItem($key); + } + + public function clear(string $prefix = ''): bool + { + return $this->inner->clear($prefix); + } + + 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 get(string $key, callable $callback, ?float $beta = null, ?array &$metadata = null): mixed + { + $this->getCalls++; + + return $this->inner->get($key, $callback, $beta, $metadata); + } + + public function delete(string $key): bool + { + return $this->inner->delete($key); + } +} + +class CacheStoreFeaturesTest extends TestCase +{ + private ArrayAdapter $adapter; + + protected function setUp(): void + { + Container::setInstance(new MockContainer()); + $this->adapter = new ArrayAdapter(); + } + + protected function tearDown(): void + { + Container::forgetInstance(); + } + + private function store(?int $defaultTtl = null, ?ArrayAdapter $adapter = null): CacheStore + { + return new CacheStore($adapter ?? $this->adapter, 'p_', $defaultTtl); + } + + private function expiryOf(string $key, ?ArrayAdapter $adapter = null): ?float + { + return ($adapter ?? $this->adapter)->getItem('p_' . $key)->getMetadata()['expiry'] ?? null; + } + + // ----- default ttl -------------------------------------------------------------- + + public function testTheDefaultTtlAppliesToPlainWritesButNotToForever(): void + { + $store = $this->store(100); + + $store->set('plain', 1); + $store->forever('perm', 2); + $store->setMultiple(['multi' => 3]); + $store->add('added', 4); + + $this->assertEqualsWithDelta(time() + 100, $this->expiryOf('plain'), 2); + $this->assertEqualsWithDelta(time() + 100, $this->expiryOf('multi'), 2); + $this->assertEqualsWithDelta(time() + 100, $this->expiryOf('added'), 2); + $this->assertNull($this->expiryOf('perm'), 'forever() must not inherit the default ttl'); + } + + public function testAnExplicitTtlBeatsTheDefault(): void + { + $this->store(100)->set('k', 1, 10); + + $this->assertEqualsWithDelta(time() + 10, $this->expiryOf('k'), 2); + } + + public function testNoDefaultTtlMeansNoExpiry(): void + { + $this->store()->set('k', 1); + + $this->assertNull($this->expiryOf('k')); + } + + // ----- increment / decrement ------------------------------------------------------ + + public function testIncrementAndDecrementKeepTheTtlOnTheArrayAdapter(): void + { + $store = $this->store(); + $store->set('n', 5, 60); + + $this->assertSame(6, $store->increment('n')); + $this->assertSame(4, $store->decrement('n', 2)); + $this->assertEqualsWithDelta(time() + 60, $this->expiryOf('n'), 2); + } + + public function testIncrementOnAMissingKeyReturnsFalse(): void + { + $this->assertFalse($this->store()->increment('absent')); + $this->assertFalse($this->store()->decrement('absent')); + } + + // ----- stash ---------------------------------------------------------------------- + + public function testStashRunsTheCallbackOnlyOnAMiss(): void + { + $store = $this->store(); + $calls = 0; + $compute = function () use (&$calls) { + return 'v' . ++$calls; + }; + + $this->assertSame('v1', $store->stash('k', 60, $compute)); + $this->assertSame('v1', $store->stash('k', 60, $compute)); + $this->assertSame(1, $calls); + } + + public function testStashCachesNullAndFalsyValues(): void + { + $store = $this->store(); + $calls = 0; + + foreach ([null, false, 0, ''] as $i => $value) { + $store->stash("k$i", 60, function () use ($value, &$calls) { + $calls++; + + return $value; + }); + $this->assertSame($value, $store->stash("k$i", 60, fn() => 'recomputed')); + } + + $this->assertSame(4, $calls); + } + + public function testStashReadsTheItemOnceInsteadOfHasThenGet(): void + { + $spy = new SpyAdapter(); + $store = new CacheStore($spy, 'p_'); + $store->set('k', 'cached'); + + $this->assertSame('cached', $store->stash('k', 60, fn() => 'recomputed')); + $this->assertSame(0, $spy->hasItemCalls, 'has() then get() leaves a window where the item can expire'); + $this->assertSame(1, $spy->getCalls); + } + + public function testStashUsesTheStoreDefaultTtlAndForeverUsesNone(): void + { + $store = $this->store(50); + + $store->stash('a', null, fn() => 1); + $store->stashForever('b', fn() => 2); + $store->stash('c', 20, fn() => 3); + + $this->assertEqualsWithDelta(time() + 50, $this->expiryOf('a'), 2); + $this->assertNull($this->expiryOf('b')); + $this->assertEqualsWithDelta(time() + 20, $this->expiryOf('c'), 2); + } + + public function testStashDoesNotCacheACallbackThatThrows(): void + { + $store = $this->store(); + + try { + $store->stash('k', 60, fn() => throw new \DomainException('nope')); + $this->fail('Expected the exception'); + } catch (\DomainException) { + } + + $this->assertFalse($store->has('k')); + $this->assertSame('ok', $store->stash('k', 60, fn() => 'ok')); + } + + public function testStashWhenSkipsTheCacheWhenTheConditionIsFalse(): void + { + $store = $this->store(); + + $this->assertSame('x', $store->stashWhen('k', fn() => 'x', false, 60)); + $this->assertFalse($store->has('k')); + $this->assertSame('x', $store->stashWhen('k', fn() => 'x', true)); + $this->assertTrue($store->has('k')); + } + + // ----- pull / missing ------------------------------------------------------------- + + public function testPullReturnsTheValueAndRemovesIt(): void + { + $store = $this->store(); + $store->set('k', 'v'); + + $this->assertSame('v', $store->pull('k')); + $this->assertFalse($store->has('k')); + $this->assertSame('fallback', $store->pull('k', 'fallback')); + } + + public function testMissing(): void + { + $store = $this->store(); + $store->set('k', null); + + $this->assertTrue($store->missing('absent')); + $this->assertFalse($store->missing('k'), 'a stored null is still present'); + } + + // ----- store() -------------------------------------------------------------------- + + public function testStoreWithoutAnArgumentIsTheSameStore(): void + { + $store = $this->store(); + + $this->assertSame($store, $store->store()); + } + + public function testStoreResolvesOtherStoresByName(): void + { + $other = $this->store(); + $store = $this->store()->resolveStoresUsing('main', fn(string $name) => $name === 'other' ? $other : throw new \RuntimeException("no [$name]")); + + $this->assertSame($other, $store->store('other')); + $this->assertSame($store, $store->store('main'), 'asking for itself returns itself'); + + $this->expectExceptionMessage('no [missing]'); + $store->store('missing'); + } + + public function testStoreWithoutAResolverExplainsWhy(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Cache store [redis] cannot be resolved'); + + $this->store()->store('redis'); + } + + public function testASubclassedStoreCanStillReachPlainStores(): void + { + // A profiler wraps the store in a subclass; store() hands back whatever the resolver builds. + $subclass = new class(new ArrayAdapter(), 'p_') extends CacheStore { + }; + $plain = $this->store(); + $subclass->resolveStoresUsing('main', fn() => $plain); + + $this->assertSame($plain, $subclass->store('other')); + } + + public function testAWrappingStoreCanInheritTheSettingsOfTheOneItReplaces(): void + { + $original = new CacheStore(new ArrayAdapter(), 'orig_', 77); + $other = $this->store(); + $original->resolveStoresUsing('main', fn() => $other); + + $replacement = (new CacheStore(new ArrayAdapter(), $original->getPrefix()))->inheritSettingsFrom($original); + $replacement->set('k', 1); + + $this->assertSame('orig_', $replacement->getPrefix()); + $this->assertSame($other, $replacement->store('other')); + $this->assertSame($replacement, $replacement->store('main'), 'it also knows its own name'); + $this->assertEqualsWithDelta(time() + 77, $replacement->getAdapter()->getItem('orig_k')->getMetadata()['expiry'], 2); + } + + // ----- tags ----------------------------------------------------------------------- + + public function testTaggedItemsAreReadBackThroughTheTaggedCache(): void + { + $tagged = $this->store()->tags('users'); + + $this->assertInstanceOf(TaggedCache::class, $tagged); + $this->assertTrue($tagged->set('42', ['name' => 'Ada'])); + $this->assertSame(['name' => 'Ada'], $tagged->get('42')); + $this->assertTrue($tagged->has('42')); + $this->assertSame('none', $tagged->get('absent', 'none')); + } + + public function testFlushRemovesOnlyTheItemsCarryingThatTag(): void + { + $store = $this->store(); + $store->tags('users')->set('a', 1); + $store->tags('posts')->set('b', 2); + + $this->assertTrue($store->tags('users')->flush()); + + $this->assertFalse($store->tags('users')->has('a')); + $this->assertSame(2, $store->tags('posts')->get('b')); + } + + public function testFlushingAnyOfSeveralTagsRemovesTheItem(): void + { + $store = $this->store(); + $store->tags(['users', 'reports'])->set('both', 1); + $store->tags('users')->set('only-users', 2); + $store->tags('other')->set('unrelated', 3); + + $store->tags('reports')->flush(); + + $this->assertFalse($store->tags('users')->has('both'), 'carried the reports tag'); + $this->assertTrue($store->tags('users')->has('only-users')); + $this->assertTrue($store->tags('other')->has('unrelated')); + } + + public function testTaggedStashCachesAndIsRemovedByFlush(): void + { + $tagged = $this->store()->tags(['reports']); + $calls = 0; + $compute = function () use (&$calls) { + return 'report' . ++$calls; + }; + + $this->assertSame('report1', $tagged->stash('top', 60, $compute)); + $this->assertSame('report1', $tagged->stash('top', 60, $compute)); + + $tagged->flush(); + + $this->assertSame('report2', $tagged->stash('top', 60, $compute)); + } + + public function testTaggedStashForeverSurvivesUntilFlushed(): void + { + $tagged = $this->store(50)->tags('t'); + + $tagged->stashForever('k', fn() => 'v'); + $tagged->forever('k2', 'v2'); + + $this->assertSame('v', $tagged->get('k')); + $this->assertSame('v2', $tagged->get('k2')); + + $tagged->flush(); + $this->assertFalse($tagged->has('k')); + } + + public function testTaggedItemsAreKeptApartFromPlainKeys(): void + { + $store = $this->store(); + $store->set('shared', 'plain'); + $store->tags('t')->set('shared', 'tagged'); + + $this->assertSame('plain', $store->get('shared')); + $this->assertSame('tagged', $store->tags('t')->get('shared')); + + $store->tags('t')->flush(); + + $this->assertSame('plain', $store->get('shared'), 'flushing a tag leaves plain keys alone'); + } + + public function testClearAlsoRemovesTaggedItems(): void + { + $store = $this->store(); + $store->set('plain', 1); + $store->tags('t')->set('tagged', 2); + + $store->clear(); + + $this->assertFalse($store->has('plain')); + $this->assertFalse($store->tags('t')->has('tagged')); + } + + public function testTaggedItemsExpire(): void + { + $tagged = $this->store()->tags('t'); + $tagged->set('short', 'v', 1); + $tagged->set('long', 'v', 60); + + sleep(2); + + $this->assertFalse($tagged->has('short')); + $this->assertTrue($tagged->has('long')); + } + + public function testTaggedPullDeleteAndForget(): void + { + $tagged = $this->store()->tags('t'); + $tagged->set('a', 1); + $tagged->set('b', 2); + $tagged->set('c', 3); + + $this->assertSame(1, $tagged->pull('a')); + $this->assertSame('gone', $tagged->pull('a', 'gone')); + $this->assertTrue($tagged->delete('b')); + $this->assertTrue($tagged->forget('c')); + $this->assertFalse($tagged->has('b') || $tagged->has('c')); + } + + public function testTaggedKeysAreSharedAcrossTags(): void + { + $store = $this->store(); + $store->tags('users')->set('same-key', 'first'); + $store->tags('posts')->set('same-key', 'second'); + + $this->assertSame('second', $store->tags('users')->get('same-key'), 'tags group items for flushing, they do not scope keys'); + } + + public function testTagsExposeTheirNames(): void + { + $this->assertSame(['a', 'b'], $this->store()->tags(['a', 'b'])->getTags()); + $this->assertSame(['a'], $this->store()->tags('a')->getTags()); + } + + public function testAtLeastOneTagIsRequired(): void + { + $this->expectException(\InvalidArgumentException::class); + + $this->store()->tags([]); + } + + public function testTagsNeedAnAdapterWithNamespaceSupport(): void + { + $store = new CacheStore(new class extends SpyAdapter { + }, 'p_'); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('does not support tags'); + + $store->tags('t'); + } + + public function testTagsWorkOnTheFilesystemDriver(): void + { + $directory = sys_get_temp_dir() . '/doppar-tags-' . bin2hex(random_bytes(4)); + + try { + $store = new CacheStore(new FilesystemAdapter('tagtest', 0, $directory), 'p_'); + $store->tags(['users'])->set('user.1', 'ada'); + $store->tags(['posts'])->set('post.1', 'hello'); + + $this->assertSame('ada', $store->tags('users')->get('user.1')); + + $store->tags('users')->flush(); + + $this->assertFalse($store->tags('users')->has('user.1')); + $this->assertSame('hello', $store->tags('posts')->get('post.1')); + } finally { + $this->removeDirectory($directory); + } + } + + private function removeDirectory(string $directory): void + { + if (!is_dir($directory)) { + return; + } + + foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST) as $item) { + $item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname()); + } + + rmdir($directory); + } +} diff --git a/tests/Cache/ThrottleRequestsTest.php b/tests/Cache/ThrottleRequestsTest.php new file mode 100644 index 00000000..11d98d8a --- /dev/null +++ b/tests/Cache/ThrottleRequestsTest.php @@ -0,0 +1,180 @@ +stubIp; + } + + public function user(): ?\Phaseolies\Database\Entity\Model + { + return null; + } +} + +class ThrottleRequestsTest extends TestCase +{ + private CacheStore $cache; + private ThrottleRequests $middleware; + private int $handled = 0; + + protected function setUp(): void + { + $container = new MockContainer(); + $container->instance('translator', new class { + public function trans($key, array $replace = [], $locale = null): string + { + return 'Too many attempts, retry in ' . ($replace['attribute'] ?? '?') . ' seconds.'; + } + }); + Container::setInstance($container); + + $this->cache = new CacheStore(new ArrayAdapter(), 'rl_'); + $this->middleware = new ThrottleRequests(new RateLimiter($this->cache)); + $this->handled = 0; + } + + protected function tearDown(): void + { + Container::forgetInstance(); + } + + /** + * Send one request through the middleware + */ + private function send(int|string $max, float|int $decayMinutes = 1, string $ip = '203.0.113.7'): Response + { + return ($this->middleware)(new ThrottleClientRequest($ip), function () { + $this->handled++; + + return new Response('ok'); + }, $max, $decayMinutes); + } + + public function testTheHandlerOnlyRunsForRequestsThatAreAllowedThrough(): void + { + $blocked = 0; + + for ($i = 0; $i < 6; $i++) { + try { + $this->send(2); + } catch (TooManyRequestsHttpException) { + $blocked++; + } + } + + $this->assertSame(2, $this->handled, 'a blocked request must never reach the controller'); + $this->assertSame(4, $blocked); + } + + public function testTheLimitIsExactlyTheNumberOfAllowedRequests(): void + { + for ($i = 1; $i <= 3; $i++) { + $response = $this->send(3); + $this->assertSame((string) 3, (string) $response->headers->get('X-RateLimit-Limit')); + $this->assertSame((string) (3 - $i), (string) $response->headers->get('X-RateLimit-Remaining')); + } + + $this->expectException(TooManyRequestsHttpException::class); + $this->send(3); + } + + public function testEachClientHasItsOwnCounter(): void + { + $this->send(1, 1, '198.51.100.1'); + + $this->send(1, 1, '198.51.100.2'); + $this->assertSame(2, $this->handled); + + $this->expectException(TooManyRequestsHttpException::class); + $this->send(1, 1, '198.51.100.1'); + } + + public function testRoutesWithDifferentLimitsDoNotShareACounter(): void + { + for ($i = 0; $i < 10; $i++) { + $this->send(100); // a busy API route + } + + $this->send(5); // the first request to a strict login route + + $this->assertSame(11, $this->handled); + } + + public function testRoutesWithTheSameLimitShareACounterPerClient(): void + { + $this->send(2); + $this->send(2); + + $this->expectException(TooManyRequestsHttpException::class); + $this->send(2); + } + + public function testTheRetryTimeReportedDoesNotOutlastTheWindow(): void + { + $this->send(1, 1); + + try { + $this->send(1, 1); + $this->fail('Expected the second request to be blocked'); + } catch (TooManyRequestsHttpException $e) { + $retryAfter = (int) ($e->getHeaders()['Retry-After'] ?? 0); + } + + $this->assertGreaterThan(0, $retryAfter); + $this->assertLessThanOrEqual(60, $retryAfter); + } + + public function testTheResetTimeFollowsTheWindowAndDoesNotMoveBetweenRequests(): void + { + $first = (int) $this->send(5)->headers->get('X-RateLimit-Reset'); + sleep(1); + $second = (int) $this->send(5)->headers->get('X-RateLimit-Reset'); + + $this->assertEqualsWithDelta($first, $second, 1, 'later hits must not push the reset time back'); + $this->assertEqualsWithDelta(time() + 59, $second, 3); + } + + public function testAClientStartsAgainOnceTheWindowHasEnded(): void + { + $signature = (new \ReflectionMethod($this->middleware, 'resolveRequestSignature')) + ->invoke($this->middleware, new ThrottleClientRequest(), 2, 1); + + // Used up, but the reset time is already in the past. + $this->cache->set($signature, 2, 60); + $this->cache->set($signature . '_timer', time() - 1, 60); + + $response = $this->send(2); + + $this->assertSame(1, $this->handled, 'the request goes through'); + $this->assertSame('1', (string) $response->headers->get('X-RateLimit-Remaining'), 'and starts a new count'); + } + + public function testALimitWithAnAuthenticatedVariantUsesTheGuestValueWithoutAUser(): void + { + for ($i = 0; $i < 2; $i++) { + $this->send('2|10'); + } + + $this->expectException(TooManyRequestsHttpException::class); + $this->send('2|10'); + } +} diff --git a/tests/Requests/RateLimiterTest.php b/tests/Requests/RateLimiterTest.php index b4972933..ed8daae3 100644 --- a/tests/Requests/RateLimiterTest.php +++ b/tests/Requests/RateLimiterTest.php @@ -84,10 +84,9 @@ public function testAttemptWithExistingKeyUsesIncrementPath() ->with($key) ->willReturn(3); - $this->cache->expects($this->once()) - ->method('set') - ->with($key . '_timer', $now + $decaySeconds, $decaySeconds) - ->willReturn(true); + // The reset timer belongs to the window that opened with the first hit: it is + // not pushed back by later hits, or Retry-After would outlast the counter. + $this->cache->expects($this->never())->method('set'); $this->cache->expects($this->once()) ->method('get') @@ -242,10 +241,9 @@ public function testHitWithExistingKeyUsesIncrement() ->with($key) ->willReturn(4); - $this->cache->expects($this->once()) - ->method('set') - ->with($key . '_timer', $now + $decaySeconds, $decaySeconds) - ->willReturn(true); + // The reset timer belongs to the window that opened with the first hit: it is + // not pushed back by later hits, or Retry-After would outlast the counter. + $this->cache->expects($this->never())->method('set'); $this->assertSame(4, $this->limiter->hit($key, $decaySeconds)); }