From 69df6f488671aab5b25ef6e66b8749f533d11056 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 29 Sep 2026 14:57:36 +0800 Subject: [PATCH 1/2] fix(throttle): key the API rate limiter on the consumer, not the proxy IP ThrottleRequests runs before API authentication, so Laravel's default signature always fell back to route domain + client IP. Behind a load balancer that IP is the balancer's, and no route has a domain, so every tenant, API key and console visitor shared one bucket: one busy integration returned 429 to the whole platform. Key the limiter on the hashed credential (API key, Sanctum token, basic auth), falling back to the user and then the IP only when none is sent, and scope it by first path segment so /v1 and /int stay separate. Also keep Retry-After and X-RateLimit-* on the 429 response so throttled clients know when to retry. --- src/Exceptions/Handler.php | 3 +- src/Http/Middleware/ThrottleRequests.php | 33 +++++++++++ .../Unit/Exceptions/ExceptionHandlerTest.php | 16 +++++ tests/Unit/Http/MiddlewareContractsTest.php | 59 +++++++++++++++++++ 4 files changed, 110 insertions(+), 1 deletion(-) 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/Middleware/ThrottleRequests.php b/src/Http/Middleware/ThrottleRequests.php index 3f32b83c..bb00885a 100644 --- a/src/Http/Middleware/ThrottleRequests.php +++ b/src/Http/Middleware/ThrottleRequests.php @@ -59,6 +59,39 @@ public function handle($request, \Closure $next, $maxAttempts = null, $decayMinu return parent::handle($request, $next, $maxAttempts, $decayMinutes, $prefix); } + /** + * 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 sha1($scope . '|ip|' . $request->ip()); + } + /** * Extract API key from the request. * 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(); From 76b8e5535482d4f89cf65d0e31728bfdda332d5b Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 29 Sep 2026 15:48:24 +0800 Subject: [PATCH 2/2] feat(throttle): admin-managed rate limits, org overrides and API consumer metrics - ApiRateLimits: effective limits from env defaults plus a system.rate-limits setting an admin can change at runtime; per-organization overrides (custom limit or unlimited); credential -> organization lookup cached per key. - ApiConsumerMetrics: per-consumer request and 429 counts in Redis (minute buckets for 2h, hour buckets for 8 days), recorded by the throttle middleware so throttled requests are visible too. - RateLimitController + int/v1/rate-limits routes (admin only): get/save/reset settings, top consumers by window, clear a consumer's limiter window. - ThrottleRequests applies overrides and records every request. --- config/api.php | 9 + .../Internal/v1/RateLimitController.php | 117 +++++ src/Http/Middleware/ThrottleRequests.php | 95 ++++- src/Support/ApiConsumerMetrics.php | 251 +++++++++++ src/Support/ApiRateLimits.php | 247 +++++++++++ src/routes.php | 7 + tests/Fixtures/Support/RedisMetricsFake.php | 132 ++++++ tests/Unit/Http/RateLimitControllerTest.php | 247 +++++++++++ .../Http/ThrottleRequestsConsumersTest.php | 213 +++++++++ tests/Unit/RoutesContractTest.php | 21 + tests/Unit/Support/ApiConsumerMetricsTest.php | 256 +++++++++++ tests/Unit/Support/ApiRateLimitsTest.php | 403 ++++++++++++++++++ 12 files changed, 1988 insertions(+), 10 deletions(-) create mode 100644 src/Http/Controllers/Internal/v1/RateLimitController.php create mode 100644 src/Support/ApiConsumerMetrics.php create mode 100644 src/Support/ApiRateLimits.php create mode 100644 tests/Fixtures/Support/RedisMetricsFake.php create mode 100644 tests/Unit/Http/RateLimitControllerTest.php create mode 100644 tests/Unit/Http/ThrottleRequestsConsumersTest.php create mode 100644 tests/Unit/Support/ApiConsumerMetricsTest.php create mode 100644 tests/Unit/Support/ApiRateLimitsTest.php 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/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 bb00885a..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,79 @@ 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 parent::handle($request, $next, $maxAttempts, $decayMinutes, $prefix); + return ['type' => 'ip', 'label' => $request->ip(), 'company_uuid' => null]; } /** 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/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…'); +});