diff --git a/src/auth/README.md b/src/auth/README.md index 72e9229d8..90403b5a9 100644 --- a/src/auth/README.md +++ b/src/auth/README.md @@ -1,4 +1,177 @@ Auth for Hypervel === -[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/auth) \ No newline at end of file +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/auth) + + + +## User Lookup Cache + +Optional cross-request cache for `EloquentUserProvider::retrieveById()`. Disabled by default. When enabled, each authenticated request can hit the cache instead of re-querying the database for the current user — a large win under Swoole where workers are long-lived and request volume is high. + +Only `retrieveById()` is cached. Credential and token lookups (`retrieveByCredentials`, `retrieveByToken`) are never cached for security — they must always see fresh data. + +### Enabling it + +Per-provider config in `config/auth.php`: + +```php +'providers' => [ + 'users' => [ + 'driver' => 'eloquent', + 'model' => App\Models\User::class, + 'cache' => [ + 'enabled' => env('AUTH_USERS_CACHE_ENABLED', false), + 'store' => env('AUTH_USERS_CACHE_STORE'), // null = default cache store + 'ttl' => env('AUTH_USERS_CACHE_TTL', 300), + 'prefix' => env('AUTH_USERS_CACHE_PREFIX', 'auth_users'), + ], + ], +], +``` + +Minimum env setup for single Redis node: + +```env +AUTH_USERS_CACHE_ENABLED=true +AUTH_USERS_CACHE_STORE=redis +``` + +High-scale recommended setup (`stack` with Swoole L1 + Redis L2): + +```env +AUTH_USERS_CACHE_ENABLED=true +AUTH_USERS_CACHE_STORE=stack +``` + +### Why microcaching helps at scale + +At high request volume, every authenticated request hits the user store. Without this cache, that's one Redis `GET` per request per worker. Even at modest RPS this is thousands of Redis round-trips per second just to hydrate `Auth::user()`. + +The recommended `stack = [swoole (3–5s) → redis]` topology ("microcaching") keeps hot lookups in each worker's Swoole Table for a few seconds. The same user making multiple requests in that window hits the L1 and skips the Redis round-trip entirely. L1 hit rates of 90%+ are typical for authenticated traffic with even a 3-second TTL, which adds up to: + +- Lower p99 latency — L1 reads are nanoseconds, Redis is hundreds of microseconds +- Smaller Redis tier — most of the load never reaches it +- Less network bandwidth — serialized user models stay inside the worker +- Brief Redis outage tolerance — L1 keeps serving authed requests for a few seconds if Redis goes down + +### Invalidation model + +Four layers, most-automatic to most-manual: + +1. **Provider writes** — `updateRememberToken()` and `rehashPasswordIfRequired()` both call `$user->save()`, which fires the `saved` model event. Invalidation is handled by the listener (layer 2), not by an explicit clear inside those methods. + +2. **Model events** — when caching is enabled for a provider, the provider registers `saved` and `deleted` listeners on the user model class. Any code path that modifies the user through Eloquent — `$user->save()`, `$user->update(...)`, `$user->delete()` — triggers cache invalidation. This covers controller updates, profile edits, admin changes. + +3. **Manual** — for writes that bypass Eloquent events (pivot table changes for roles/permissions, raw DB queries, mass `update()`, external processes), clear explicitly via `Auth::clearUserCache(...)` — see "Manual invalidation API" below. + +4. **TTL expiry** — even if active invalidation is missed, entries expire on their TTL and the next request fetches fresh data. + +**Within a node:** `SwooleStore` uses a Swoole Table in shared memory, so one `forget()` from any worker clears it for every worker on that node. + +**Across nodes:** only the shared tiers (`redis`, `database`) propagate. If you use `stack = [swoole, redis]`, invalidation clears the origin node's L1 + the shared Redis — but other nodes' Swoole L1s keep serving stale entries until their own L1 TTL expires. That bounded staleness window (a few seconds) is the microcaching trade-off. Cross-node pub/sub invalidation is out of scope for this feature; apps that need strict global consistency should skip the L1 tier. + +### Manual invalidation API + +```php +Auth::clearUserCache(mixed $identifier, ?string $guard = null): void +``` + +Call this after any write path that doesn't fire Eloquent model events — typical scenarios: + +- Pivot table writes for roles/permissions (`$user->roles()->attach(...)`, `detach`, `sync`) +- Raw query builder or PDO writes (`DB::table('users')->update(...)`) +- Mass updates (`User::query()->where(...)->update(...)` — Laravel's `Builder::update()` does not fire model events) +- Queue jobs, scheduled commands, or external services modifying users through non-Eloquent paths + +**Parameters:** + +- **`$identifier`** — the user's auth identifier (what `retrieveById()` expects). For the default Eloquent-based guard this is the user's primary key. Use the same value you'd pass to `Auth::loginUsingId()`. +- **`$guard`** — the guard name to clear against, or `null` to use the application's default guard. The method resolves that guard, finds its provider, and clears the cache entry for **that provider's model**. + +**How the model is chosen:** + +The cache key includes the provider's model FQCN, so `Auth::clearUserCache(42, 'web')` only clears `App\Models\User:42`, not `App\Models\Landlord:42`. The guard determines the provider; the provider determines the model. + +**Multi-guard / multi-model apps:** + +| Setup | Behaviour | +|---|---| +| One provider shared by multiple guards (e.g. `web`, `api`, `sanctum`, `jwt` all point at `users`) | One call with any of those guard names clears the single shared cache keyspace. Calling for each guard is redundant. | +| Different guards with different models (e.g. `web → User`, `admin → Admin`, `landlord → Landlord`) | You must call once per guard/model you want to invalidate. `Auth::clearUserCache(42)` with no guard name clears *only* the default guard's model — a landlord update that hits `Landlord:42` needs `Auth::clearUserCache(42, 'landlord')`. | +| Default guard omitted in a multi-guard setup | Clears for the default guard *only*, not all guards. In non-trivial deployments, always pass the guard name explicitly to avoid surprises. | + +**Tenant-aware resolver interaction:** + +If you've registered `EloquentUserProvider::resolveUserCacheKeyUsing(...)`, `clearUserCache()` uses the same resolver — so it clears the entry for the **current** tenant context, not every tenant's copy. To clear the same user across multiple tenants, call `clearUserCache()` once per tenant context. + +**No-ops:** + +- If the guard's provider is not an `EloquentUserProvider` (e.g. a custom `RequestGuard`), the call is silently ignored. +- If caching is disabled for the provider, the call is a no-op. + +### TTL guidance + +| Scenario | Guidance | +|---|---| +| Profile updates (name, avatar, preferences) | Default 300s is fine. Model events clear on save. | +| Password change | Irrelevant — session invalidation logs the user out. The cache miss on their next login is one-off. | +| Permission revocation (direct on user model) | Model events clear on save. | +| Permission revocation (via pivot table / bulk query) | Model events don't fire. Either call `Auth::clearUserCache($id)` explicitly, or accept the TTL staleness window. | +| High-security providers (financial/admin) | Use a tight L1 TTL (1–2s), skip the L1 tier, or disable caching entirely for that provider. | + +### Store selection guide + +| Store | Multi-node | Notes | +|---|---|---| +| `redis` | ✓ | Standard choice. Shared invalidation, fast, well-understood. | +| `database` | ✓ | Shared. Slower than Redis but still a major win over per-request hydration, especially with in-memory/unlogged Postgres tables. | +| `file` | ✗ | Node-local. Single-instance deployments only. | +| `swoole` | ✗ | Node-local, shared memory. Fastest single-node option; also the ideal L1 tier inside a `stack`. | +| `stack` | partial | Eventually consistent if a node-local tier (swoole/file) is layered above a shared tier (redis/database). See "Invalidation model" above. | + +Rejected drivers (throw on `enableCache`): + +- `session` — scoped to the current user's session; would cache user data inside one user's session. +- `array` — coroutine-local after the upcoming rewrite; nothing persists across requests. +- `null` — discards writes. +- `failover` — ambiguous fallback semantics; silently degrades onto an unsafe tier when the primary is down. + +Stack composition caveat: only the outer store is validated. A stack built with an unsupported inner tier (e.g. `[array, redis]`) won't be caught — pick sensible tiers yourself. + +### Tenant-aware cache keys + +Default cache key format is `{prefix}:{fqcn}:{identifier}` — e.g. `auth_users:App\Models\User:42`. The fully-qualified model class name is always included so providers using different user models never collide. + +For multi-tenant apps where the same user ID resolves to different rows per tenant (tenant global scopes, shared user tables), register a global resolver in a service provider's `boot()`: + +```php +use Hypervel\Auth\EloquentUserProvider; + +public function boot(): void +{ + EloquentUserProvider::resolveUserCacheKeyUsing( + fn (mixed $identifier) => tenantId() . ':' . $identifier, + ); +} +``` + +Produces keys like `auth_users:App\Models\User:5:42` (prefix, FQCN, tenant 5, user 42). + +**Why a static callback, not a config closure?** Config files are evaluated once at boot in Swoole. A closure calling `tenantId()` in the config would capture the boot-time tenant (likely null), not the per-request tenant. The static resolver callback runs fresh on each `retrieveById()`, reading the current coroutine's context. + +### Gotchas + +- **`withQuery()` caches the first-seen shape.** If the provider has a `withQuery()` callback that eager-loads relations, the first uncached call caches the result including those relations. Every subsequent hit returns the same loaded relations. This is usually what you want for auth. +- **Bulk updates bypass Eloquent events.** `User::query()->update([...])`, raw `DB::update(...)`, pivot inserts/deletes via `attach/detach` — none of these fire model events. Use `Auth::clearUserCache($id)` after such writes or accept TTL staleness. +- **The whitelist only checks the outer store.** `stack = [array, redis]` passes the check because the outer class is `StackStore`. Responsibility for sensible tier selection is yours. + +### Threat model + +For auth-sensitive contexts (admin panels, financial actions), consider: + +- Shorter L1 TTL (1–2s) — still absorbs bursts, narrower staleness window +- Skip L1 entirely — use plain `redis` instead of `stack` +- Disable caching for that provider — set `enabled => false` for the specific guard's provider + +Password changes and session revocation are not staleness-sensitive — session invalidation already logs the user out, so the auth cache's state becomes moot on the user's next request. \ No newline at end of file diff --git a/src/auth/src/AuthManager.php b/src/auth/src/AuthManager.php index 15acc40c3..bc6e61ddb 100755 --- a/src/auth/src/AuthManager.php +++ b/src/auth/src/AuthManager.php @@ -231,6 +231,34 @@ public function resolveUsersUsing(Closure $userResolver): static return $this; } + /** + * Clear the cached user for the given identifier. + * + * Uses the specified guard's existing provider instance to avoid + * creating throwaway provider objects. If the guard doesn't expose + * getProvider() (custom guards that don't use GuardHelpers), or the + * provider is not an EloquentUserProvider, or caching is disabled, + * this is a no-op. + */ + public function clearUserCache(mixed $identifier, ?string $guard = null): void + { + $guardInstance = $this->guard($guard); + + // getProvider() lives on the GuardHelpers trait, not the Guard + // contract. Custom guards (via extend()/viaRequest()) may not use + // the trait — without this check, __call forwarding would throw + // BadMethodCallException at runtime. + if (! method_exists($guardInstance, 'getProvider')) { + return; + } + + $provider = $guardInstance->getProvider(); /* @phpstan-ignore method.notFound (getProvider() is on GuardHelpers trait, not the Guard contract) */ + + if ($provider instanceof EloquentUserProvider) { + $provider->clearUserCache($identifier); + } + } + /** * Register a custom driver creator Closure. */ diff --git a/src/auth/src/CreatesUserProviders.php b/src/auth/src/CreatesUserProviders.php index 9cff256c3..39204f1c6 100644 --- a/src/auth/src/CreatesUserProviders.php +++ b/src/auth/src/CreatesUserProviders.php @@ -71,7 +71,17 @@ protected function createDatabaseProvider(array $config): DatabaseUserProvider */ protected function createEloquentProvider(array $config): EloquentUserProvider { - return new EloquentUserProvider($this->app['hash'], $config['model']); + $provider = new EloquentUserProvider($this->app['hash'], $config['model']); + + if (! empty($config['cache']['enabled'])) { + $provider->enableCache( + $config['cache']['store'] ?? null, + (int) ($config['cache']['ttl'] ?? 300), + $config['cache']['prefix'] ?? 'auth_users', + ); + } + + return $provider; } /** diff --git a/src/auth/src/EloquentUserProvider.php b/src/auth/src/EloquentUserProvider.php index a7c2f4b5c..8b6723d4c 100755 --- a/src/auth/src/EloquentUserProvider.php +++ b/src/auth/src/EloquentUserProvider.php @@ -5,16 +5,80 @@ namespace Hypervel\Auth; use Closure; +use Hypervel\Cache\DatabaseStore; +use Hypervel\Cache\FileStore; +use Hypervel\Cache\RedisStore; +use Hypervel\Cache\StackStore; +use Hypervel\Cache\SwooleStore; +use Hypervel\Container\Container; use Hypervel\Contracts\Auth\Authenticatable as UserContract; use Hypervel\Contracts\Auth\UserProvider; +use Hypervel\Contracts\Cache\Repository as CacheRepository; use Hypervel\Contracts\Hashing\Hasher as HasherContract; use Hypervel\Contracts\Support\Arrayable; use Hypervel\Database\Eloquent\Builder; use Hypervel\Database\Eloquent\Model; +use InvalidArgumentException; use SensitiveParameter; class EloquentUserProvider implements UserProvider { + /** + * Sentinel value cached for user IDs that don't exist. + * + * Must be serializable (not an object) because it's stored in an + * external cache store. + * + * @var array{__auth_null_sentinel: true} + */ + protected const NULL_SENTINEL = ['__auth_null_sentinel' => true]; + + /** + * Whitelist of cache store classes supported for auth user caching. + * + * Checked with instanceof in ensureSupportedAuthCacheStore(), so + * legitimate subclasses of these stores are also accepted. + * + * @var list + */ + private const array SUPPORTED_AUTH_CACHE_STORES = [ + RedisStore::class, + DatabaseStore::class, + FileStore::class, + SwooleStore::class, + StackStore::class, + ]; + + /** + * The callback used to build the identifier segment of cache keys. + * + * Global for all cached Eloquent user providers. Set once in a service + * provider's boot() method. Evaluated at call time so it can read + * per-request context (e.g., tenant ID from Context). + * + * @var null|(Closure(mixed): string) + */ + protected static ?Closure $cacheKeyResolver = null; + + /** + * Registry of cache descriptors per model class. + * + * Each entry is keyed by a deterministic descriptor hash, holding + * enough information to rebuild the exact cache key on invalidation + * (storeName, prefix, modelSegment) without retaining a reference + * to any provider instance. Duplicate configs collapse on insert. + * + * @var array> + */ + protected static array $cachedProviders = []; + + /** + * Whether model event listeners have been registered for a model class. + * + * @var array + */ + protected static array $cacheEventsRegistered = []; + /** * The callback that may modify the user retrieval queries. * @@ -22,6 +86,37 @@ class EloquentUserProvider implements UserProvider */ protected ?Closure $queryCallback = null; + /** + * The cache store for user lookups. + */ + protected ?CacheRepository $cache = null; + + /** + * The cache store name (null = default store). + * + * Stored so the descriptor registry can re-resolve the store by name + * on invalidation without holding a strong reference to this provider. + */ + protected ?string $cacheStoreName = null; + + /** + * The cache TTL in seconds. + */ + protected int $cacheTtl = 300; + + /** + * The cache key prefix. + */ + protected string $cachePrefix = 'auth_users'; + + /** + * Memoized model key segment (the fully qualified class name). + * + * Computed once in enableCache() and reused on every retrieveById() + * to avoid per-request string work on the hot path. + */ + protected string $modelSegment = ''; + /** * Create a new database user provider. * @@ -37,6 +132,33 @@ public function __construct( * Retrieve a user by their unique identifier. */ public function retrieveById(mixed $identifier): ?UserContract + { + if (! $this->cache) { + return $this->fetchUserById($identifier); + } + + $key = $this->buildCacheKey($identifier); + $cached = $this->cache->get($key); + + if ($cached === self::NULL_SENTINEL) { + return null; + } + + if ($cached !== null) { + return $cached; + } + + $user = $this->fetchUserById($identifier); + + $this->cache->put($key, $user ?? self::NULL_SENTINEL, $this->cacheTtl); + + return $user; + } + + /** + * Fetch a user by ID from the database. + */ + protected function fetchUserById(mixed $identifier): ?UserContract { $model = $this->createModel(); @@ -83,6 +205,9 @@ public function updateRememberToken(UserContract $user, #[SensitiveParameter] st $user->save(); $user->timestamps = $timestamps; + + // Cache invalidation (when caching is enabled) is handled by the + // saved model event listener — no explicit clear needed here. } /** @@ -150,6 +275,199 @@ public function rehashPasswordIfRequired(UserContract $user, #[SensitiveParamete ])->save(); } + /** + * Enable cross-request caching for user lookups. + * + * Accepts a store name (or null for the default store) rather than a + * pre-resolved repository so the descriptor registry can re-resolve + * by name on invalidation and avoid holding strong references. + * + * A null or empty-string prefix is normalized to the feature default + * ('auth_users') so misconfiguration does not create hard-to-read keys + * with a leading colon. + * + * The store is validated against the supported-drivers whitelist BEFORE + * any instance state is mutated, so a rejected store leaves the provider + * in its prior (uncached) state and does not register a descriptor or + * model event listeners. + * + * @throws InvalidArgumentException when the resolved store is not supported + */ + public function enableCache(?string $storeName, int $ttl = 300, ?string $prefix = 'auth_users'): static + { + $cache = Container::getInstance()->make('cache')->store($storeName); + $this->ensureSupportedAuthCacheStore($cache); + + $this->cache = $cache; + $this->cacheStoreName = $storeName; + $this->cacheTtl = $ttl; + $this->cachePrefix = $prefix === null || $prefix === '' ? 'auth_users' : $prefix; + $this->modelSegment = $this->model; + + $this->registerCacheInvalidationEvents(); + + return $this; + } + + /** + * Determine if cross-request user caching is enabled. + */ + public function isCacheEnabled(): bool + { + return $this->cache !== null; + } + + /** + * Clear the cached user for the given identifier. + * + * Uses the same key resolver as retrieveById(), so it respects + * tenant context and custom key callbacks. + */ + public function clearUserCache(mixed $identifier): void + { + $this->cache?->forget($this->buildCacheKey($identifier)); + } + + /** + * Set the cache key resolver for all cached Eloquent user providers. + * + * The callback receives the user identifier and should return a string + * that uniquely identifies the user within the current context (e.g., + * including tenant ID for multi-tenant apps). Called once in a service + * provider's boot() method — the closure is evaluated fresh on each + * retrieveById() call so per-request context like tenant ID is current. + * + * The fully qualified model class name is always included in the key + * automatically. The resolver only controls the identifier segment. + * + * @param Closure(mixed): string $callback + */ + public static function resolveUserCacheKeyUsing(Closure $callback): void + { + static::$cacheKeyResolver = $callback; + } + + /** + * Flush static state for test isolation. + */ + public static function flushState(): void + { + static::$cacheKeyResolver = null; + static::$cachedProviders = []; + static::$cacheEventsRegistered = []; + } + + /** + * Ensure the configured cache store is supported for auth user caching. + * + * Throws when the resolved Store is not an instance of one of the + * whitelisted classes. Called from enableCache() before any instance + * state is mutated, so a rejected store leaves the provider in its + * prior uncached state. Uses instanceof so legitimate subclasses of + * supported stores are accepted. + * + * @throws InvalidArgumentException + */ + protected function ensureSupportedAuthCacheStore(CacheRepository $cache): void + { + $store = $cache->getStore(); + + foreach (self::SUPPORTED_AUTH_CACHE_STORES as $supported) { + if ($store instanceof $supported) { + return; + } + } + + throw new InvalidArgumentException(sprintf( + 'Auth user caching does not support cache store [%s]. See the auth cache documentation for supported stores.', + $store::class + )); + } + + /** + * Build the cache key for a user identifier. + * + * Always includes the fully qualified model class name (memoized in + * enableCache()) so providers using different models never collide — + * even when two models share a basename across namespaces. The custom + * resolver (if set) controls the identifier segment only. + */ + protected function buildCacheKey(mixed $identifier): string + { + $identifierSegment = static::$cacheKeyResolver + ? (static::$cacheKeyResolver)($identifier) + : (string) $identifier; + + return $this->cachePrefix . ':' . $this->modelSegment . ':' . $identifierSegment; + } + + /** + * Register this provider's cache descriptor and set up model event + * listeners for automatic cache invalidation. + * + * Uses a descriptor-based registry: each (storeName, prefix, modelSegment) + * triple is stored under a deterministic hash so duplicate configs + * collapse. On save/delete, the listener iterates descriptors for the + * model class, re-resolves each store by name via the cache manager, + * rebuilds the key using the current global resolver callback, and + * calls forget(). Nothing holds a reference to a provider instance — + * safe against forgetGuards() + re-resolve cycles under Swoole. + * + * Event listener registration is guarded by the model's dispatcher + * being non-null — HasEvents::registerModelEvent() silently no-ops + * when the dispatcher isn't set, so we only mark the class as + * registered AFTER a successful attempt, leaving a retry window on + * the next enableCache() call. + */ + protected function registerCacheInvalidationEvents(): void + { + $modelClass = $this->model; + + // Insert or replace the descriptor — duplicate configs collapse. + $descriptorKey = md5( + ($this->cacheStoreName ?? '') . '|' . $this->cachePrefix . '|' . $this->modelSegment + ); + + static::$cachedProviders[$modelClass][$descriptorKey] = [ + 'storeName' => $this->cacheStoreName, + 'prefix' => $this->cachePrefix, + 'modelSegment' => $this->modelSegment, + ]; + + if (isset(static::$cacheEventsRegistered[$modelClass])) { + return; + } + + // registerModelEvent() silently no-ops if the dispatcher isn't set. + // Use the public getEventDispatcher() since Model::$dispatcher is + // protected. Inside withoutEvents() this returns a NullDispatcher + // wrapping the real one — non-null, so we proceed, and the listener + // still attaches to the real dispatcher underneath. + if ($modelClass::getEventDispatcher() === null) { + return; + } + + $invalidate = static function (UserContract $user): void { + $id = $user->getAuthIdentifier(); + $identifierSegment = static::$cacheKeyResolver + ? (static::$cacheKeyResolver)($id) + : (string) $id; + + $cacheManager = Container::getInstance()->make('cache'); + + foreach (static::$cachedProviders[$user::class] ?? [] as $descriptor) { + $cacheManager + ->store($descriptor['storeName']) + ->forget($descriptor['prefix'] . ':' . $descriptor['modelSegment'] . ':' . $identifierSegment); + } + }; + + $modelClass::saved($invalidate); + $modelClass::deleted($invalidate); + + static::$cacheEventsRegistered[$modelClass] = true; + } + /** * Get a new query builder for the model instance. */ diff --git a/src/foundation/config/auth.php b/src/foundation/config/auth.php index fd65ce36d..a474e128c 100644 --- a/src/foundation/config/auth.php +++ b/src/foundation/config/auth.php @@ -73,6 +73,45 @@ 'users' => [ 'driver' => 'eloquent', 'model' => env('AUTH_MODEL', App\Models\User::class), // @phpstan-ignore class.notFound + + /* + |------------------------------------------------------------------ + | User Lookup Cache (opt-in, Eloquent provider only) + |------------------------------------------------------------------ + | + | Caches retrieveById() lookups across requests. Disabled by + | default. Credential and token lookups are never cached + | (security). + | + | Supported stores: 'redis', 'database', 'file', 'swoole', 'stack'. + | Any other driver ('array', 'null', 'session', 'failover') is + | rejected. + | + | Cross-node behaviour: + | - 'redis' / 'database': fully shared — invalidation is global. + | - 'file' / 'swoole': node-local, no cross-node invalidation + | (single-instance deployments only). + | - 'stack' with a node-local upper tier (e.g. [swoole, redis]): + | eventually consistent — the shared lower tier clears + | globally, but each node's L1 serves its stale entry until + | the L1 TTL expires. This is the microcaching trade-off. + | + | High-scale: the recommended topology is a 'stack' cache with + | 'swoole' as L1 (3–5s) and 'redis' as L2 — the microcaching + | pattern eliminates the majority of Redis round-trips for + | authed requests at high concurrency. See the auth caching + | documentation for the full explanation. + | + | Caveat: only the outer store is validated. A stack with an + | unsupported inner tier (e.g. [array, redis]) won't be caught. + | + */ + 'cache' => [ + 'enabled' => env('AUTH_USERS_CACHE_ENABLED', false), + 'store' => env('AUTH_USERS_CACHE_STORE'), + 'ttl' => env('AUTH_USERS_CACHE_TTL', 300), + 'prefix' => env('AUTH_USERS_CACHE_PREFIX', 'auth_users'), + ], ], ], diff --git a/src/support/src/Facades/Auth.php b/src/support/src/Facades/Auth.php index 571d707c0..94bd63931 100644 --- a/src/support/src/Facades/Auth.php +++ b/src/support/src/Facades/Auth.php @@ -14,6 +14,7 @@ * @method static \Hypervel\Auth\AuthManager viaRequest(string $driver, callable $callback) * @method static \Closure userResolver() * @method static \Hypervel\Auth\AuthManager resolveUsersUsing(\Closure $userResolver) + * @method static void clearUserCache(mixed $identifier, ?string $guard = null) * @method static \Hypervel\Auth\AuthManager extend(string $driver, \Closure $callback) * @method static \Hypervel\Auth\AuthManager provider(string $name, \Closure $callback) * @method static bool hasResolvedGuards() diff --git a/src/testbench/hypervel/config/auth.php b/src/testbench/hypervel/config/auth.php index 011eca69d..ab2a0a783 100644 --- a/src/testbench/hypervel/config/auth.php +++ b/src/testbench/hypervel/config/auth.php @@ -7,6 +7,45 @@ 'users' => [ 'driver' => 'eloquent', 'model' => Hypervel\Foundation\Auth\User::class, + + /* + |------------------------------------------------------------------ + | User Lookup Cache (opt-in, Eloquent provider only) + |------------------------------------------------------------------ + | + | Caches retrieveById() lookups across requests. Disabled by + | default. Credential and token lookups are never cached + | (security). + | + | Supported stores: 'redis', 'database', 'file', 'swoole', 'stack'. + | Any other driver ('array', 'null', 'session', 'failover') is + | rejected. + | + | Cross-node behaviour: + | - 'redis' / 'database': fully shared — invalidation is global. + | - 'file' / 'swoole': node-local, no cross-node invalidation + | (single-instance deployments only). + | - 'stack' with a node-local upper tier (e.g. [swoole, redis]): + | eventually consistent — the shared lower tier clears + | globally, but each node's L1 serves its stale entry until + | the L1 TTL expires. This is the microcaching trade-off. + | + | High-scale: the recommended topology is a 'stack' cache with + | 'swoole' as L1 (3–5s) and 'redis' as L2 — the microcaching + | pattern eliminates the majority of Redis round-trips for + | authed requests at high concurrency. See the auth caching + | documentation for the full explanation. + | + | Caveat: only the outer store is validated. A stack with an + | unsupported inner tier (e.g. [array, redis]) won't be caught. + | + */ + 'cache' => [ + 'enabled' => env('AUTH_USERS_CACHE_ENABLED', false), + 'store' => env('AUTH_USERS_CACHE_STORE'), + 'ttl' => env('AUTH_USERS_CACHE_TTL', 300), + 'prefix' => env('AUTH_USERS_CACHE_PREFIX', 'auth_users'), + ], ], ], ]; diff --git a/tests/AfterEachTestSubscriber.php b/tests/AfterEachTestSubscriber.php index bcc4931f3..83f019de1 100644 --- a/tests/AfterEachTestSubscriber.php +++ b/tests/AfterEachTestSubscriber.php @@ -31,6 +31,7 @@ public function notify(Finished $event): void \Hypervel\ApiClient\PendingRequest::flushCache(); \Hypervel\Auth\Access\Gate::flushState(); \Hypervel\Auth\AuthenticationException::flushState(); + \Hypervel\Auth\EloquentUserProvider::flushState(); \Hypervel\Auth\Middleware\Authenticate::flushState(); \Hypervel\Auth\Middleware\RedirectIfAuthenticated::flushState(); \Hypervel\Auth\Notifications\ResetPassword::flushState(); diff --git a/tests/Auth/AuthEloquentUserProviderCacheTest.php b/tests/Auth/AuthEloquentUserProviderCacheTest.php new file mode 100644 index 000000000..0bc2c7c9f --- /dev/null +++ b/tests/Auth/AuthEloquentUserProviderCacheTest.php @@ -0,0 +1,468 @@ +cacheManager = m::mock(CacheManager::class); + $container->instance('cache', $this->cacheManager); + } + + // ------------------------------------------------------------------ + // Cache disabled (default behaviour) + // ------------------------------------------------------------------ + + public function testRetrieveByIdWithoutCacheDoesNotTouchCache() + { + $this->cacheManager->shouldNotReceive('store'); + + $user = m::mock(Authenticatable::class); + $provider = $this->providerExpectingDbFetch($user, 42); + + $this->assertSame($user, $provider->retrieveById(42)); + } + + // ------------------------------------------------------------------ + // Cache enabled — basic operation + // ------------------------------------------------------------------ + + public function testRetrieveByIdCachesMissedLookup() + { + $repo = $this->stubCache(RedisStore::class); + $user = m::mock(Authenticatable::class); + $key = $this->buildDefaultKey(42); + + $repo->shouldReceive('get')->once()->with($key)->andReturn(null); + $repo->shouldReceive('put')->once()->with($key, $user, 300)->andReturn(true); + + $provider = $this->providerExpectingDbFetch($user, 42); + $provider->enableCache(null); + + $this->assertSame($user, $provider->retrieveById(42)); + } + + public function testRetrieveByIdReturnsCachedUser() + { + $repo = $this->stubCache(RedisStore::class); + $user = m::mock(Authenticatable::class); + + $repo->shouldReceive('get')->once()->with($this->buildDefaultKey(42))->andReturn($user); + + $provider = $this->providerWithoutDbFetch(); + $provider->enableCache(null); + + $this->assertSame($user, $provider->retrieveById(42)); + } + + public function testRetrieveByIdCachesNullSentinelForMissingUser() + { + $repo = $this->stubCache(RedisStore::class); + $sentinel = ['__auth_null_sentinel' => true]; + $key = $this->buildDefaultKey(999); + + $repo->shouldReceive('get')->once()->with($key)->andReturn(null); + $repo->shouldReceive('put')->once()->with($key, $sentinel, 300)->andReturn(true); + + $provider = $this->providerExpectingDbFetch(null, 999); + $provider->enableCache(null); + + $this->assertNull($provider->retrieveById(999)); + } + + public function testRetrieveByIdReturnsNullForCachedSentinel() + { + $repo = $this->stubCache(RedisStore::class); + $sentinel = ['__auth_null_sentinel' => true]; + + $repo->shouldReceive('get')->once()->with($this->buildDefaultKey(999))->andReturn($sentinel); + + $provider = $this->providerWithoutDbFetch(); + $provider->enableCache(null); + + $this->assertNull($provider->retrieveById(999)); + } + + public function testRetrieveByCredentialsIsNeverCached() + { + $repo = $this->stubCache(RedisStore::class); + $repo->shouldNotReceive('get'); + $repo->shouldNotReceive('put'); + + $expectedUser = m::mock(Authenticatable::class); + $model = m::mock(Model::class); + $builder = m::mock(Builder::class); + $model->shouldReceive('newQuery')->once()->andReturn($builder); + $builder->shouldReceive('where')->once()->with('username', 'u'); + $builder->shouldReceive('first')->once()->andReturn($expectedUser); + + $provider = $this->providerMock(); + $provider->expects($this->once())->method('createModel')->willReturn($model); + $provider->enableCache(null); + + $this->assertSame($expectedUser, $provider->retrieveByCredentials(['username' => 'u'])); + } + + public function testRetrieveByTokenIsNeverCached() + { + $repo = $this->stubCache(RedisStore::class); + $repo->shouldNotReceive('get'); + $repo->shouldNotReceive('put'); + + $user = m::mock(Authenticatable::class); + $user->shouldReceive('getRememberToken')->once()->andReturn('tok'); + $model = m::mock(Model::class); + $builder = m::mock(Builder::class); + $model->shouldReceive('newQuery')->once()->andReturn($builder); + $model->shouldReceive('getAuthIdentifierName')->once()->andReturn('id'); + $builder->shouldReceive('where')->once()->with('id', 1)->andReturn($builder); + $builder->shouldReceive('first')->once()->andReturn($user); + + $provider = $this->providerMock(); + $provider->expects($this->once())->method('createModel')->willReturn($model); + $provider->enableCache(null); + + $this->assertSame($user, $provider->retrieveByToken(1, 'tok')); + } + + // ------------------------------------------------------------------ + // Cache key resolution + // ------------------------------------------------------------------ + + public function testDefaultCacheKeyIncludesFqcnAndIdentifier() + { + $repo = $this->stubCache(RedisStore::class); + $expectedKey = self::DEFAULT_KEY_PREFIX . ':' . self::MODEL . ':42'; + + $repo->shouldReceive('get')->once()->with($expectedKey)->andReturn(m::mock(Authenticatable::class)); + + $provider = $this->providerWithoutDbFetch(); + $provider->enableCache(null); + + $provider->retrieveById(42); + } + + public function testEnableCacheNormalizesBlankPrefixToDefault() + { + // Two enableCache() calls with blank prefixes (null and '') should both + // produce keys using the 'auth_users' default. We set up two distinct + // repositories returned in sequence from store(null). + $repo1 = m::mock(CacheRepository::class); + $repo1->shouldReceive('getStore')->andReturn(m::mock(RedisStore::class)); + $repo2 = m::mock(CacheRepository::class); + $repo2->shouldReceive('getStore')->andReturn(m::mock(RedisStore::class)); + + $this->cacheManager->shouldReceive('store')->with(null) + ->andReturn($repo1, $repo2); + + $expectedKey = self::DEFAULT_KEY_PREFIX . ':' . self::MODEL . ':42'; + $repo1->shouldReceive('get')->once()->with($expectedKey)->andReturn(m::mock(Authenticatable::class)); + $repo2->shouldReceive('get')->once()->with($expectedKey)->andReturn(m::mock(Authenticatable::class)); + + $providerNull = $this->providerWithoutDbFetch(); + $providerNull->enableCache(null, 300, null); + $providerNull->retrieveById(42); + + $providerEmpty = $this->providerWithoutDbFetch(); + $providerEmpty->enableCache(null, 300, ''); + $providerEmpty->retrieveById(42); + } + + public function testCustomCacheKeyResolverIsUsed() + { + EloquentUserProvider::resolveUserCacheKeyUsing(fn (mixed $id): string => "tenant5:{$id}"); + + $repo = $this->stubCache(RedisStore::class); + $expectedKey = self::DEFAULT_KEY_PREFIX . ':' . self::MODEL . ':tenant5:42'; + $repo->shouldReceive('get')->once()->with($expectedKey)->andReturn(m::mock(Authenticatable::class)); + + $provider = $this->providerWithoutDbFetch(); + $provider->enableCache(null); + + $provider->retrieveById(42); + } + + public function testCustomCacheKeyResolverReceivesIdentifier() + { + $received = null; + EloquentUserProvider::resolveUserCacheKeyUsing(function (mixed $id) use (&$received): string { + $received = $id; + + return (string) $id; + }); + + $repo = $this->stubCache(RedisStore::class); + $repo->shouldReceive('get')->once()->andReturn(m::mock(Authenticatable::class)); + + $provider = $this->providerWithoutDbFetch(); + $provider->enableCache(null); + + $provider->retrieveById(42); + + $this->assertSame(42, $received); + } + + public function testCacheKeyAlwaysIncludesFqcnEvenWithCustomResolver() + { + EloquentUserProvider::resolveUserCacheKeyUsing(fn (mixed $id): string => "wrapper:{$id}"); + + $capturedKey = null; + $repo = $this->stubCache(RedisStore::class); + $repo->shouldReceive('get')->once()->andReturnUsing(function (string $key) use (&$capturedKey) { + $capturedKey = $key; + + return m::mock(Authenticatable::class); + }); + + $provider = $this->providerWithoutDbFetch(); + $provider->enableCache(null); + + $provider->retrieveById(42); + + $this->assertStringContainsString(self::MODEL, $capturedKey); + } + + // ------------------------------------------------------------------ + // Supported-store whitelist + // ------------------------------------------------------------------ + + #[DataProvider('supportedStoreProvider')] + public function testEnableCacheAcceptsSupportedStores(string $storeClass) + { + $this->stubCache($storeClass); + + $provider = $this->providerWithoutDbFetch(); + $provider->enableCache(null); + + $this->assertTrue($provider->isCacheEnabled()); + } + + // ------------------------------------------------------------------ + // Data providers + // ------------------------------------------------------------------ + + public static function supportedStoreProvider(): iterable + { + yield 'Redis' => [RedisStore::class]; + yield 'Database' => [DatabaseStore::class]; + yield 'File' => [FileStore::class]; + yield 'Swoole' => [SwooleStore::class]; + yield 'Stack' => [StackStore::class]; + } + + #[DataProvider('unsupportedStoreProvider')] + public function testEnableCacheRejectsUnsupportedStores(string $storeClass) + { + $this->stubCache($storeClass); + + $provider = $this->providerWithoutDbFetch(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/does not support cache store/'); + + $provider->enableCache(null); + } + + public static function unsupportedStoreProvider(): iterable + { + yield 'Array' => [ArrayStore::class]; + yield 'Null' => [NullStore::class]; + yield 'Session' => [SessionStore::class]; + yield 'Failover' => [FailoverStore::class]; + } + + public function testEnableCacheLeavesProviderInDisabledStateWhenValidationFails() + { + $this->stubCache(ArrayStore::class); + + $user = m::mock(Authenticatable::class); + $provider = $this->providerExpectingDbFetch($user, 42); + + try { + $provider->enableCache(null); + $this->fail('Expected InvalidArgumentException'); + } catch (InvalidArgumentException) { + // expected + } + + $this->assertFalse($provider->isCacheEnabled()); + + $reflection = new ReflectionClass(EloquentUserProvider::class); + $descriptors = $reflection->getStaticPropertyValue('cachedProviders'); + $registered = $reflection->getStaticPropertyValue('cacheEventsRegistered'); + $this->assertArrayNotHasKey(self::MODEL, $descriptors); + $this->assertArrayNotHasKey(self::MODEL, $registered); + + // Provider still falls through to the DB path on retrieveById. + $this->assertSame($user, $provider->retrieveById(42)); + } + + // ------------------------------------------------------------------ + // Manual invalidation + // ------------------------------------------------------------------ + + public function testClearUserCacheRemovesCachedEntry() + { + $repo = $this->stubCache(RedisStore::class); + $repo->shouldReceive('forget')->once()->with($this->buildDefaultKey(42))->andReturn(true); + + $provider = $this->providerWithoutDbFetch(); + $provider->enableCache(null); + + $provider->clearUserCache(42); + } + + public function testClearUserCacheUsesCustomKeyResolver() + { + EloquentUserProvider::resolveUserCacheKeyUsing(fn (mixed $id): string => "tenant:{$id}"); + + $repo = $this->stubCache(RedisStore::class); + $expectedKey = self::DEFAULT_KEY_PREFIX . ':' . self::MODEL . ':tenant:42'; + $repo->shouldReceive('forget')->once()->with($expectedKey)->andReturn(true); + + $provider = $this->providerWithoutDbFetch(); + $provider->enableCache(null); + + $provider->clearUserCache(42); + } + + public function testClearUserCacheIsNoOpWhenCacheDisabled() + { + $this->cacheManager->shouldNotReceive('store'); + + $provider = $this->providerWithoutDbFetch(); + + // No enableCache() — cache is null; clearUserCache must not blow up. + $provider->clearUserCache(42); + + $this->assertFalse($provider->isCacheEnabled()); + } + + // ------------------------------------------------------------------ + // flushState + // ------------------------------------------------------------------ + + public function testFlushStateClearsAllStaticState() + { + EloquentUserProvider::resolveUserCacheKeyUsing(fn (mixed $id): string => (string) $id); + + $this->stubCache(RedisStore::class); + $provider = $this->providerWithoutDbFetch(); + $provider->enableCache(null); + + $reflection = new ReflectionClass(EloquentUserProvider::class); + $this->assertNotSame([], $reflection->getStaticPropertyValue('cachedProviders')); + + EloquentUserProvider::flushState(); + + $this->assertNull($reflection->getStaticPropertyValue('cacheKeyResolver')); + $this->assertSame([], $reflection->getStaticPropertyValue('cachedProviders')); + $this->assertSame([], $reflection->getStaticPropertyValue('cacheEventsRegistered')); + } + + // ------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------ + + protected function providerMock(): EloquentUserProvider&MockObject + { + $hasher = m::mock(Hasher::class); + + return $this->getMockBuilder(EloquentUserProvider::class) + ->onlyMethods(['createModel']) + ->setConstructorArgs([$hasher, self::MODEL]) + ->getMock(); + } + + /** + * Provider whose createModel() returns a mock Model + Builder chain that + * yields $user for retrieveById($id). + */ + protected function providerExpectingDbFetch(?Authenticatable $user, mixed $id): EloquentUserProvider&MockObject + { + $model = m::mock(Model::class); + $builder = m::mock(Builder::class); + $model->shouldReceive('newQuery')->once()->andReturn($builder); + $model->shouldReceive('getAuthIdentifierName')->once()->andReturn('id'); + $builder->shouldReceive('where')->once()->with('id', $id)->andReturn($builder); + $builder->shouldReceive('first')->once()->andReturn($user); + + $provider = $this->providerMock(); + $provider->expects($this->once())->method('createModel')->willReturn($model); + + return $provider; + } + + /** + * Provider configured so that createModel() must never be called + * (cache-hit / cache-disabled paths). + */ + protected function providerWithoutDbFetch(): EloquentUserProvider&MockObject + { + $provider = $this->providerMock(); + $provider->expects($this->never())->method('createModel'); + + return $provider; + } + + /** + * Stub the cache manager to return a mocked repository backed by an + * instance of $storeClass. Returns the repository mock so tests can + * set further expectations on it. + */ + protected function stubCache(string $storeClass, ?string $name = null): MockInterface + { + $store = m::mock($storeClass); + $repo = m::mock(CacheRepository::class); + $repo->shouldReceive('getStore')->andReturn($store); + $this->cacheManager->shouldReceive('store')->with($name)->andReturn($repo); + + return $repo; + } + + protected function buildDefaultKey(mixed $identifier): string + { + return self::DEFAULT_KEY_PREFIX . ':' . self::MODEL . ':' . $identifier; + } +} + +class EloquentCacheProviderUserStub extends Model +{ +} diff --git a/tests/Auth/AuthManagerTest.php b/tests/Auth/AuthManagerTest.php index 36ac02195..dc91056f3 100644 --- a/tests/Auth/AuthManagerTest.php +++ b/tests/Auth/AuthManagerTest.php @@ -7,20 +7,26 @@ use Closure; use Hypervel\Auth\AuthManager; use Hypervel\Auth\DatabaseUserProvider; +use Hypervel\Auth\EloquentUserProvider; use Hypervel\Auth\RequestGuard; +use Hypervel\Cache\CacheManager; +use Hypervel\Cache\RedisStore; use Hypervel\Config\Repository; use Hypervel\Container\Container; use Hypervel\Context\CoroutineContext; use Hypervel\Contracts\Auth\Authenticatable; use Hypervel\Contracts\Auth\Guard; use Hypervel\Contracts\Auth\UserProvider; +use Hypervel\Contracts\Cache\Repository as CacheRepository; use Hypervel\Contracts\Hashing\Hasher as HashContract; use Hypervel\Coroutine\Coroutine; use Hypervel\Database\ConnectionInterface; +use Hypervel\Foundation\Auth\User as FoundationUser; use Hypervel\Http\Request; use Hypervel\Tests\TestCase; use InvalidArgumentException; use Mockery as m; +use ReflectionClass; class AuthManagerTest extends TestCase { @@ -327,6 +333,142 @@ public function testMagicCallDelegatesToDefaultGuard() $this->assertTrue($manager->check()); } + public function testClearUserCacheIsNoOpForCustomGuardWithoutGetProvider() + { + $manager = new AuthManager($container = $this->getContainer([ + 'guards' => [ + 'api' => ['driver' => 'custom'], + ], + ])); + + $guard = m::mock(Guard::class); + $manager->extend('custom', fn () => $guard); + + $manager->clearUserCache(42, 'api'); + + $this->addToAssertionCount(1); + } + + public function testClearUserCacheUsesSpecifiedGuardProvider() + { + $manager = new AuthManager($container = $this->getContainer([ + 'defaults' => [ + 'guard' => 'web', + ], + 'guards' => [ + 'web' => ['driver' => 'token', 'provider' => 'users'], + 'admin' => ['driver' => 'token', 'provider' => 'admins'], + ], + 'providers' => [ + 'users' => [ + 'driver' => 'eloquent', + 'model' => AuthManagerCacheUserStub::class, + 'cache' => ['enabled' => true, 'store' => 'web-store'], + ], + 'admins' => [ + 'driver' => 'eloquent', + 'model' => AuthManagerCacheAdminStub::class, + 'cache' => ['enabled' => true, 'store' => 'admin-store', 'prefix' => 'admin_users'], + ], + ], + ])); + + Container::setInstance($container); + $container->instance('hash', m::mock(HashContract::class)); + + $cacheManager = m::mock(CacheManager::class); + $adminRepo = m::mock(CacheRepository::class); + $adminRepo->shouldReceive('getStore')->andReturn(m::mock(RedisStore::class)); + $adminRepo->shouldReceive('forget') + ->once() + ->with('admin_users:' . AuthManagerCacheAdminStub::class . ':42') + ->andReturn(true); + $cacheManager->shouldReceive('store')->with('admin-store')->andReturn($adminRepo); + $container->instance('cache', $cacheManager); + + $manager->clearUserCache(42, 'admin'); + } + + public function testClearUserCacheUsesDefaultGuardAndRespectsResolver() + { + $manager = new AuthManager($container = $this->getContainer([ + 'defaults' => [ + 'guard' => 'web', + ], + 'guards' => [ + 'web' => ['driver' => 'token', 'provider' => 'users'], + ], + 'providers' => [ + 'users' => [ + 'driver' => 'eloquent', + 'model' => AuthManagerCacheUserStub::class, + 'cache' => ['enabled' => true, 'store' => 'web-store'], + ], + ], + ])); + + Container::setInstance($container); + $container->instance('hash', m::mock(HashContract::class)); + + $cacheManager = m::mock(CacheManager::class); + $repo = m::mock(CacheRepository::class); + $repo->shouldReceive('getStore')->andReturn(m::mock(RedisStore::class)); + $repo->shouldReceive('forget') + ->once() + ->with('auth_users:' . AuthManagerCacheUserStub::class . ':tenant:42') + ->andReturn(true); + $cacheManager->shouldReceive('store')->with('web-store')->andReturn($repo); + $container->instance('cache', $cacheManager); + + EloquentUserProvider::resolveUserCacheKeyUsing(fn (mixed $identifier): string => 'tenant:' . $identifier); + + $manager->clearUserCache(42); + } + + public function testForgetGuardsDoesNotAccumulateAuthCacheDescriptors() + { + $manager = new AuthManager($container = $this->getContainer([ + 'defaults' => [ + 'guard' => 'api', + ], + 'guards' => [ + 'api' => ['driver' => 'token', 'provider' => 'users'], + ], + 'providers' => [ + 'users' => [ + 'driver' => 'eloquent', + 'model' => AuthManagerCacheUserStub::class, + 'cache' => ['enabled' => true, 'store' => 'redis'], + ], + ], + ])); + + Container::setInstance($container); + $container->instance('hash', m::mock(HashContract::class)); + + $cacheManager = m::mock(CacheManager::class); + $firstRepo = m::mock(CacheRepository::class); + $firstRepo->shouldReceive('getStore')->andReturn(m::mock(RedisStore::class)); + $secondRepo = m::mock(CacheRepository::class); + $secondRepo->shouldReceive('getStore')->andReturn(m::mock(RedisStore::class)); + $cacheManager->shouldReceive('store')->with('redis')->andReturn($firstRepo, $secondRepo); + $container->instance('cache', $cacheManager); + + $firstGuard = $manager->guard('api'); + + $manager->forgetGuards(); + + $secondGuard = $manager->guard('api'); + + $this->assertNotSame($firstGuard, $secondGuard); + + $reflection = new ReflectionClass(EloquentUserProvider::class); + $descriptors = $reflection->getStaticPropertyValue('cachedProviders'); + + $this->assertArrayHasKey(AuthManagerCacheUserStub::class, $descriptors); + $this->assertCount(1, $descriptors[AuthManagerCacheUserStub::class]); + } + protected function getContainer(array $authConfig = []): Container { $container = new Container; @@ -337,3 +479,11 @@ protected function getContainer(array $authConfig = []): Container return $container; } } + +class AuthManagerCacheUserStub extends FoundationUser +{ +} + +class AuthManagerCacheAdminStub extends FoundationUser +{ +} diff --git a/tests/Integration/Auth/EloquentUserProviderCacheTest.php b/tests/Integration/Auth/EloquentUserProviderCacheTest.php new file mode 100644 index 000000000..71a24bb95 --- /dev/null +++ b/tests/Integration/Auth/EloquentUserProviderCacheTest.php @@ -0,0 +1,257 @@ +cacheManager = m::mock(CacheManager::class); + $this->app->instance('cache', $this->cacheManager); + } + + protected function afterRefreshingDatabase(): void + { + User::forceCreate([ + 'name' => 'Test User', + 'email' => 'test@example.com', + 'password' => bcrypt('secret'), + ]); + } + + // ------------------------------------------------------------------ + // Cache invalidation — model events + // ------------------------------------------------------------------ + + public function testCacheIsClearedOnUserSave() + { + $user = User::query()->first(); + $expectedKey = $this->buildKey($user->getAuthIdentifier()); + + $repo = $this->stubCache(); + $repo->shouldReceive('forget')->once()->with($expectedKey)->andReturn(true); + + $this->makeCachedProvider(); + + $user->name = 'Updated'; + $user->save(); + } + + public function testCacheIsClearedOnUserDelete() + { + $user = User::query()->first(); + $expectedKey = $this->buildKey($user->getAuthIdentifier()); + + $repo = $this->stubCache(); + $repo->shouldReceive('forget')->once()->with($expectedKey)->andReturn(true); + + $this->makeCachedProvider(); + + $user->delete(); + } + + public function testDescriptorsDedupeOnIdenticalConfig() + { + $this->stubCache(); + + $this->makeCachedProvider(); + $this->makeCachedProvider(); + + $reflection = new ReflectionClass(EloquentUserProvider::class); + $descriptors = $reflection->getStaticPropertyValue('cachedProviders'); + + $this->assertArrayHasKey(User::class, $descriptors); + $this->assertCount(1, $descriptors[User::class]); + } + + public function testModelEventInvalidatesAllDescriptorsForSameModel() + { + // Two distinct provider configurations for the same model should + // produce two descriptors; saving the user should clear both keys. + $repoA = $this->stubCache('redis-a'); + $repoB = $this->stubCache('redis-b'); + + $user = User::query()->first(); + $keyA = self::DEFAULT_KEY_PREFIX . ':' . User::class . ':' . $user->getAuthIdentifier(); + $keyB = 'admin_users:' . User::class . ':' . $user->getAuthIdentifier(); + + $repoA->shouldReceive('forget')->once()->with($keyA)->andReturn(true); + $repoB->shouldReceive('forget')->once()->with($keyB)->andReturn(true); + + $providerA = new EloquentUserProvider($this->app['hash'], User::class); + $providerA->enableCache('redis-a'); + + $providerB = new EloquentUserProvider($this->app['hash'], User::class); + $providerB->enableCache('redis-b', 300, 'admin_users'); + + $user->name = 'Updated'; + $user->save(); + } + + public function testModelEventListenersRegisteredOnlyOnce() + { + // Two distinct providers with different configs. If the save/deleted + // listeners were attached per-enableCache, the single save below would + // invoke forget 4 times (2 listeners × 2 descriptors). We expect + // exactly 2 forget calls — one listener, iterating 2 descriptors. + $repoA = $this->stubCache('redis-a'); + $repoB = $this->stubCache('redis-b'); + + $user = User::query()->first(); + $keyA = self::DEFAULT_KEY_PREFIX . ':' . User::class . ':' . $user->getAuthIdentifier(); + $keyB = 'admin_users:' . User::class . ':' . $user->getAuthIdentifier(); + + $repoA->shouldReceive('forget')->once()->with($keyA)->andReturn(true); + $repoB->shouldReceive('forget')->once()->with($keyB)->andReturn(true); + + $providerA = new EloquentUserProvider($this->app['hash'], User::class); + $providerA->enableCache('redis-a'); + + $providerB = new EloquentUserProvider($this->app['hash'], User::class); + $providerB->enableCache('redis-b', 300, 'admin_users'); + + $user->name = 'Updated'; + $user->save(); + } + + // ------------------------------------------------------------------ + // Cache invalidation — provider writes + // ------------------------------------------------------------------ + + public function testUpdateRememberTokenClearsCache() + { + $user = User::query()->first(); + $expectedKey = $this->buildKey($user->getAuthIdentifier()); + + $repo = $this->stubCache(); + $repo->shouldReceive('forget')->once()->with($expectedKey)->andReturn(true); + + $provider = $this->makeCachedProvider(); + + $provider->updateRememberToken($user, 'new-remember-token'); + } + + public function testRehashPasswordClearsCache() + { + $user = User::query()->first(); + $expectedKey = $this->buildKey($user->getAuthIdentifier()); + + $repo = $this->stubCache(); + $repo->shouldReceive('forget')->once()->with($expectedKey)->andReturn(true); + + $provider = $this->makeCachedProvider(); + + $provider->rehashPasswordIfRequired($user, ['password' => 'newpassword'], force: true); + } + + // ------------------------------------------------------------------ + // Dispatcher ordering + // ------------------------------------------------------------------ + + public function testEnableCacheSkipsListenerRegistrationWhenDispatcherAbsent() + { + $this->stubCache(); + + // Drop the dispatcher, then enable caching. The provider should + // populate its descriptor but skip listener registration, leaving + // $cacheEventsRegistered untouched for this model. + Model::unsetEventDispatcher(); + + $provider = new EloquentUserProvider($this->app['hash'], User::class); + $provider->enableCache(null); + + $reflection = new ReflectionClass(EloquentUserProvider::class); + $descriptors = $reflection->getStaticPropertyValue('cachedProviders'); + $registered = $reflection->getStaticPropertyValue('cacheEventsRegistered'); + + $this->assertArrayHasKey(User::class, $descriptors); + $this->assertArrayNotHasKey(User::class, $registered); + } + + // ------------------------------------------------------------------ + // withQuery() compatibility + // ------------------------------------------------------------------ + + public function testRetrieveByIdCachesResultWithEagerLoadedRelations() + { + // A withQuery callback that runs during the DB fetch should affect + // the first (cache-miss) retrieval. Subsequent calls hit the cache + // and return the cached User instance without re-running the query. + $user = User::query()->first(); + $expectedKey = $this->buildKey($user->getAuthIdentifier()); + + $repo = $this->stubCache(); + $repo->shouldReceive('get')->twice()->with($expectedKey) + ->andReturn(null, $user); // first call: miss; second: hit + $repo->shouldReceive('put')->once()->with($expectedKey, m::type(User::class), 300) + ->andReturn(true); + + $withQueryInvocations = 0; + $provider = new EloquentUserProvider($this->app['hash'], User::class); + $provider->enableCache(null); + $provider->withQuery(function ($builder) use (&$withQueryInvocations): void { + ++$withQueryInvocations; + }); + + $first = $provider->retrieveById($user->getAuthIdentifier()); + $second = $provider->retrieveById($user->getAuthIdentifier()); + + $this->assertNotNull($first); + $this->assertNotNull($second); + $this->assertSame(1, $withQueryInvocations, 'withQuery callback should run only on the cache-miss fetch'); + } + + // ------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------ + + protected function makeCachedProvider(): EloquentUserProvider + { + $provider = new EloquentUserProvider($this->app['hash'], User::class); + $provider->enableCache(null); + + return $provider; + } + + protected function stubCache(?string $name = null): MockInterface + { + $repo = m::mock(CacheRepository::class); + $repo->shouldReceive('getStore')->andReturn(m::mock(RedisStore::class)); + $this->cacheManager->shouldReceive('store')->with($name)->andReturn($repo); + + return $repo; + } + + protected function buildKey(mixed $identifier): string + { + return self::DEFAULT_KEY_PREFIX . ':' . User::class . ':' . $identifier; + } +}