diff --git a/config/api.php b/config/api.php index 6a1c0b5c..826cc4cc 100644 --- a/config/api.php +++ b/config/api.php @@ -23,6 +23,15 @@ // These keys can be used for performance testing in production // Example: THROTTLE_UNLIMITED_API_KEYS=Bearer test_key_123,Bearer load_test_456 'unlimited_keys' => array_filter(explode(',', env('THROTTLE_UNLIMITED_API_KEYS', ''))), + + // Count requests per API consumer for the admin "API consumers" view (needs Redis) + // Default: true (enabled). Administrators can also toggle this in the console. + // Example: THROTTLE_TRACK_CONSUMERS=false + 'track_consumers' => env('THROTTLE_TRACK_CONSUMERS', true), + + // Redis connection the consumer metrics are written to + // Default: cache + 'metrics_connection' => env('THROTTLE_METRICS_REDIS_CONNECTION', 'cache'), ], 'cache' => [ diff --git a/src/Exceptions/Handler.php b/src/Exceptions/Handler.php index def3bcdf..4abc3167 100644 --- a/src/Exceptions/Handler.php +++ b/src/Exceptions/Handler.php @@ -205,7 +205,8 @@ private function manuallyHandleException(\Throwable $exception): ?\Illuminate\Ht return response()->error('Invalid XSRF token sent with request.', 419); case 'ThrottleRequestsException': - return response()->error('Too many requests.', 429); + // Keep Retry-After and X-RateLimit-* so the throttled client knows when to retry. + return response()->error('Too many requests.', 429)->withHeaders($exception->getHeaders()); case 'AuthenticationException': return response()->error('Unauthenticated.', 401); diff --git a/src/Http/Controllers/Internal/v1/RateLimitController.php b/src/Http/Controllers/Internal/v1/RateLimitController.php new file mode 100644 index 00000000..24e20a2e --- /dev/null +++ b/src/Http/Controllers/Internal/v1/RateLimitController.php @@ -0,0 +1,117 @@ +json($this->settingsPayload(ApiRateLimits::settings())); + } + + /** + * Save the administrator's settings; they apply from the next request. + */ + public function saveSettings(AdminRequest $request): JsonResponse + { + $validated = $request->validate([ + 'enabled' => ['required', 'boolean'], + 'max_attempts' => ['required', 'integer', 'min:1', 'max:1000000'], + 'decay_minutes' => ['required', 'integer', 'min:1', 'max:1440'], + 'track_consumers' => ['sometimes', 'boolean'], + 'overrides' => ['sometimes', 'array'], + 'overrides.*.company_uuid' => ['required', 'string', 'distinct', Rule::exists('companies', 'uuid')], + 'overrides.*.unlimited' => ['sometimes', 'boolean'], + 'overrides.*.max_attempts' => ['nullable', 'integer', 'min:1', 'max:1000000'], + 'overrides.*.note' => ['nullable', 'string', 'max:255'], + ]); + + return response()->json($this->settingsPayload(ApiRateLimits::store($validated))); + } + + /** + * Discard the administrator's settings and fall back to the environment. + */ + public function resetSettings(AdminRequest $request): JsonResponse + { + return response()->json($this->settingsPayload(ApiRateLimits::reset())); + } + + /** + * The busiest (or most throttled) API consumers over a recent window. + */ + public function consumers(AdminRequest $request): JsonResponse + { + $request->validate([ + 'window' => ['sometimes', 'integer', Rule::in(ApiConsumerMetrics::WINDOWS)], + 'sort' => ['sometimes', Rule::in(['hits', 'throttled'])], + 'limit' => ['sometimes', 'integer', 'min:1', 'max:200'], + ]); + + $settings = ApiRateLimits::settings(); + $metrics = ApiConsumerMetrics::top( + (int) $request->input('window', 15), + (string) $request->input('sort', 'hits'), + (int) $request->input('limit', 50) + ); + + $metrics['tracking'] = $settings['track_consumers']; + $metrics['default_limit'] = $settings['max_attempts']; + $metrics['decay_minutes'] = $settings['decay_minutes']; + + return response()->json($metrics); + } + + /** + * Clear a consumer's current limiter window so it can send requests again at once. + */ + public function resetConsumer(AdminRequest $request, string $signature): JsonResponse + { + if (!preg_match('/^[a-f0-9]{40}$/', $signature)) { + return response()->error('Invalid consumer.', 422); + } + + RateLimiter::clear($signature); + + return response()->json(['status' => 'OK']); + } + + protected function settingsPayload(array $settings): array + { + $companies = Company::whereIn('uuid', array_column($settings['overrides'], 'company_uuid')) + ->get(['uuid', 'public_id', 'name']) + ->keyBy('uuid'); + + $settings['overrides'] = array_map(function (array $override) use ($companies) { + $company = $companies->get($override['company_uuid']); + + return array_merge($override, [ + 'company_id' => $company?->public_id, + 'company_name' => $company?->name, + ]); + }, $settings['overrides']); + + return [ + 'settings' => $settings, + 'defaults' => ApiRateLimits::defaults(), + 'unlimited_keys' => count(config('api.throttle.unlimited_keys', [])), + ]; + } +} diff --git a/src/Http/Middleware/ThrottleRequests.php b/src/Http/Middleware/ThrottleRequests.php index 3f32b83c..597e4fc5 100644 --- a/src/Http/Middleware/ThrottleRequests.php +++ b/src/Http/Middleware/ThrottleRequests.php @@ -2,6 +2,9 @@ namespace Fleetbase\Http\Middleware; +use Fleetbase\Support\ApiConsumerMetrics; +use Fleetbase\Support\ApiRateLimits; +use Illuminate\Http\Exceptions\ThrottleRequestsException; use Illuminate\Routing\Middleware\ThrottleRequests as ThrottleRequestsMiddleware; use Illuminate\Support\Facades\Log; @@ -10,10 +13,15 @@ class ThrottleRequests extends ThrottleRequestsMiddleware /** * Handle an incoming request. * - * This middleware supports multiple bypass mechanisms: - * 1. Global disable via THROTTLE_ENABLED=false (for development/testing) + * Limits come from ApiRateLimits: the environment (config/api.php) supplies the + * defaults and a system administrator may override them, per organization too, from + * the console. Two bypasses remain for operators: + * 1. Global disable via THROTTLE_ENABLED=false or the admin setting * 2. Unlimited API keys via THROTTLE_UNLIMITED_API_KEYS (for production testing) * + * Every request is also counted per consumer (ApiConsumerMetrics) so administrators + * can see who is driving traffic and who is being throttled. + * * @param \Illuminate\Http\Request $request * @param int|string $maxAttempts * @param float|int $decayMinutes @@ -23,8 +31,10 @@ class ThrottleRequests extends ThrottleRequestsMiddleware */ public function handle($request, \Closure $next, $maxAttempts = null, $decayMinutes = null, $prefix = '') { - // Check if throttling is globally disabled via configuration - if (config('api.throttle.enabled', true) === false) { + $settings = ApiRateLimits::settings(); + + // Check if throttling is globally disabled + if ($settings['enabled'] === false) { // Log when throttling is disabled (for security monitoring) if (app()->environment('production')) { Log::warning('API throttling is DISABLED globally', [ @@ -35,7 +45,7 @@ public function handle($request, \Closure $next, $maxAttempts = null, $decayMinu ]); } - return $next($request); + return $this->passThrough($request, $next, $settings, null); } // Check if request is using an unlimited/test API key @@ -49,14 +59,112 @@ public function handle($request, \Closure $next, $maxAttempts = null, $decayMinu 'method' => $request->method(), ]); - return $next($request); + return $this->passThrough($request, $next, $settings, null); + } + + // Organization overrides need to know whose credential this is; skip the lookup + // when there are none and consumers are not being tracked. + $consumer = ($settings['overrides'] || $settings['track_consumers']) ? $this->describeConsumer($request) : []; + $limit = ApiRateLimits::limitFor($consumer['company_uuid'] ?? null, $settings); + + if ($limit === null) { + return $this->passThrough($request, $next, $settings, null, $consumer); + } + + try { + $response = parent::handle($request, $next, $limit, $settings['decay_minutes'], $prefix); + } catch (ThrottleRequestsException $exception) { + $this->recordConsumer($request, $settings, $limit, $consumer, true); + + throw $exception; + } + + $this->recordConsumer($request, $settings, $limit, $consumer); + + return $response; + } + + /** + * Let a request through unthrottled, still counting it for the consumer view. + */ + protected function passThrough($request, \Closure $next, array $settings, ?int $limit, ?array $consumer = null) + { + $response = $next($request); + + $this->recordConsumer($request, $settings, $limit, $consumer); + + return $response; + } + + /** + * Count the request against its consumer when consumer tracking is on. + */ + protected function recordConsumer($request, array $settings, ?int $limit, ?array $consumer = null, bool $throttled = false): void + { + if (!$settings['track_consumers']) { + return; } - // Normal throttling: Get limits from configuration - $maxAttempts = config('api.throttle.max_attempts', 90); - $decayMinutes = config('api.throttle.decay_minutes', 1); + $consumer = $consumer ?: $this->describeConsumer($request); + + ApiConsumerMetrics::record($this->resolveRequestSignature($request), array_merge($consumer, [ + 'scope' => $request->segment(1) ?? '', + 'ip' => $request->ip(), + 'limit' => $limit, + ]), $throttled); + } + + /** + * Who is making this request, for overrides and the consumer view. + */ + protected function describeConsumer($request): array + { + if ($credential = $this->extractApiKey($request)) { + return ApiRateLimits::identify($credential); + } + + if ($user = $request->user()) { + return [ + 'type' => 'user', + 'label' => $user->name ?? $user->email ?? (string) $user->getAuthIdentifier(), + 'company_uuid' => $user->company_uuid ?? null, + ]; + } + + return ['type' => 'ip', 'label' => $request->ip(), 'company_uuid' => null]; + } + + /** + * Resolve the limiter key from the consumer rather than the connection. + * + * This middleware runs ahead of API authentication, so Laravel's default signature + * (the authenticated user, else route domain + client IP) always fell through to the + * IP. Behind a load balancer or reverse proxy that IP is the proxy's, and no route has + * a domain, so every tenant, API key and console visitor shared one bucket: a single + * busy integration returned 429 to the entire platform. + * + * The presented credential identifies the consumer without a database lookup, so the + * key is the hashed credential. Only requests carrying no credential fall back to the + * user, then the IP. The first path segment ("v1", "int", ...) keeps the public API and + * the console's public routes in separate buckets. + * + * @param \Illuminate\Http\Request $request + * + * @return string + */ + protected function resolveRequestSignature($request) + { + $scope = 'fleetbase-throttle|' . ($request->segment(1) ?? ''); + + if ($credential = $this->extractApiKey($request)) { + return sha1($scope . '|credential|' . $credential); + } + + if ($user = $request->user()) { + return sha1($scope . '|user|' . $user->getAuthIdentifier()); + } - return parent::handle($request, $next, $maxAttempts, $decayMinutes, $prefix); + return sha1($scope . '|ip|' . $request->ip()); } /** diff --git a/src/Support/ApiConsumerMetrics.php b/src/Support/ApiConsumerMetrics.php new file mode 100644 index 00000000..8bafeb12 --- /dev/null +++ b/src/Support/ApiConsumerMetrics.php @@ -0,0 +1,251 @@ +toIso8601String(); + + try { + static::connection()->pipeline(function ($pipe) use ($signature, $consumer, $throttled, $minute, $hour) { + $metrics = $throttled ? ['hits', 'throttled'] : ['hits']; + foreach ($metrics as $metric) { + $pipe->zincrby(static::key('m', $minute, $metric), 1, $signature); + $pipe->expire(static::key('m', $minute, $metric), static::MINUTE_RETENTION); + $pipe->zincrby(static::key('h', $hour, $metric), 1, $signature); + $pipe->expire(static::key('h', $hour, $metric), static::HOUR_RETENTION); + } + + $pipe->setex(static::PREFIX . ':consumer:' . $signature, static::HOUR_RETENTION, json_encode($consumer)); + }); + } catch (\Throwable $e) { + // Metrics are best-effort; never fail the request over them. + } + } + + /** + * The busiest consumers over a window, with platform totals and a request timeline. + * + * @param int $windowMinutes one of WINDOWS + * @param string $sort "hits" or "throttled" + */ + public static function top(int $windowMinutes = 15, string $sort = 'hits', int $limit = 50, ?Carbon $now = null): array + { + $now ??= Carbon::now(); + $sort = $sort === 'throttled' ? 'throttled' : 'hits'; + + try { + $redis = static::connection(); + [$granularity, $buckets] = static::buckets($windowMinutes, $now); + + $results = $redis->pipeline(function ($pipe) use ($buckets, $granularity) { + foreach ($buckets as $bucket) { + $pipe->zrangebyscore(static::key($granularity, $bucket, 'hits'), '-inf', '+inf', ['withscores' => true]); + $pipe->zrangebyscore(static::key($granularity, $bucket, 'throttled'), '-inf', '+inf', ['withscores' => true]); + } + }); + } catch (\Throwable $e) { + return static::unavailable($windowMinutes, $sort); + } + + $consumers = []; + $series = []; + foreach ($buckets as $index => $bucket) { + $hits = static::scores($results[$index * 2] ?? []); + $throttled = static::scores($results[$index * 2 + 1] ?? []); + + $series[] = [ + 'bucket' => static::bucketTime($granularity, $bucket)->toIso8601String(), + 'hits' => array_sum($hits), + 'throttled' => array_sum($throttled), + ]; + + foreach (['hits' => $hits, 'throttled' => $throttled] as $metric => $scores) { + foreach ($scores as $signature => $count) { + $consumers[$signature] ??= ['signature' => $signature, 'hits' => 0, 'throttled' => 0, 'peak_per_minute' => 0]; + $consumers[$signature][$metric] += $count; + if ($metric === 'hits' && $granularity === 'm') { + $consumers[$signature]['peak_per_minute'] = max($consumers[$signature]['peak_per_minute'], $count); + } + } + } + } + + $totalHits = array_sum(array_column($consumers, 'hits')); + $totalThrottled = array_sum(array_column($consumers, 'throttled')); + + uasort($consumers, fn ($a, $b) => [$b[$sort], $b['hits']] <=> [$a[$sort], $a['hits']]); + $top = array_slice(array_values($consumers), 0, max(1, $limit)); + + $details = static::details(array_column($top, 'signature')); + $top = array_map(function (array $row) use ($details, $totalHits, $windowMinutes) { + $row = array_merge($details[$row['signature']] ?? [], $row); + + $row['share'] = $totalHits > 0 ? round($row['hits'] / $totalHits * 100, 2) : 0; + $row['avg_per_minute'] = round($row['hits'] / max(1, $windowMinutes), 2); + $row['peak_per_minute'] = $row['peak_per_minute'] ?: null; + + return $row; + }, $top); + + return [ + 'available' => true, + 'window' => $windowMinutes, + 'granularity' => $granularity === 'm' ? 'minute' : 'hour', + 'sort' => $sort, + 'total_requests' => $totalHits, + 'total_throttled'=> $totalThrottled, + 'consumer_count' => count($consumers), + 'consumers' => $top, + 'series' => $series, + ]; + } + + /** + * Stored descriptions for a set of consumer signatures. + */ + protected static function details(array $signatures): array + { + if (empty($signatures)) { + return []; + } + + try { + $values = static::connection()->mget(array_map(fn ($signature) => static::PREFIX . ':consumer:' . $signature, $signatures)); + } catch (\Throwable $e) { + return []; + } + + $details = []; + foreach (array_values($signatures) as $index => $signature) { + $decoded = is_string($values[$index] ?? null) ? json_decode($values[$index], true) : null; + $details[$signature] = is_array($decoded) ? $decoded : []; + } + + return $details; + } + + /** + * Minute buckets for windows up to two hours, hour buckets beyond, oldest first. + */ + protected static function buckets(int $windowMinutes, Carbon $now): array + { + $windowMinutes = max(1, $windowMinutes); + + if ($windowMinutes <= 120) { + $buckets = []; + for ($i = $windowMinutes - 1; $i >= 0; $i--) { + $buckets[] = static::minuteBucket($now->copy()->subMinutes($i)); + } + + return ['m', $buckets]; + } + + $hours = (int) ceil($windowMinutes / 60); + $buckets = []; + for ($i = $hours - 1; $i >= 0; $i--) { + $buckets[] = static::hourBucket($now->copy()->subHours($i)); + } + + return ['h', $buckets]; + } + + /** + * Normalize a zrangebyscore WITHSCORES reply to [member => int]. + */ + protected static function scores($reply): array + { + if (!is_array($reply)) { + return []; + } + + // Some clients reply with a flat [member, score, member, score] list. + if (!empty($reply) && array_keys($reply) === range(0, count($reply) - 1) && count($reply) % 2 === 0 && !is_numeric($reply[0])) { + $pairs = []; + for ($i = 0; $i < count($reply); $i += 2) { + $pairs[$reply[$i]] = $reply[$i + 1]; + } + $reply = $pairs; + } + + return array_map(fn ($score) => (int) $score, $reply); + } + + protected static function unavailable(int $windowMinutes, string $sort): array + { + return [ + 'available' => false, + 'window' => $windowMinutes, + 'granularity' => $windowMinutes <= 120 ? 'minute' : 'hour', + 'sort' => $sort, + 'total_requests' => 0, + 'total_throttled' => 0, + 'consumer_count' => 0, + 'consumers' => [], + 'series' => [], + ]; + } + + protected static function key(string $granularity, string $bucket, string $metric): string + { + return static::PREFIX . ':' . $granularity . ':' . $bucket . ':' . $metric; + } + + protected static function minuteBucket(Carbon $at): string + { + return $at->copy()->utc()->format('YmdHi'); + } + + protected static function hourBucket(Carbon $at): string + { + return $at->copy()->utc()->format('YmdH'); + } + + protected static function bucketTime(string $granularity, string $bucket): Carbon + { + return Carbon::createFromFormat($granularity === 'm' ? '!YmdHi' : '!YmdH', $bucket, 'UTC'); + } + + protected static function connection() + { + return Redis::connection(config('api.throttle.metrics_connection', 'cache')); + } +} diff --git a/src/Support/ApiRateLimits.php b/src/Support/ApiRateLimits.php new file mode 100644 index 00000000..4ded2f56 --- /dev/null +++ b/src/Support/ApiRateLimits.php @@ -0,0 +1,247 @@ + config('api.throttle.enabled', true) !== false, + 'max_attempts' => (int) config('api.throttle.max_attempts', 120), + 'decay_minutes' => (int) config('api.throttle.decay_minutes', 1), + 'track_consumers' => config('api.throttle.track_consumers', true) !== false, + 'overrides' => [], + ]; + } + + /** + * The effective settings: the environment defaults with the stored override applied. + * + * Read on every throttled request, so it is cached; saving through store() clears it. + * A missing database or cache (unit tests, a broken install) falls back to the defaults + * rather than failing the request. + */ + public static function settings(): array + { + try { + $stored = Cache::remember(static::SETTINGS_CACHE_KEY, 60, function () { + return Setting::where('key', 'system.' . static::SETTING_KEY)->value('value') ?? []; + }); + } catch (\Throwable $e) { + $stored = []; + } + + return static::normalize(array_merge(static::defaults(), is_array($stored) ? $stored : [])); + } + + /** + * Persist administrator settings and make them effective immediately. + */ + public static function store(array $settings): array + { + $settings = static::normalize(array_merge(static::defaults(), $settings)); + + Setting::configureSystem(static::SETTING_KEY, $settings); + Cache::forget(static::SETTINGS_CACHE_KEY); + + return $settings; + } + + /** + * Drop the stored override so the environment defaults apply again. + */ + public static function reset(): array + { + Setting::where('key', 'system.' . static::SETTING_KEY)->delete(); + Cache::forget(static::SETTINGS_CACHE_KEY); + + return static::settings(); + } + + /** + * Coerce settings into their canonical shape. + */ + public static function normalize(array $settings): array + { + $overrides = []; + foreach ((array) ($settings['overrides'] ?? []) as $override) { + $companyUuid = data_get($override, 'company_uuid'); + if (!is_string($companyUuid) || $companyUuid === '') { + continue; + } + + $maxAttempts = data_get($override, 'max_attempts'); + $overrides[$companyUuid] = [ + 'company_uuid' => $companyUuid, + 'unlimited' => (bool) data_get($override, 'unlimited', false), + 'max_attempts' => is_numeric($maxAttempts) ? max(1, (int) $maxAttempts) : null, + 'note' => (string) data_get($override, 'note', ''), + ]; + } + + return [ + 'enabled' => (bool) ($settings['enabled'] ?? true), + 'max_attempts' => max(1, (int) ($settings['max_attempts'] ?? 120)), + 'decay_minutes' => max(1, (int) ($settings['decay_minutes'] ?? 1)), + 'track_consumers' => (bool) ($settings['track_consumers'] ?? true), + 'overrides' => array_values($overrides), + ]; + } + + /** + * The limit that applies to a consumer, or null when it is unlimited. + */ + public static function limitFor(?string $companyUuid, ?array $settings = null): ?int + { + $settings ??= static::settings(); + + if ($companyUuid) { + foreach ($settings['overrides'] as $override) { + if ($override['company_uuid'] !== $companyUuid) { + continue; + } + + if ($override['unlimited']) { + return null; + } + + return $override['max_attempts'] ?? $settings['max_attempts']; + } + } + + return $settings['max_attempts']; + } + + /** + * Identify who a credential belongs to, cached so the lookup is at most one query per + * credential per cache period. Unknown credentials are cached too, so a caller sending + * a bad key does not reach the database more often than one sending a good one. + * + * @param string $credential the raw credential as extracted by the throttle middleware + */ + public static function identify(string $credential): array + { + try { + return Cache::remember(static::CONSUMER_CACHE_PREFIX . sha1($credential), static::CONSUMER_CACHE_TTL, function () use ($credential) { + return static::lookupConsumer($credential); + }); + } catch (\Throwable $e) { + return static::unknownConsumer($credential); + } + } + + /** + * Resolve a credential to its owning organization without authenticating it. + */ + protected static function lookupConsumer(string $credential): array + { + $token = trim(Str::startsWith($credential, 'Bearer ') ? Str::after($credential, 'Bearer ') : $credential); + + // Sanctum personal access tokens are "|". + if (Str::contains($token, '|')) { + $accessToken = PersonalAccessToken::findToken($token); + $user = $accessToken?->tokenable; + if ($user) { + return static::describe('token', $user->name ?? $user->email ?? 'Access token', $user->company_uuid ?? null, null, $accessToken->name ?? null); + } + + return static::unknownConsumer($credential); + } + + // Secret keys ("$...") carry no mode prefix, so a live miss is retried on sandbox, + // the same as AuthenticateOnceWithBasicAuth does. + $connections = Str::startsWith($token, 'flb_test_') ? ['sandbox'] : (Str::startsWith($token, '$') ? ['mysql', 'sandbox'] : ['mysql']); + $apiKey = null; + foreach ($connections as $connection) { + $apiKey = ApiCredential::on($connection) + ->withoutGlobalScopes() + ->where(function ($query) use ($token) { + $query->where('key', $token)->orWhere('secret', $token); + }) + ->first(['uuid', 'name', 'key', 'company_uuid', 'test_mode']); + + if ($apiKey) { + break; + } + } + + if ($apiKey) { + return static::describe('api_key', $apiKey->name ?: 'API key', $apiKey->company_uuid, $apiKey->uuid, static::mask($apiKey->key), (bool) $apiKey->test_mode); + } + + return static::unknownConsumer($credential); + } + + protected static function describe(string $type, string $label, ?string $companyUuid, ?string $credentialUuid = null, ?string $detail = null, bool $testMode = false): array + { + $company = $companyUuid ? Company::where('uuid', $companyUuid)->first(['uuid', 'public_id', 'name']) : null; + + return [ + 'type' => $type, + 'label' => $label, + 'detail' => $detail, + 'test_mode' => $testMode, + 'credential_uuid' => $credentialUuid, + 'company_uuid' => $company?->uuid ?? $companyUuid, + 'company_id' => $company?->public_id, + 'company_name' => $company?->name, + ]; + } + + protected static function unknownConsumer(string $credential): array + { + $token = trim(Str::after($credential, ' ')); + + return [ + 'type' => 'unknown', + 'label' => 'Unrecognized credential', + 'detail' => static::mask($token ?: $credential), + 'test_mode' => false, + 'credential_uuid' => null, + 'company_uuid' => null, + 'company_id' => null, + 'company_name' => null, + ]; + } + + /** + * Enough of a key to recognize it, never enough to use it. + */ + public static function mask(?string $value): ?string + { + if (!$value) { + return null; + } + + return mb_substr($value, 0, min(12, (int) floor(mb_strlen($value) / 2))) . '…'; + } +} diff --git a/src/routes.php b/src/routes.php index e60690fc..fe76c767 100644 --- a/src/routes.php +++ b/src/routes.php @@ -254,6 +254,13 @@ function ($router, $controller) { $router->post('test-notification-channels-config', $controller('testNotificationChannelsConfig')); } ); + $router->group(['prefix' => 'rate-limits'], function ($router) { + $router->get('settings', 'RateLimitController@getSettings'); + $router->post('settings', 'RateLimitController@saveSettings'); + $router->delete('settings', 'RateLimitController@resetSettings'); + $router->get('consumers', 'RateLimitController@consumers'); + $router->post('consumers/{signature}/reset', 'RateLimitController@resetConsumer'); + }); $router->fleetbaseRoutes('schedule-monitor', null, [], function ($router, $controller) { $router->get('tasks', $controller('tasks')); $router->get('{id}/logs', $controller('logs')); diff --git a/tests/Fixtures/Support/RedisMetricsFake.php b/tests/Fixtures/Support/RedisMetricsFake.php new file mode 100644 index 00000000..e0f895d6 --- /dev/null +++ b/tests/Fixtures/Support/RedisMetricsFake.php @@ -0,0 +1,132 @@ +<?php + +namespace Fleetbase\Tests\Fixtures\Support; + +/** + * An in-memory stand-in for the Redis manager and connection used by ApiConsumerMetrics. + * + * Bound as the container's `redis` so that Redis::connection($name) returns this object. + * Only the sorted-set, string and pipeline commands the metrics use are implemented, and + * every command is recorded in $commands for assertions. + */ +class RedisMetricsFake +{ + public array $commands = []; + public array $connections = []; + public array $sortedSets = []; + public array $strings = []; + public array $ttls = []; + + /** + * Replace the pipeline reply entirely (e.g. to simulate a client's flat WITHSCORES replies). + */ + public ?array $pipelineReply = null; + + /** + * Reply to zrangebyscore with a flat [member, score, ...] list instead of [member => score]. + */ + public bool $flatScores = false; + + public bool $pipelineThrows = false; + + public bool $mgetThrows = false; + + public function connection(?string $name = null): self + { + $this->connections[] = $name; + + return $this; + } + + public function pipeline(callable $callback): array + { + if ($this->pipelineThrows) { + throw new \RuntimeException('redis unavailable'); + } + + $pipe = new class { + public array $queued = []; + + public function __call(string $method, array $arguments): self + { + $this->queued[] = [$method, $arguments]; + + return $this; + } + }; + + $callback($pipe); + + $replies = []; + foreach ($pipe->queued as [$method, $arguments]) { + $replies[] = $this->{$method}(...$arguments); + } + + return $this->pipelineReply ?? $replies; + } + + public function zincrby(string $key, int|float $increment, string $member): float + { + $this->commands[] = ['zincrby', $key, $increment, $member]; + + $this->sortedSets[$key][$member] = ($this->sortedSets[$key][$member] ?? 0) + $increment; + + return (float) $this->sortedSets[$key][$member]; + } + + public function expire(string $key, int $seconds): bool + { + $this->commands[] = ['expire', $key, $seconds]; + $this->ttls[$key] = $seconds; + + return true; + } + + public function setex(string $key, int $seconds, string $value): bool + { + $this->commands[] = ['setex', $key, $seconds, $value]; + $this->strings[$key] = $value; + $this->ttls[$key] = $seconds; + + return true; + } + + public function zrangebyscore(string $key, string $min, string $max, array $options = []): array + { + $this->commands[] = ['zrangebyscore', $key, $min, $max, $options]; + + $set = $this->sortedSets[$key] ?? []; + asort($set); + + // Redis replies with scores as strings. + $set = array_map(fn ($score) => (string) $score, $set); + + if (!$this->flatScores) { + return $set; + } + + $flat = []; + foreach ($set as $member => $score) { + $flat[] = (string) $member; + $flat[] = $score; + } + + return $flat; + } + + public function mget(array $keys): array + { + $this->commands[] = ['mget', $keys]; + + if ($this->mgetThrows) { + throw new \RuntimeException('redis unavailable'); + } + + return array_map(fn ($key) => $this->strings[$key] ?? null, $keys); + } + + public function commandNames(): array + { + return array_column($this->commands, 0); + } +} diff --git a/tests/Unit/Exceptions/ExceptionHandlerTest.php b/tests/Unit/Exceptions/ExceptionHandlerTest.php index 68dfa51d..08c71d30 100644 --- a/tests/Unit/Exceptions/ExceptionHandlerTest.php +++ b/tests/Unit/Exceptions/ExceptionHandlerTest.php @@ -113,6 +113,22 @@ function exception_handler_subject(): Handler 'http not found' => [new NotFoundHttpException(), ['There is nothing to see here.'], 404], ]); + it('keeps the retry-after and rate limit headers on a throttled response', function () { + $handler = exception_handler_subject(); + $exception = new ThrottleRequestsException('Too Many Attempts.', null, [ + 'Retry-After' => 42, + 'X-RateLimit-Limit' => 120, + 'X-RateLimit-Remaining' => 0, + ]); + + $response = $handler->render(Request::create('/v1/orders', 'POST'), $exception); + + expect($response->getStatusCode())->toBe(429) + ->and($response->headers->get('Retry-After'))->toBe('42') + ->and($response->headers->get('X-RateLimit-Limit'))->toBe('120') + ->and($response->headers->get('X-RateLimit-Remaining'))->toBe('0'); + }); + it('returns a resource-specific model not found json response when the model is known', function () { $handler = exception_handler_subject(); $exception = (new ModelNotFoundException())->setModel(User::class); diff --git a/tests/Unit/Http/MiddlewareContractsTest.php b/tests/Unit/Http/MiddlewareContractsTest.php index 96027c7b..952a0f49 100644 --- a/tests/Unit/Http/MiddlewareContractsTest.php +++ b/tests/Unit/Http/MiddlewareContractsTest.php @@ -1023,6 +1023,65 @@ public function info(string $message, array $context = []): void ->and($isUnlimitedApiKey->invoke($middleware, 'Bearer other-key'))->toBeFalse(); }); + test('throttle requests keys the limiter on the presented credential rather than the proxy ip', function () { + middleware_contracts_fixture([ + 'api.throttle.enabled' => true, + 'api.throttle.max_attempts' => 2, + 'api.throttle.decay_minutes' => 1, + 'api.throttle.unlimited_keys' => [], + ]); + + $middleware = middleware_contracts_throttle(); + // Every request arrives from the same load balancer address, as in production. + $send = function (string $uri, ?string $credential = null) use ($middleware) { + $server = ['REMOTE_ADDR' => '10.0.0.5']; + if ($credential) { + $server['HTTP_AUTHORIZATION'] = 'Bearer ' . $credential; + } + $request = Request::create($uri, 'GET', [], [], [], $server); + $request->setRouteResolver(fn () => new Illuminate\Routing\Route(['GET'], ltrim($uri, '/'), fn () => null)); + + try { + return $middleware->handle($request, fn () => new JsonResponse(['ok' => true]))->getStatusCode(); + } catch (Illuminate\Http\Exceptions\ThrottleRequestsException $exception) { + return $exception->getStatusCode(); + } + }; + + $noisy = [$send('/v1/orders', 'flb_live_noisy'), $send('/v1/orders', 'flb_live_noisy'), $send('/v1/orders', 'flb_live_noisy')]; + + expect($noisy)->toBe([200, 200, 429]) + ->and($send('/v1/orders', 'flb_live_quiet'))->toBe(200) + ->and($send('/int/v1/auth/login'))->toBe(200) + ->and($send('/int/v1/lookup/countries'))->toBe(200) + ->and($send('/int/v1/settings/branding'))->toBe(429) + ->and($send('/v1/orders'))->toBe(200); + }); + + test('throttle requests falls back to the authenticated user before the ip when no credential is sent', function () { + middleware_contracts_fixture([ + 'api.throttle.enabled' => true, + 'api.throttle.max_attempts' => 1, + 'api.throttle.unlimited_keys' => [], + ]); + + $middleware = middleware_contracts_throttle(); + $signature = new ReflectionMethod($middleware, 'resolveRequestSignature'); + $signature->setAccessible(true); + $request = function (?string $userId, string $ip) { + $request = Request::create('/v1/orders', 'GET', [], [], [], ['REMOTE_ADDR' => $ip]); + $request->setUserResolver(fn () => $userId ? new Illuminate\Auth\GenericUser(['id' => $userId]) : null); + + return $request; + }; + + expect($signature->invoke($middleware, $request('user-1', '10.0.0.5'))) + ->toBe($signature->invoke($middleware, $request('user-1', '10.0.0.6'))) + ->not->toBe($signature->invoke($middleware, $request('user-2', '10.0.0.5'))) + ->and($signature->invoke($middleware, $request(null, '10.0.0.5'))) + ->not->toBe($signature->invoke($middleware, $request(null, '10.0.0.6'))); + }); + test('basic auth middleware rejects requests without bearer credentials before continuing', function () { middleware_contracts_fixture(); diff --git a/tests/Unit/Http/RateLimitControllerTest.php b/tests/Unit/Http/RateLimitControllerTest.php new file mode 100644 index 00000000..841cf96a --- /dev/null +++ b/tests/Unit/Http/RateLimitControllerTest.php @@ -0,0 +1,247 @@ +<?php + +use Fleetbase\Http\Controllers\Internal\v1\RateLimitController; +use Fleetbase\Http\Requests\AdminRequest; +use Fleetbase\Support\ApiConsumerMetrics; +use Fleetbase\Support\ApiRateLimits; +use Fleetbase\Tests\Fixtures\Support\RedisMetricsFake; +use Illuminate\Cache\ArrayStore; +use Illuminate\Cache\RateLimiter; +use Illuminate\Cache\Repository as CacheRepository; +use Illuminate\Database\Capsule\Manager as Capsule; +use Illuminate\Database\Eloquent\Model as EloquentModel; +use Illuminate\Events\Dispatcher; +use Illuminate\Http\Request; +use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Facade; +use Illuminate\Translation\ArrayLoader; +use Illuminate\Translation\Translator; +use Illuminate\Validation\DatabasePresenceVerifier; +use Illuminate\Validation\Factory as ValidationFactory; +use Illuminate\Validation\ValidationException; + +function rate_limit_controller_fixture(array $config = []): RedisMetricsFake +{ + EloquentModel::clearBootedModels(); + + $connection = [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]; + + $container = bind_test_container(array_merge([ + 'api.cache.enabled' => false, + 'api.throttle.enabled' => true, + 'api.throttle.max_attempts' => 120, + 'api.throttle.decay_minutes' => 1, + 'api.throttle.track_consumers' => true, + 'api.throttle.unlimited_keys' => ['Bearer load-test-1', 'Bearer load-test-2'], + 'database.default' => 'mysql', + 'database.connections.mysql' => $connection, + 'fleetbase.connection.db' => 'mysql', + ], $config)); + + $cache = new CacheRepository(new ArrayStore()); + $container->instance('cache', $cache); + $container->instance(RateLimiter::class, new RateLimiter($cache)); + $redis = new RedisMetricsFake(); + $container->instance('redis', $redis); + Facade::clearResolvedInstances(); + + $capsule = new Capsule($container); + $capsule->addConnection($connection, 'mysql'); + $capsule->setEventDispatcher(new Dispatcher($container)); + $capsule->setAsGlobal(); + $capsule->bootEloquent(); + $capsule->getDatabaseManager()->setDefaultConnection('mysql'); + $container->instance('db', $capsule->getDatabaseManager()); + Facade::clearResolvedInstance('db'); + + $schema = $capsule->getConnection('mysql')->getSchemaBuilder(); + $schema->create('settings', function ($table) { + $table->increments('id'); + $table->string('key')->unique(); + $table->text('value')->nullable(); + }); + $schema->create('companies', function ($table) { + $table->string('uuid')->primary(); + $table->string('public_id')->nullable(); + $table->string('name')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + $capsule->getConnection('mysql')->table('companies')->insert([ + ['uuid' => 'company-acme', 'public_id' => 'company_acme', 'name' => 'Acme'], + ['uuid' => 'company-globex', 'public_id' => 'company_globex', 'name' => 'Globex'], + ]); + + // Mirrors the `validate` request macro the framework's FoundationServiceProvider registers. + $validation = new ValidationFactory(new Translator(new ArrayLoader(), 'en')); + $validation->setPresenceVerifier(new DatabasePresenceVerifier($capsule->getDatabaseManager())); + Request::macro('validate', function (array $rules) use ($validation) { + return $validation->make($this->all(), $rules)->validate(); + }); + + return $redis; +} + +function rate_limit_controller_request(string $method = 'GET', array $input = [], string $uri = '/int/v1/rate-limits/settings'): AdminRequest +{ + return AdminRequest::create($uri, $method, $input); +} + +afterEach(function () { + Carbon::setTestNow(); + + $macros = new ReflectionProperty(Request::class, 'macros'); + $macros->setAccessible(true); + $macros->setValue(null, array_diff_key($macros->getValue(), ['validate' => true])); + + config(['api.throttle' => array_diff_key((array) config('api.throttle', []), ['track_consumers' => true, 'unlimited_keys' => true])]); + app()->forgetInstance('redis'); + EloquentModel::clearBootedModels(); + Facade::clearResolvedInstances(); +}); + +test('rate limit controller returns the effective settings with the environment defaults beneath them', function () { + rate_limit_controller_fixture(); + ApiRateLimits::store(['max_attempts' => 60, 'overrides' => [ + ['company_uuid' => 'company-acme', 'unlimited' => true, 'note' => 'Partner'], + ['company_uuid' => 'company-deleted', 'max_attempts' => 5], + ]]); + + $response = (new RateLimitController())->getSettings(rate_limit_controller_request()); + + expect($response->getStatusCode())->toBe(200) + ->and($response->getData(true))->toBe([ + 'settings' => [ + 'enabled' => true, + 'max_attempts' => 60, + 'decay_minutes' => 1, + 'track_consumers' => true, + 'overrides' => [ + ['company_uuid' => 'company-acme', 'unlimited' => true, 'max_attempts' => null, 'note' => 'Partner', 'company_id' => 'company_acme', 'company_name' => 'Acme'], + ['company_uuid' => 'company-deleted', 'unlimited' => false, 'max_attempts' => 5, 'note' => '', 'company_id' => null, 'company_name' => null], + ], + ], + 'defaults' => [ + 'enabled' => true, + 'max_attempts' => 120, + 'decay_minutes' => 1, + 'track_consumers' => true, + 'overrides' => [], + ], + 'unlimited_keys' => 2, + ]); +}); + +test('rate limit controller saves validated settings and applies them from the next request', function () { + rate_limit_controller_fixture(); + + $response = (new RateLimitController())->saveSettings(rate_limit_controller_request('POST', [ + 'enabled' => true, + 'max_attempts' => 250, + 'decay_minutes' => 2, + 'track_consumers' => false, + 'overrides' => [ + ['company_uuid' => 'company-globex', 'max_attempts' => 1000, 'note' => 'Bulk importer'], + ], + ])); + + expect($response->getData(true)['settings'])->toBe([ + 'enabled' => true, + 'max_attempts' => 250, + 'decay_minutes' => 2, + 'track_consumers' => false, + 'overrides' => [ + ['company_uuid' => 'company-globex', 'unlimited' => false, 'max_attempts' => 1000, 'note' => 'Bulk importer', 'company_id' => 'company_globex', 'company_name' => 'Globex'], + ], + ]) + ->and(ApiRateLimits::settings()['max_attempts'])->toBe(250) + ->and(ApiRateLimits::limitFor('company-globex'))->toBe(1000); +}); + +test('rate limit controller rejects invalid settings and overrides for unknown organizations', function (array $input) { + rate_limit_controller_fixture(); + + expect(fn () => (new RateLimitController())->saveSettings(rate_limit_controller_request('POST', $input))) + ->toThrow(ValidationException::class); + + expect(ApiRateLimits::settings()['max_attempts'])->toBe(120); +})->with([ + 'missing limit' => [['enabled' => true, 'decay_minutes' => 1]], + 'zero limit' => [['enabled' => true, 'max_attempts' => 0, 'decay_minutes' => 1]], + 'decay over a day' => [['enabled' => true, 'max_attempts' => 10, 'decay_minutes' => 1441]], + 'unknown organization' => [['enabled' => true, 'max_attempts' => 10, 'decay_minutes' => 1, 'overrides' => [['company_uuid' => 'company-missing']]]], + 'duplicate override' => [['enabled' => true, 'max_attempts' => 10, 'decay_minutes' => 1, 'overrides' => [['company_uuid' => 'company-acme'], ['company_uuid' => 'company-acme']]]], +]); + +test('rate limit controller reset discards the administrator settings', function () { + rate_limit_controller_fixture(); + ApiRateLimits::store(['max_attempts' => 5, 'overrides' => [['company_uuid' => 'company-acme', 'unlimited' => true]]]); + + $response = (new RateLimitController())->resetSettings(rate_limit_controller_request('DELETE')); + + expect($response->getData(true)['settings'])->toBe([ + 'enabled' => true, + 'max_attempts' => 120, + 'decay_minutes' => 1, + 'track_consumers' => true, + 'overrides' => [], + ]) + ->and(ApiRateLimits::settings()['max_attempts'])->toBe(120); +}); + +test('rate limit controller lists the busiest consumers with the limits that apply', function () { + Carbon::setTestNow(Carbon::parse('2026-07-18 12:30:00', 'UTC')); + rate_limit_controller_fixture(); + ApiRateLimits::store(['max_attempts' => 90, 'decay_minutes' => 3, 'track_consumers' => false]); + + ApiConsumerMetrics::record('sig-busy', ['label' => 'Busy']); + ApiConsumerMetrics::record('sig-busy', ['label' => 'Busy'], true); + ApiConsumerMetrics::record('sig-quiet', ['label' => 'Quiet']); + + $defaults = (new RateLimitController())->consumers(rate_limit_controller_request('GET', [], '/int/v1/rate-limits/consumers'))->getData(true); + $sorted = (new RateLimitController())->consumers(rate_limit_controller_request('GET', ['window' => 60, 'sort' => 'throttled', 'limit' => 1], '/int/v1/rate-limits/consumers'))->getData(true); + + expect($defaults)->toMatchArray([ + 'available' => true, + 'window' => 15, + 'sort' => 'hits', + 'total_requests' => 3, + 'consumer_count' => 2, + 'tracking' => false, + 'default_limit' => 90, + 'decay_minutes' => 3, + ]) + ->and(array_column($defaults['consumers'], 'label'))->toBe(['Busy', 'Quiet']) + ->and($sorted)->toMatchArray(['window' => 60, 'sort' => 'throttled']) + ->and(array_column($sorted['consumers'], 'signature'))->toBe(['sig-busy']); +}); + +test('rate limit controller rejects consumer windows the view does not offer', function () { + rate_limit_controller_fixture(); + + expect(fn () => (new RateLimitController())->consumers(rate_limit_controller_request('GET', ['window' => 7], '/int/v1/rate-limits/consumers'))) + ->toThrow(ValidationException::class); +}); + +test('rate limit controller resets a consumer limiter and refuses malformed signatures', function () { + rate_limit_controller_fixture(); + $signature = sha1('fleetbase-throttle|v1|credential|Bearer flb_live_noisy'); + $limiter = app(RateLimiter::class); + $limiter->hit($signature, 60); + $limiter->hit($signature, 60); + + $invalid = (new RateLimitController())->resetConsumer(rate_limit_controller_request('POST'), 'not-a-signature'); + + expect($invalid->getStatusCode())->toBe(422) + ->and($invalid->getData(true))->toBe(['errors' => ['Invalid consumer.']]) + ->and($limiter->attempts($signature))->toBe(2); + + $reset = (new RateLimitController())->resetConsumer(rate_limit_controller_request('POST'), $signature); + + expect($reset->getData(true))->toBe(['status' => 'OK']) + ->and($limiter->attempts($signature))->toBe(0); +}); diff --git a/tests/Unit/Http/ThrottleRequestsConsumersTest.php b/tests/Unit/Http/ThrottleRequestsConsumersTest.php new file mode 100644 index 00000000..490f4c67 --- /dev/null +++ b/tests/Unit/Http/ThrottleRequestsConsumersTest.php @@ -0,0 +1,213 @@ +<?php + +use Fleetbase\Http\Middleware\ThrottleRequests; +use Fleetbase\Support\ApiRateLimits; +use Fleetbase\Tests\Fixtures\Support\RedisMetricsFake; +use Illuminate\Auth\GenericUser; +use Illuminate\Cache\ArrayStore; +use Illuminate\Cache\RateLimiter; +use Illuminate\Cache\Repository as CacheRepository; +use Illuminate\Http\Exceptions\ThrottleRequestsException; +use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; +use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Facade; + +/** + * Binds a real array cache (seeded with the given effective settings) and an in-memory + * Redis, so the throttle reads administrator settings and records consumer metrics. + */ +function throttle_consumers_fixture(array $settings = [], array $config = []): RedisMetricsFake +{ + $container = bind_test_container(array_merge([ + 'app.env' => 'testing', + 'api.throttle.enabled' => true, + 'api.throttle.max_attempts' => 120, + 'api.throttle.decay_minutes' => 1, + 'api.throttle.track_consumers' => true, + 'api.throttle.unlimited_keys' => [], + ], $config)); + + $container->instance('cache', new CacheRepository(new ArrayStore())); + $redis = new RedisMetricsFake(); + $container->instance('redis', $redis); + Facade::clearResolvedInstances(); + + if ($settings !== []) { + cache()->put(ApiRateLimits::SETTINGS_CACHE_KEY, $settings, 60); + } + + return $redis; +} + +function throttle_consumers_identify(string $credential, array $consumer): void +{ + cache()->put(ApiRateLimits::CONSUMER_CACHE_PREFIX . sha1($credential), $consumer, 600); +} + +function throttle_consumers_request(string $uri = '/v1/orders', array $server = [], ?object $user = null): Request +{ + $request = Request::create($uri, 'GET', [], [], [], array_merge(['REMOTE_ADDR' => '10.0.0.5'], $server)); + $request->setRouteResolver(fn () => new Illuminate\Routing\Route(['GET'], ltrim($uri, '/'), fn () => null)); + $request->setUserResolver(fn () => $user); + + return $request; +} + +function throttle_consumers_send(ThrottleRequests $middleware, Request $request): int +{ + try { + return $middleware->handle($request, fn () => new JsonResponse(['ok' => true]))->getStatusCode(); + } catch (ThrottleRequestsException $exception) { + return $exception->getStatusCode(); + } +} + +function throttle_consumers_middleware(): ThrottleRequests +{ + return new ThrottleRequests(new RateLimiter(new CacheRepository(new ArrayStore()))); +} + +/** + * The consumer descriptions the middleware stored, keyed by limiter signature. + */ +function throttle_consumers_recorded(RedisMetricsFake $redis): array +{ + $recorded = []; + foreach ($redis->strings as $key => $value) { + $recorded[substr($key, strlen('api_consumer_metrics:consumer:'))] = json_decode($value, true); + } + + return $recorded; +} + +function throttle_consumers_count(RedisMetricsFake $redis, string $metric): array +{ + return $redis->sortedSets['api_consumer_metrics:m:202607181230:' . $metric] ?? []; +} + +beforeEach(function () { + Carbon::setTestNow(Carbon::parse('2026-07-18 12:30:00', 'UTC')); +}); + +afterEach(function () { + Carbon::setTestNow(); + config(['api.throttle' => array_diff_key((array) config('api.throttle', []), ['track_consumers' => true])]); + app()->forgetInstance('redis'); + Facade::clearResolvedInstances(); +}); + +test('throttle requests counts every request and every rejection per consumer', function () { + $redis = throttle_consumers_fixture(['max_attempts' => 2]); + $middleware = throttle_consumers_middleware(); + throttle_consumers_identify('Bearer flb_live_noisy', ['type' => 'api_key', 'label' => 'Noisy', 'company_uuid' => 'company-acme']); + + $statuses = []; + foreach (range(1, 3) as $i) { + $statuses[] = throttle_consumers_send($middleware, throttle_consumers_request('/v1/orders', ['HTTP_AUTHORIZATION' => 'Bearer flb_live_noisy'])); + } + + $signature = sha1('fleetbase-throttle|v1|credential|Bearer flb_live_noisy'); + + expect($statuses)->toBe([200, 200, 429]) + ->and(throttle_consumers_count($redis, 'hits'))->toBe([$signature => 3]) + ->and(throttle_consumers_count($redis, 'throttled'))->toBe([$signature => 1]) + ->and(throttle_consumers_recorded($redis)[$signature])->toBe([ + 'type' => 'api_key', + 'label' => 'Noisy', + 'company_uuid' => 'company-acme', + 'scope' => 'v1', + 'ip' => '10.0.0.5', + 'limit' => 2, + 'last_seen_at' => '2026-07-18T12:30:00+00:00', + ]); +}); + +test('throttle requests applies organization overrides to the consumer behind a credential', function () { + $redis = throttle_consumers_fixture(ApiRateLimits::normalize([ + 'max_attempts' => 1, + 'overrides' => [ + ['company_uuid' => 'company-partner', 'max_attempts' => 3], + ['company_uuid' => 'company-internal', 'unlimited' => true], + ], + ])); + $middleware = throttle_consumers_middleware(); + throttle_consumers_identify('Bearer flb_live_partner', ['type' => 'api_key', 'label' => 'Partner', 'company_uuid' => 'company-partner']); + throttle_consumers_identify('Bearer flb_live_internal', ['type' => 'api_key', 'label' => 'Internal', 'company_uuid' => 'company-internal']); + + $send = fn (string $credential) => throttle_consumers_send($middleware, throttle_consumers_request('/v1/orders', ['HTTP_AUTHORIZATION' => $credential])); + + $partner = array_map(fn () => $send('Bearer flb_live_partner'), range(1, 4)); + $internal = array_map(fn () => $send('Bearer flb_live_internal'), range(1, 5)); + $other = array_map(fn () => $send('Bearer flb_live_other'), range(1, 2)); + + $recorded = throttle_consumers_recorded($redis); + + expect($partner)->toBe([200, 200, 200, 429]) + ->and($internal)->toBe([200, 200, 200, 200, 200]) + ->and($other)->toBe([200, 429]) + ->and($recorded[sha1('fleetbase-throttle|v1|credential|Bearer flb_live_partner')]['limit'])->toBe(3) + ->and($recorded[sha1('fleetbase-throttle|v1|credential|Bearer flb_live_internal')])->toMatchArray(['label' => 'Internal', 'limit' => null]) + // No cached identity and no database: the credential is reported as unrecognized. + ->and($recorded[sha1('fleetbase-throttle|v1|credential|Bearer flb_live_other')])->toMatchArray(['type' => 'unknown', 'limit' => 1]); +}); + +test('throttle requests still counts consumers when throttling is disabled or the key is unlimited', function () { + $redis = throttle_consumers_fixture(['enabled' => false]); + $middleware = throttle_consumers_middleware(); + + $disabled = throttle_consumers_send($middleware, throttle_consumers_request('/v1/orders', [], new GenericUser(['id' => 'user-1', 'name' => 'Ada', 'company_uuid' => 'company-acme']))); + + expect($disabled)->toBe(200) + ->and(throttle_consumers_recorded($redis)[sha1('fleetbase-throttle|v1|user|user-1')])->toMatchArray([ + 'type' => 'user', + 'label' => 'Ada', + 'company_uuid' => 'company-acme', + 'limit' => null, + ]); + + $redis = throttle_consumers_fixture([], ['api.throttle.unlimited_keys' => ['Bearer load-test']]); + + $unlimited = throttle_consumers_send($middleware, throttle_consumers_request('/v1/orders', ['HTTP_AUTHORIZATION' => 'Bearer load-test'])); + + expect($unlimited)->toBe(200) + ->and(throttle_consumers_recorded($redis)[sha1('fleetbase-throttle|v1|credential|Bearer load-test')])->toMatchArray([ + 'type' => 'unknown', + 'limit' => null, + ]); +}); + +test('throttle requests describes users without a name and anonymous callers by ip', function () { + $redis = throttle_consumers_fixture(); + $middleware = throttle_consumers_middleware(); + + throttle_consumers_send($middleware, throttle_consumers_request('/int/v1/orders', [], new GenericUser(['id' => 'user-2', 'email' => 'grace@example.test']))); + throttle_consumers_send($middleware, throttle_consumers_request('/int/v1/orders', [], new GenericUser(['id' => 'user-3']))); + throttle_consumers_send($middleware, throttle_consumers_request('/', ['REMOTE_ADDR' => '203.0.113.9'])); + + $recorded = throttle_consumers_recorded($redis); + + expect($recorded[sha1('fleetbase-throttle|int|user|user-2')])->toMatchArray(['type' => 'user', 'label' => 'grace@example.test', 'company_uuid' => null, 'scope' => 'int', 'limit' => 120]) + ->and($recorded[sha1('fleetbase-throttle|int|user|user-3')])->toMatchArray(['type' => 'user', 'label' => 'user-3']) + ->and($recorded[sha1('fleetbase-throttle||ip|203.0.113.9')])->toMatchArray([ + 'type' => 'ip', + 'label' => '203.0.113.9', + 'company_uuid' => null, + 'scope' => '', + 'ip' => '203.0.113.9', + ]); +}); + +test('throttle requests skips consumer lookups and metrics when tracking is off and there are no overrides', function () { + $redis = throttle_consumers_fixture(['track_consumers' => false, 'max_attempts' => 1]); + $middleware = throttle_consumers_middleware(); + throttle_consumers_identify('Bearer flb_live_key', ['type' => 'api_key', 'label' => 'Key', 'company_uuid' => 'company-acme']); + + $statuses = [ + throttle_consumers_send($middleware, throttle_consumers_request('/v1/orders', ['HTTP_AUTHORIZATION' => 'Bearer flb_live_key'])), + throttle_consumers_send($middleware, throttle_consumers_request('/v1/orders', ['HTTP_AUTHORIZATION' => 'Bearer flb_live_key'])), + ]; + + expect($statuses)->toBe([200, 429]) + ->and($redis->commands)->toBe([]); +}); diff --git a/tests/Unit/RoutesContractTest.php b/tests/Unit/RoutesContractTest.php index d2da21f9..f7e8fe2e 100644 --- a/tests/Unit/RoutesContractTest.php +++ b/tests/Unit/RoutesContractTest.php @@ -364,4 +364,25 @@ function routes_contract_index(array $rows, string $method, string $uri): int|fa ->and(routes_contract_find($routes, 'GET', 'int/v1/notifications/registry')['action']) ->toBe('Fleetbase\Http\Controllers\Internal\v1\NotificationController@registry'); }); + + test('route file exposes api rate limit administration as protected routes', function () { + $routes = routes_contract_rows(routes_contract_router()); + $controller = 'Fleetbase\\Http\\Controllers\\Internal\\v1\\RateLimitController'; + + $expected = [ + ['GET', 'int/v1/rate-limits/settings', 'getSettings'], + ['POST', 'int/v1/rate-limits/settings', 'saveSettings'], + ['DELETE', 'int/v1/rate-limits/settings', 'resetSettings'], + ['GET', 'int/v1/rate-limits/consumers', 'consumers'], + ['POST', 'int/v1/rate-limits/consumers/{signature}/reset', 'resetConsumer'], + ]; + + foreach ($expected as [$method, $uri, $action]) { + $route = routes_contract_find($routes, $method, $uri); + + expect($route)->not->toBeNull() + ->and($route['action'])->toBe($controller . '@' . $action) + ->and($route['middleware'])->toContain('fleetbase.protected'); + } + }); } diff --git a/tests/Unit/Support/ApiConsumerMetricsTest.php b/tests/Unit/Support/ApiConsumerMetricsTest.php new file mode 100644 index 00000000..6bbedde7 --- /dev/null +++ b/tests/Unit/Support/ApiConsumerMetricsTest.php @@ -0,0 +1,256 @@ +<?php + +use Fleetbase\Support\ApiConsumerMetrics; +use Fleetbase\Tests\Fixtures\Support\RedisMetricsFake; +use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Facade; + +function api_consumer_metrics_redis(array $config = []): RedisMetricsFake +{ + $container = bind_test_container($config); + $redis = new RedisMetricsFake(); + $container->instance('redis', $redis); + Facade::clearResolvedInstance('redis'); + + return $redis; +} + +beforeEach(function () { + Carbon::setTestNow(Carbon::parse('2026-07-18 12:30:45', 'UTC')); +}); + +afterEach(function () { + Carbon::setTestNow(); + config(['api.throttle' => array_diff_key((array) config('api.throttle', []), ['metrics_connection' => true])]); + app()->forgetInstance('redis'); + Facade::clearResolvedInstance('redis'); +}); + +test('api consumer metrics record counts a request into minute and hour buckets and stores the consumer', function () { + $redis = api_consumer_metrics_redis(['api.throttle.metrics_connection' => 'metrics']); + + ApiConsumerMetrics::record('sig-a', ['type' => 'api_key', 'label' => 'Live key']); + + $consumer = json_decode($redis->strings['api_consumer_metrics:consumer:sig-a'], true); + + expect($redis->connections)->toBe(['metrics']) + ->and($redis->sortedSets)->toBe([ + 'api_consumer_metrics:m:202607181230:hits' => ['sig-a' => 1], + 'api_consumer_metrics:h:2026071812:hits' => ['sig-a' => 1], + ]) + ->and($redis->ttls)->toBe([ + 'api_consumer_metrics:m:202607181230:hits' => ApiConsumerMetrics::MINUTE_RETENTION, + 'api_consumer_metrics:h:2026071812:hits' => ApiConsumerMetrics::HOUR_RETENTION, + 'api_consumer_metrics:consumer:sig-a' => ApiConsumerMetrics::HOUR_RETENTION, + ]) + ->and($consumer)->toBe([ + 'type' => 'api_key', + 'label' => 'Live key', + 'last_seen_at' => '2026-07-18T12:30:45+00:00', + ]); +}); + +test('api consumer metrics record counts throttled requests as hits and throttles at an explicit time', function () { + $redis = api_consumer_metrics_redis(); + + ApiConsumerMetrics::record('sig-a', [], true, Carbon::parse('2026-07-18 09:05:00', 'UTC')); + ApiConsumerMetrics::record('sig-a', [], true, Carbon::parse('2026-07-18 09:05:30', 'UTC')); + + expect($redis->connections)->toBe(['cache', 'cache']) + ->and($redis->sortedSets)->toBe([ + 'api_consumer_metrics:m:202607180905:hits' => ['sig-a' => 2], + 'api_consumer_metrics:h:2026071809:hits' => ['sig-a' => 2], + 'api_consumer_metrics:m:202607180905:throttled' => ['sig-a' => 2], + 'api_consumer_metrics:h:2026071809:throttled' => ['sig-a' => 2], + ]); +}); + +test('api consumer metrics record never fails the request when redis is unavailable', function () { + bind_test_container(); + app()->forgetInstance('redis'); + Facade::clearResolvedInstance('redis'); + + ApiConsumerMetrics::record('sig-a', ['type' => 'ip']); + + $redis = api_consumer_metrics_redis(); + $redis->pipelineThrows = true; + + ApiConsumerMetrics::record('sig-a', ['type' => 'ip']); + + expect($redis->commands)->toBe([]); +}); + +test('api consumer metrics top aggregates minute buckets into ranked consumers with details and a timeline', function () { + $redis = api_consumer_metrics_redis(); + $now = Carbon::now(); + + foreach (range(1, 3) as $i) { + ApiConsumerMetrics::record('sig-busy', ['label' => 'Busy key'], false, $now->copy()->subMinutes(2)); + } + ApiConsumerMetrics::record('sig-busy', ['label' => 'Busy key'], false, $now); + ApiConsumerMetrics::record('sig-busy', ['label' => 'Busy key'], true, $now); + ApiConsumerMetrics::record('sig-quiet', ['label' => 'Quiet key'], false, $now); + // Outside a five-minute window. + ApiConsumerMetrics::record('sig-old', ['label' => 'Old key'], false, $now->copy()->subMinutes(10)); + + $top = ApiConsumerMetrics::top(5, 'hits', 50); + + expect($top)->toMatchArray([ + 'available' => true, + 'window' => 5, + 'granularity' => 'minute', + 'sort' => 'hits', + 'total_requests' => 6, + 'total_throttled' => 1, + 'consumer_count' => 2, + ]) + ->and($top['consumers'])->toHaveCount(2) + ->and($top['consumers'][0])->toMatchArray([ + 'label' => 'Busy key', + 'last_seen_at' => '2026-07-18T12:30:45+00:00', + 'signature' => 'sig-busy', + 'hits' => 5, + 'throttled' => 1, + 'peak_per_minute' => 3, + 'share' => 83.33, + 'avg_per_minute' => 1.0, + ]) + ->and($top['consumers'][1])->toMatchArray([ + 'label' => 'Quiet key', + 'signature' => 'sig-quiet', + 'hits' => 1, + 'throttled' => 0, + 'peak_per_minute' => 1, + 'share' => 16.67, + 'avg_per_minute' => 0.2, + ]) + ->and($top['series'])->toBe([ + ['bucket' => '2026-07-18T12:26:00+00:00', 'hits' => 0, 'throttled' => 0], + ['bucket' => '2026-07-18T12:27:00+00:00', 'hits' => 0, 'throttled' => 0], + ['bucket' => '2026-07-18T12:28:00+00:00', 'hits' => 3, 'throttled' => 0], + ['bucket' => '2026-07-18T12:29:00+00:00', 'hits' => 0, 'throttled' => 0], + ['bucket' => '2026-07-18T12:30:00+00:00', 'hits' => 3, 'throttled' => 1], + ]); +}); + +test('api consumer metrics top sorts by throttled counts and honours the limit', function () { + $redis = api_consumer_metrics_redis(); + + ApiConsumerMetrics::record('sig-busy', [], false); + ApiConsumerMetrics::record('sig-busy', [], false); + ApiConsumerMetrics::record('sig-blocked', [], true); + + $byThrottled = ApiConsumerMetrics::top(15, 'throttled', 1); + $byUnknown = ApiConsumerMetrics::top(15, 'bogus', 0); + + expect($byThrottled['sort'])->toBe('throttled') + ->and($byThrottled['consumer_count'])->toBe(2) + ->and(array_column($byThrottled['consumers'], 'signature'))->toBe(['sig-blocked']) + ->and($byUnknown['sort'])->toBe('hits') + ->and(array_column($byUnknown['consumers'], 'signature'))->toBe(['sig-busy']); +}); + +test('api consumer metrics top uses hour buckets for long windows and omits the per minute peak', function () { + $redis = api_consumer_metrics_redis(); + $now = Carbon::now(); + + ApiConsumerMetrics::record('sig-a', [], false, $now->copy()->subHours(5)); + ApiConsumerMetrics::record('sig-a', [], false, $now); + // Older than the six-hour window. + ApiConsumerMetrics::record('sig-a', [], false, $now->copy()->subHours(7)); + + $top = ApiConsumerMetrics::top(360, 'hits', 10, $now); + + expect($top['granularity'])->toBe('hour') + ->and($top['total_requests'])->toBe(2) + ->and($top['series'])->toHaveCount(6) + ->and($top['series'][0])->toBe(['bucket' => '2026-07-18T07:00:00+00:00', 'hits' => 1, 'throttled' => 0]) + ->and($top['series'][5])->toBe(['bucket' => '2026-07-18T12:00:00+00:00', 'hits' => 1, 'throttled' => 0]) + ->and($top['consumers'][0])->toMatchArray([ + 'signature' => 'sig-a', + 'hits' => 2, + 'peak_per_minute' => null, + 'avg_per_minute' => 0.01, + ]); +}); + +test('api consumer metrics top reads flat withscores replies and ignores malformed ones', function () { + $redis = api_consumer_metrics_redis(); + $redis->flatScores = true; + + ApiConsumerMetrics::record('sig-a', ['label' => 'A']); + ApiConsumerMetrics::record('sig-a', ['label' => 'A']); + ApiConsumerMetrics::record('sig-b', ['label' => 'B'], true); + + $flat = ApiConsumerMetrics::top(1); + + $redis->pipelineReply = [false, 'not-a-reply']; + $malformed = ApiConsumerMetrics::top(1); + + expect($flat['total_requests'])->toBe(3) + ->and($flat['total_throttled'])->toBe(1) + ->and(array_column($flat['consumers'], 'hits', 'signature'))->toBe(['sig-a' => 2, 'sig-b' => 1]) + ->and($malformed)->toMatchArray([ + 'available' => true, + 'total_requests' => 0, + 'consumer_count' => 0, + 'consumers' => [], + 'series' => [['bucket' => '2026-07-18T12:30:00+00:00', 'hits' => 0, 'throttled' => 0]], + ]); +}); + +test('api consumer metrics top keeps counts without details when stored details are missing or unreadable', function () { + $redis = api_consumer_metrics_redis(); + + ApiConsumerMetrics::record('sig-a', ['label' => 'A']); + ApiConsumerMetrics::record('sig-b', ['label' => 'B'], true); + $redis->strings['api_consumer_metrics:consumer:sig-a'] = '"not an object"'; + unset($redis->strings['api_consumer_metrics:consumer:sig-b']); + + $withoutDetails = ApiConsumerMetrics::top(1); + + $redis->mgetThrows = true; + $detailsDown = ApiConsumerMetrics::top(1); + + expect($withoutDetails['consumers'][0])->not->toHaveKey('label') + ->and($withoutDetails['consumers'][1])->not->toHaveKey('label') + ->and($withoutDetails['consumers'][1])->toMatchArray(['signature' => 'sig-b', 'hits' => 1, 'throttled' => 1, 'share' => 50.0]) + ->and($detailsDown['consumers'])->toHaveCount(2) + ->and($detailsDown['consumers'][0])->not->toHaveKey('label'); +}); + +test('api consumer metrics top reports throttles without hits as a zero share', function () { + $redis = api_consumer_metrics_redis(); + $redis->pipelineReply = [[], ['sig-a' => '2']]; + + $top = ApiConsumerMetrics::top(0); + + expect($top['window'])->toBe(0) + ->and($top['series'])->toHaveCount(1) + ->and($top['consumers'][0])->toMatchArray([ + 'signature' => 'sig-a', + 'hits' => 0, + 'throttled' => 2, + 'share' => 0, + 'avg_per_minute' => 0.0, + 'peak_per_minute' => null, + ]); +}); + +test('api consumer metrics top reports metrics as unavailable when redis fails', function () { + $redis = api_consumer_metrics_redis(); + $redis->pipelineThrows = true; + + expect(ApiConsumerMetrics::top(15, 'throttled'))->toBe([ + 'available' => false, + 'window' => 15, + 'granularity' => 'minute', + 'sort' => 'throttled', + 'total_requests' => 0, + 'total_throttled' => 0, + 'consumer_count' => 0, + 'consumers' => [], + 'series' => [], + ]) + ->and(ApiConsumerMetrics::top(1440)['granularity'])->toBe('hour'); +}); diff --git a/tests/Unit/Support/ApiRateLimitsTest.php b/tests/Unit/Support/ApiRateLimitsTest.php new file mode 100644 index 00000000..024be780 --- /dev/null +++ b/tests/Unit/Support/ApiRateLimitsTest.php @@ -0,0 +1,403 @@ +<?php + +use Fleetbase\Models\Setting; +use Fleetbase\Models\User; +use Fleetbase\Support\ApiRateLimits; +use Illuminate\Cache\ArrayStore; +use Illuminate\Cache\Repository as CacheRepository; +use Illuminate\Database\Capsule\Manager as Capsule; +use Illuminate\Database\Eloquent\Model as EloquentModel; +use Illuminate\Events\Dispatcher; +use Illuminate\Support\Facades\Facade; + +function api_rate_limits_database(array $config = []): Capsule +{ + EloquentModel::clearBootedModels(); + + $connection = [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]; + + $container = bind_test_container(array_merge([ + 'api.cache.enabled' => false, + 'api.throttle.enabled' => true, + 'api.throttle.max_attempts' => 120, + 'api.throttle.decay_minutes' => 1, + 'api.throttle.track_consumers'=> true, + 'database.default' => 'mysql', + 'database.connections.mysql' => $connection, + 'fleetbase.connection.db' => 'mysql', + ], $config)); + $container->instance('cache', new CacheRepository(new ArrayStore())); + app()->forgetInstance('redis'); + Facade::clearResolvedInstances(); + + $capsule = new Capsule($container); + $capsule->addConnection($connection, 'mysql'); + $capsule->addConnection($connection, 'sandbox'); + $capsule->setEventDispatcher(new Dispatcher($container)); + $capsule->setAsGlobal(); + $capsule->bootEloquent(); + $capsule->getDatabaseManager()->setDefaultConnection('mysql'); + $container->instance('db', $capsule->getDatabaseManager()); + Facade::clearResolvedInstance('db'); + + $schema = $capsule->getConnection('mysql')->getSchemaBuilder(); + $schema->create('settings', function ($table) { + $table->increments('id'); + $table->string('key')->unique(); + $table->text('value')->nullable(); + }); + $schema->create('companies', function ($table) { + $table->string('uuid')->primary(); + $table->string('public_id')->nullable(); + $table->string('name')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + $schema->create('users', function ($table) { + $table->string('uuid')->primary(); + $table->string('company_uuid')->nullable(); + $table->string('name')->nullable(); + $table->string('email')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + $schema->create('personal_access_tokens', function ($table) { + $table->increments('id'); + $table->string('tokenable_type'); + $table->string('tokenable_id'); + $table->string('name')->nullable(); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamps(); + }); + foreach (['mysql', 'sandbox'] as $name) { + $capsule->getConnection($name)->getSchemaBuilder()->create('api_credentials', function ($table) { + $table->string('uuid')->primary(); + $table->string('company_uuid')->nullable(); + $table->string('name')->nullable(); + $table->string('key')->nullable(); + $table->string('secret')->nullable(); + $table->boolean('test_mode')->default(false); + $table->timestamps(); + $table->softDeletes(); + }); + } + + $db = $capsule->getConnection('mysql'); + $db->table('companies')->insert([ + ['uuid' => 'company-acme', 'public_id' => 'company_acme', 'name' => 'Acme'], + ['uuid' => 'company-sandbox', 'public_id' => 'company_sandbox', 'name' => 'Sandbox Org'], + ]); + $db->table('users')->insert([ + ['uuid' => 'user-ada', 'company_uuid' => 'company-acme', 'name' => 'Ada Lovelace', 'email' => 'ada@example.test'], + ['uuid' => 'user-nameless', 'company_uuid' => null, 'name' => null, 'email' => 'nameless@example.test'], + ]); + $db->table('personal_access_tokens')->insert([ + ['id' => 1, 'tokenable_type' => User::class, 'tokenable_id' => 'user-ada', 'name' => 'console', 'token' => hash('sha256', 'ada-plain-token'), 'abilities' => '["*"]'], + ['id' => 2, 'tokenable_type' => User::class, 'tokenable_id' => 'user-nameless', 'name' => 'cli', 'token' => hash('sha256', 'nameless-plain-token'), 'abilities' => '["*"]'], + ['id' => 3, 'tokenable_type' => User::class, 'tokenable_id' => 'user-missing', 'name' => 'orphan', 'token' => hash('sha256', 'orphan-plain-token'), 'abilities' => '["*"]'], + ]); + $db->table('api_credentials')->insert([ + ['uuid' => 'credential-live', 'company_uuid' => 'company-acme', 'name' => 'Production', 'key' => 'flb_live_abcdefghijklmnop', 'secret' => '$live_secret_value', 'test_mode' => 0], + ['uuid' => 'credential-unnamed', 'company_uuid' => 'company-gone', 'name' => '', 'key' => 'flb_live_unnamed_key', 'secret' => '$unnamed_secret', 'test_mode' => 0], + ]); + $capsule->getConnection('sandbox')->table('api_credentials')->insert([ + ['uuid' => 'credential-test', 'company_uuid' => 'company-sandbox', 'name' => 'Sandbox', 'key' => 'flb_test_abcdefghijklmnop', 'secret' => '$test_secret_value', 'test_mode' => 1], + ]); + + return $capsule; +} + +afterEach(function () { + config(['api.throttle' => array_diff_key((array) config('api.throttle', []), ['track_consumers' => true])]); + EloquentModel::clearBootedModels(); + Facade::clearResolvedInstances(); +}); + +test('api rate limits defaults come from the environment configuration', function () { + bind_test_container([ + 'api.throttle.enabled' => false, + 'api.throttle.max_attempts' => '300', + 'api.throttle.decay_minutes' => '5', + 'api.throttle.track_consumers' => false, + ]); + + expect(ApiRateLimits::defaults())->toBe([ + 'enabled' => false, + 'max_attempts' => 300, + 'decay_minutes' => 5, + 'track_consumers' => false, + 'overrides' => [], + ]); +}); + +test('api rate limits settings fall back to the defaults when the cache or database is unavailable', function () { + // The minimal container cache has no remember(), as on a broken install. + bind_test_container([ + 'api.throttle.enabled' => true, + 'api.throttle.max_attempts' => 60, + 'api.throttle.decay_minutes' => 2, + 'api.throttle.track_consumers' => true, + ]); + Facade::clearResolvedInstance('cache'); + + expect(ApiRateLimits::settings())->toBe([ + 'enabled' => true, + 'max_attempts' => 60, + 'decay_minutes' => 2, + 'track_consumers' => true, + 'overrides' => [], + ]); +}); + +test('api rate limits settings apply the stored administrator override and cache it', function () { + $capsule = api_rate_limits_database(); + + expect(ApiRateLimits::settings()['max_attempts'])->toBe(120); + + // Cached: a row written behind the cache's back is not seen until the cache is cleared. + $capsule->getConnection('mysql')->table('settings')->insert([ + 'key' => 'system.rate-limits', + 'value' => json_encode(['max_attempts' => 30, 'overrides' => [['company_uuid' => 'company-acme', 'unlimited' => true]]]), + ]); + + expect(ApiRateLimits::settings()['max_attempts'])->toBe(120); + + cache()->forget(ApiRateLimits::SETTINGS_CACHE_KEY); + + expect(ApiRateLimits::settings())->toBe([ + 'enabled' => true, + 'max_attempts' => 30, + 'decay_minutes' => 1, + 'track_consumers' => true, + 'overrides' => [[ + 'company_uuid' => 'company-acme', + 'unlimited' => true, + 'max_attempts' => null, + 'note' => '', + ]], + ]); +}); + +test('api rate limits settings ignore a stored value that is not an object', function () { + $capsule = api_rate_limits_database(); + $capsule->getConnection('mysql')->table('settings')->insert([ + 'key' => 'system.rate-limits', + 'value' => json_encode('garbage'), + ]); + + expect(ApiRateLimits::settings()['max_attempts'])->toBe(120); +}); + +test('api rate limits store persists normalized settings and makes them effective immediately', function () { + api_rate_limits_database(); + + expect(ApiRateLimits::settings()['max_attempts'])->toBe(120); + + $stored = ApiRateLimits::store([ + 'max_attempts' => 45, + 'decay_minutes' => 0, + 'overrides' => [['company_uuid' => 'company-acme', 'max_attempts' => '500', 'note' => 'Partner']], + ]); + + $expected = [ + 'enabled' => true, + 'max_attempts' => 45, + 'decay_minutes' => 1, + 'track_consumers' => true, + 'overrides' => [[ + 'company_uuid' => 'company-acme', + 'unlimited' => false, + 'max_attempts' => 500, + 'note' => 'Partner', + ]], + ]; + + expect($stored)->toBe($expected) + ->and(Setting::where('key', 'system.rate-limits')->value('value'))->toBe($expected) + ->and(ApiRateLimits::settings())->toBe($expected); +}); + +test('api rate limits reset drops the override and returns the environment defaults', function () { + api_rate_limits_database(); + ApiRateLimits::store(['max_attempts' => 10, 'enabled' => false]); + + expect(ApiRateLimits::settings()['max_attempts'])->toBe(10); + + $reset = ApiRateLimits::reset(); + + expect($reset['max_attempts'])->toBe(120) + ->and($reset['enabled'])->toBeTrue() + ->and(Setting::where('key', 'system.rate-limits')->exists())->toBeFalse(); +}); + +test('api rate limits normalize coerces values and drops overrides without an organization', function () { + expect(ApiRateLimits::normalize([ + 'enabled' => 0, + 'max_attempts' => '-5', + 'decay_minutes' => '3', + 'track_consumers' => '', + 'overrides' => [ + ['company_uuid' => 'company-a', 'max_attempts' => 'lots', 'unlimited' => 1, 'note' => null], + ['company_uuid' => ''], + ['company_uuid' => 42], + ['max_attempts' => 5], + ['company_uuid' => 'company-b', 'max_attempts' => 0], + // A later override for the same organization replaces the earlier one. + ['company_uuid' => 'company-a', 'max_attempts' => 7], + ], + ]))->toBe([ + 'enabled' => false, + 'max_attempts' => 1, + 'decay_minutes' => 3, + 'track_consumers' => false, + 'overrides' => [ + ['company_uuid' => 'company-a', 'unlimited' => false, 'max_attempts' => 7, 'note' => ''], + ['company_uuid' => 'company-b', 'unlimited' => false, 'max_attempts' => 1, 'note' => ''], + ], + ]) + ->and(ApiRateLimits::normalize([]))->toBe([ + 'enabled' => true, + 'max_attempts' => 120, + 'decay_minutes' => 1, + 'track_consumers' => true, + 'overrides' => [], + ]); +}); + +test('api rate limits limit for applies organization overrides and lifts unlimited ones', function () { + $settings = ApiRateLimits::normalize([ + 'max_attempts' => 100, + 'overrides' => [ + ['company_uuid' => 'company-raised', 'max_attempts' => 1000], + ['company_uuid' => 'company-default'], + ['company_uuid' => 'company-unlimited', 'unlimited' => true, 'max_attempts' => 5], + ], + ]); + + expect(ApiRateLimits::limitFor(null, $settings))->toBe(100) + ->and(ApiRateLimits::limitFor('company-other', $settings))->toBe(100) + ->and(ApiRateLimits::limitFor('company-raised', $settings))->toBe(1000) + ->and(ApiRateLimits::limitFor('company-default', $settings))->toBe(100) + ->and(ApiRateLimits::limitFor('company-unlimited', $settings))->toBeNull(); +}); + +test('api rate limits limit for reads the effective settings when none are given', function () { + api_rate_limits_database(); + ApiRateLimits::store(['max_attempts' => 80, 'overrides' => [['company_uuid' => 'company-acme', 'unlimited' => true]]]); + + expect(ApiRateLimits::limitFor(null))->toBe(80) + ->and(ApiRateLimits::limitFor('company-acme'))->toBeNull(); +}); + +test('api rate limits identify resolves live and sandbox api keys by key or secret', function () { + api_rate_limits_database(); + + expect(ApiRateLimits::identify('Bearer flb_live_abcdefghijklmnop'))->toBe([ + 'type' => 'api_key', + 'label' => 'Production', + 'detail' => 'flb_live_abc…', + 'test_mode' => false, + 'credential_uuid' => 'credential-live', + 'company_uuid' => 'company-acme', + 'company_id' => 'company_acme', + 'company_name' => 'Acme', + ]) + ->and(ApiRateLimits::identify('Bearer $live_secret_value')['credential_uuid'])->toBe('credential-live') + // A secret carries no mode prefix, so a live miss falls through to the sandbox. + ->and(ApiRateLimits::identify('Bearer $test_secret_value')['credential_uuid'])->toBe('credential-test') + ->and(ApiRateLimits::identify('Bearer flb_test_abcdefghijklmnop'))->toBe([ + 'type' => 'api_key', + 'label' => 'Sandbox', + 'detail' => 'flb_test_abc…', + 'test_mode' => true, + 'credential_uuid' => 'credential-test', + 'company_uuid' => 'company-sandbox', + 'company_id' => 'company_sandbox', + 'company_name' => 'Sandbox Org', + ]) + // An unnamed key whose organization no longer exists keeps the raw organization id. + ->and(ApiRateLimits::identify('flb_live_unnamed_key'))->toMatchArray([ + 'label' => 'API key', + 'company_uuid' => 'company-gone', + 'company_id' => null, + 'company_name' => null, + ]); +}); + +test('api rate limits identify resolves sanctum personal access tokens to their user', function () { + api_rate_limits_database(); + + expect(ApiRateLimits::identify('Bearer 1|ada-plain-token'))->toBe([ + 'type' => 'token', + 'label' => 'Ada Lovelace', + 'detail' => 'console', + 'test_mode' => false, + 'credential_uuid' => null, + 'company_uuid' => 'company-acme', + 'company_id' => 'company_acme', + 'company_name' => 'Acme', + ]) + ->and(ApiRateLimits::identify('Bearer 2|nameless-plain-token'))->toMatchArray([ + 'type' => 'token', + 'label' => 'nameless@example.test', + 'detail' => 'cli', + 'company_uuid' => null, + 'company_id' => null, + ]) + ->and(ApiRateLimits::identify('Bearer 3|orphan-plain-token')['type'])->toBe('unknown') + ->and(ApiRateLimits::identify('Bearer 1|wrong-plain-token')['type'])->toBe('unknown'); +}); + +test('api rate limits identify describes unknown credentials without revealing them and caches the answer', function () { + $capsule = api_rate_limits_database(); + + $unknown = ApiRateLimits::identify('Bearer flb_live_unknown_credential'); + + expect($unknown)->toBe([ + 'type' => 'unknown', + 'label' => 'Unrecognized credential', + 'detail' => 'flb_live_unk…', + 'test_mode' => false, + 'credential_uuid' => null, + 'company_uuid' => null, + 'company_id' => null, + 'company_name' => null, + ]) + ->and(ApiRateLimits::identify('Basic:someone')['detail'])->toBe('Basic:…'); + + // A credential created after its first lookup stays unknown until the cache expires. + $capsule->getConnection('mysql')->table('api_credentials')->insert([ + 'uuid' => 'credential-late', 'company_uuid' => 'company-acme', 'name' => 'Late', 'key' => 'flb_live_unknown_credential', 'secret' => '$late', 'test_mode' => 0, + ]); + + expect(ApiRateLimits::identify('Bearer flb_live_unknown_credential')['type'])->toBe('unknown'); + + cache()->forget(ApiRateLimits::CONSUMER_CACHE_PREFIX . sha1('Bearer flb_live_unknown_credential')); + + expect(ApiRateLimits::identify('Bearer flb_live_unknown_credential')['label'])->toBe('Late'); +}); + +test('api rate limits identify reports an unknown consumer when the lookup fails', function () { + bind_test_container(); + Facade::clearResolvedInstance('cache'); + + expect(ApiRateLimits::identify('Bearer flb_live_abcdefghijklmnop'))->toMatchArray([ + 'type' => 'unknown', + 'detail' => 'flb_live_abc…', + ]) + ->and(ApiRateLimits::identify('Bearer ')['detail'])->toBe('Bea…'); +}); + +test('api rate limits mask keeps at most half of a value and never more than twelve characters', function () { + expect(ApiRateLimits::mask(null))->toBeNull() + ->and(ApiRateLimits::mask(''))->toBeNull() + ->and(ApiRateLimits::mask('abcd'))->toBe('ab…') + ->and(ApiRateLimits::mask('flb_live_abcdefghijklmnop'))->toBe('flb_live_abc…'); +});