diff --git a/src/Http/Middleware/LogApiRequests.php b/src/Http/Middleware/LogApiRequests.php index 7405ecf7..9e2714e5 100644 --- a/src/Http/Middleware/LogApiRequests.php +++ b/src/Http/Middleware/LogApiRequests.php @@ -59,7 +59,10 @@ public function handle($request, \Closure $next) */ protected function shouldLog($request): bool { - if (!Http::isPublicRequest($request)) { + // Every route that runs this middleware opted in to logging through its + // middleware group (core's public API or an extension's), so only skip + // requests without a route and internal routes such as "int/v1/..." + if ($request->route() === null || Http::isInternalRequest($request)) { return false; } diff --git a/src/Jobs/LogApiRequest.php b/src/Jobs/LogApiRequest.php index 2699eabf..5049d13f 100644 --- a/src/Jobs/LogApiRequest.php +++ b/src/Jobs/LogApiRequest.php @@ -25,6 +25,16 @@ class LogApiRequest implements ShouldQueue use Queueable; use SerializesModels; + /** + * Request attribute holding ids to add to the log's related column. + * + * Extensions set it from their middleware so a logged request can be traced + * to the resource behind it, for example the owner of the key that made it. + * + * @var string + */ + public const RELATED_ATTRIBUTE = 'api_request_log_related'; + /** * The API Request/JsonResponse payload. * @@ -82,6 +92,13 @@ public static function getPayload(Request $request, $response): array $related[] = Utils::get($content, 'id'); } + // Push ids that middleware related to this request, such as the resource whose key made it + foreach ((array) $request->attributes->get(static::RELATED_ATTRIBUTE, []) as $id) { + if ($id) { + $related[] = $id; + } + } + // Get api credential from session $apiCredential = session('api_credential'); diff --git a/src/Support/ApiModelCache.php b/src/Support/ApiModelCache.php index 1c272fe7..7e598aba 100644 --- a/src/Support/ApiModelCache.php +++ b/src/Support/ApiModelCache.php @@ -843,7 +843,7 @@ public static function getRelationshipTtl(): int protected static function getCompanyUuid(Request $request): ?string { // Try to get from session - if ($request->session()->has('company')) { + if ($request->hasSession() && $request->session()->has('company')) { return $request->session()->get('company'); } diff --git a/tests/Unit/Http/MiddlewareContractsTest.php b/tests/Unit/Http/MiddlewareContractsTest.php index 111efe5f..5da56557 100644 --- a/tests/Unit/Http/MiddlewareContractsTest.php +++ b/tests/Unit/Http/MiddlewareContractsTest.php @@ -660,15 +660,27 @@ function middleware_contracts_log_middleware(bool $enabled = true): LogApiReques middleware_contracts_mutation_request('/int/v1/orders', 'int/v1/orders'), fn () => new JsonResponse(['id' => 'order_2'], 201) ); + $storefrontInternal = middleware_contracts_log_middleware()->handle( + middleware_contracts_mutation_request('/storefront/int/v1/orders', 'storefront/int/v1/orders'), + fn () => new JsonResponse(['id' => 'order_3'], 201) + ); $excluded = middleware_contracts_log_middleware()->handle( middleware_contracts_mutation_request('/v1/drivers/driver_1/track', 'v1/drivers/{driver}/track'), fn () => new JsonResponse(['id' => 'track_1'], 201) ); + $unroutedRequest = middleware_contracts_mutation_request('/v1/orders', 'v1/orders'); + $unroutedRequest->setRouteResolver(fn () => null); + $unrouted = middleware_contracts_log_middleware()->handle( + $unroutedRequest, + fn () => new JsonResponse(['id' => 'order_4'], 201) + ); expect($disabled->getStatusCode())->toBe(201) ->and($read->getStatusCode())->toBe(204) ->and($internal->getStatusCode())->toBe(201) + ->and($storefrontInternal->getStatusCode())->toBe(201) ->and($excluded->getStatusCode())->toBe(201) + ->and($unrouted->getStatusCode())->toBe(201) ->and($bus->jobs)->toBeEmpty(); }); @@ -712,6 +724,38 @@ function middleware_contracts_log_middleware(bool $enabled = true): LogApiReques ]); }); + test('log api requests dispatches extension mutation request logs with related ids from the request', function () { + middleware_contracts_fixture(['api.version' => 'v1']); + session([ + 'company' => 'company-1', + 'api_credential' => 'store_live_key', + ]); + $bus = new MiddlewareContractsBusFake(); + app()->instance('bus', $bus); + app()->instance(BusDispatcher::class, $bus); + Facade::clearResolvedInstance('bus'); + Facade::clearResolvedInstance(BusDispatcher::class); + + $request = middleware_contracts_mutation_request('/storefront/v1/customers/request-creation-code', 'storefront/v1/customers/request-creation-code'); + $request->attributes->set(Fleetbase\Jobs\LogApiRequest::RELATED_ATTRIBUTE, ['store_abc123']); + + $response = middleware_contracts_log_middleware()->handle( + $request, + fn () => new JsonResponse(['status' => 'ok'], 200) + ); + + expect($response->getStatusCode())->toBe(200) + ->and($bus->jobs)->toHaveCount(1) + ->and($bus->jobs[0])->toBeInstanceOf(Fleetbase\Jobs\LogApiRequest::class) + ->and($bus->jobs[0]->payload)->toMatchArray([ + 'company_uuid' => 'company-1', + 'method' => 'POST', + 'path' => 'storefront/v1/customers/request-creation-code', + 'status_code' => 200, + 'related' => ['store_abc123'], + ]); + }); + test('log api requests swallows dispatch errors and keeps original response', function () { middleware_contracts_fixture(['api.version' => 'v1']); $bus = new MiddlewareContractsBusFake(); diff --git a/tests/Unit/Jobs/LogApiRequestTest.php b/tests/Unit/Jobs/LogApiRequestTest.php index 1bcdc8ea..eb55e01b 100644 --- a/tests/Unit/Jobs/LogApiRequestTest.php +++ b/tests/Unit/Jobs/LogApiRequestTest.php @@ -224,6 +224,30 @@ function loggable_request(float $startedAt): Request ->and($payload)->not->toHaveKeys(['api_credential_uuid', 'access_token_id']); }); + it('adds ids that middleware related to the request to the payload', function () { + bind_test_container(['api.version' => 'v1']); + session([ + 'api_credential' => 'store_live_key', + 'company' => 'company-1', + ]); + + $related = loggable_request(microtime(true) - 0.01); + $related->attributes->set(LogApiRequest::RELATED_ATTRIBUTE, ['store_abc123', null, '']); + + $single = loggable_request(microtime(true) - 0.01); + $single->attributes->set(LogApiRequest::RELATED_ATTRIBUTE, 'network_xyz789'); + + $relatedPayload = LogApiRequest::getPayload($related, new JsonResponse(['id' => 'customer_1'], 201)); + $singlePayload = LogApiRequest::getPayload($single, new JsonResponse(['status' => 'ok'], 200)); + $plainPayload = LogApiRequest::getPayload(loggable_request(microtime(true) - 0.01), new JsonResponse(['status' => 'ok'], 200)); + + expect($relatedPayload['related'])->toBe(['customer_1', 'store_abc123']) + ->and($relatedPayload['company_uuid'])->toBe('company-1') + ->and($relatedPayload)->not->toHaveKeys(['api_credential_uuid', 'access_token_id']) + ->and($singlePayload['related'])->toBe(['network_xyz789']) + ->and($plainPayload['related'])->toBe([]); + }); + it('persists API request logs on the selected connection and clears response cache', function () { $database = log_api_request_database(); $cache = new LogApiRequestResponseCacheFake(); diff --git a/tests/Unit/Support/ApiModelCacheTest.php b/tests/Unit/Support/ApiModelCacheTest.php index 9d064e84..d302cab1 100644 --- a/tests/Unit/Support/ApiModelCacheTest.php +++ b/tests/Unit/Support/ApiModelCacheTest.php @@ -206,6 +206,24 @@ class ApiModelCachePartlyResolvableDependencyOrder extends ApiModelCacheRelation protected $apiCacheDependencies = ['tags', 'payload.customer']; } +class ApiModelCacheUncallableRelationsOrder extends ApiModelCacheRelationsOrder +{ + public static function staticPayload(): BelongsTo + { + return (new static())->belongsTo(ApiModelCacheDependencyPayload::class, 'payload_uuid', 'uuid'); + } + + public function payloadFor(string $key): BelongsTo + { + return $this->belongsTo(ApiModelCacheDependencyPayload::class, $key, 'uuid'); + } + + protected function hiddenPayload(): BelongsTo + { + return $this->belongsTo(ApiModelCacheDependencyPayload::class, 'payload_uuid', 'uuid'); + } +} + class ApiModelCacheTestLock { public function __construct(private ApiModelCacheTestStore $store) @@ -987,6 +1005,41 @@ public function gc(int $max_lifetime): int|false app()->forgetInstance('db.transactions'); }); +test('api model cache keeps the immediate bump when the transactions manager cannot be resolved', function () { + $capsule = api_model_cache_dependency_database(); + $store = app('cache'); + $store->flushRemovesValues = false; + $connection = $capsule->getConnection('mysql'); + app()->bind('db.transactions', fn () => throw new RuntimeException('transactions manager unavailable')); + + $connection->beginTransaction(); + ApiModelCache::invalidateModelCache(new ApiModelCacheDependencyPayload(), 'company-1'); + $connection->commit(); + + app()->offsetUnset('db.transactions'); + + expect($store->get('api_query_version:payloads:company-1'))->toBe(1); +}); + +test('api model cache only resolves public instance relation methods without required parameters', function () { + api_model_cache_dependency_database(); + $order = new ApiModelCacheUncallableRelationsOrder(); + + expect(ApiModelCache::dependencyTables($order, ['static_payload', 'payload_for', 'hidden_payload']))->toBe([]) + ->and(ApiModelCache::dependencyTables($order, ['payload']))->toBe(['payloads']); +}); + +test('api model cache clears the relation path memo once it reaches its limit', function () { + api_model_cache_dependency_database(); + $memo = new ReflectionProperty(ApiModelCache::class, 'dependencyTables'); + $limit = (new ReflectionClassConstant(ApiModelCache::class, 'DEPENDENCY_MEMO_LIMIT'))->getValue(); + $memo->setAccessible(true); + $memo->setValue(null, array_fill_keys(array_map(fn ($i) => "memo-{$i}", range(1, $limit)), [])); + + expect(ApiModelCache::dependencyTables(new ApiModelCacheRelationsOrder(), ['payload']))->toBe(['payloads']) + ->and($memo->getValue())->toHaveCount(1); +}); + test('api model cache resolves request style relation paths without side effects on the model', function () { api_model_cache_dependency_database(); $order = new ApiModelCacheRelationsOrder(); @@ -1182,6 +1235,23 @@ public function gc(int $max_lifetime): int|false ->and($key(['customer' => 'customer-a', 'storefront_store' => 'store-a']))->toBe($key(['customer' => 'customer-a', 'storefront_store' => 'store-b'])); }); +test('api model cache keys requests without a session by company input and leaves them unscoped', function () { + api_model_cache_fixture(); + $model = api_model_cache_model(); + + $sessionless = ApiModelCache::generateQueryCacheKey($model, Request::create('/v1/orders', 'GET', [ + 'status' => 'active', + 'company_uuid' => 'company-1', + ])); + $unscoped = ApiModelCache::generateQueryCacheKey($model, api_model_cache_scoped_request('/v1/orders', [], [ + 'status' => 'active', + 'company_uuid' => 'company-1', + ])); + + expect($sessionless)->toStartWith('{api_query}:orders:company_company-1:v0.0:') + ->and($sessionless)->toBe($unscoped); +}); + test('api model cache resolves tenant scope from authenticated user and request input fallbacks', function () { api_model_cache_fixture(); $model = api_model_cache_model();