From 8e8ec0254de1222c39a69450f6963fa35d54a355 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 15 Oct 2026 12:21:46 +0800 Subject: [PATCH 01/12] fix(events): publish driver-assigned records on driver.{id} Lifecycle events for records assigned to a driver (orders, and anything with a driver_assigned_uuid) went only to driverAssigned.{uuid} and driverAssigned.{public_id}. Clients that follow the {type}.{id} convention, such as the Navigator driver app and the console scheduler, listen on driver.{public_id} and never received them. They now also go to driver.{public_id} and driver.{uuid}, where the driver's location and geofence events already arrive. driverAssigned.* is still published for existing subscribers and is marked deprecated. broadcastOn() now returns each channel name once. --- src/Events/ResourceLifecycleEvent.php | 48 ++++++++++++++--- tests/Unit/EventsAndExceptionsTest.php | 72 +++++++++++++++++++++++++- 2 files changed, 111 insertions(+), 9 deletions(-) diff --git a/src/Events/ResourceLifecycleEvent.php b/src/Events/ResourceLifecycleEvent.php index 5a9e444c..b6224500 100644 --- a/src/Events/ResourceLifecycleEvent.php +++ b/src/Events/ResourceLifecycleEvent.php @@ -156,7 +156,26 @@ public function broadcastOn(): array|Channel $this->addRelationshipSpecificChannels($model, $channels); $this->addChatChannels($model, $channels); - return $channels; + return static::uniqueChannels($channels); + } + + /** + * Drops channels whose name is already in the list, keeping the first, so the event goes out + * once per channel name (e.g. `company.{uuid}` from both the session and the model, or a + * driver's own `driver.{id}` channel). + * + * @param array $channels + * + * @return array + */ + public static function uniqueChannels(array $channels): array + { + $unique = []; + foreach ($channels as $channel) { + $unique[(string) $channel] ??= $channel; + } + + return array_values($unique); } /** @@ -218,20 +237,33 @@ protected function addApiCredentialChannel($model, &$channels) /** * Adds channels related to relationships defined in the model such as driver, customer, facilitator, and vendor. * + * A record assigned to a driver is published on the driver's own channel (`driver.{public_id}` + * and `driver.{uuid}`), next to the driver's location and geofence events. + * + * Deprecated: `driverAssigned.{uuid}` and `driverAssigned.{public_id}` are still published so + * existing subscribers keep working. New clients should subscribe to `driver.{public_id}`. + * * @param object $model the model related to the event * @param array $channels reference to the channels array */ protected function addRelationshipSpecificChannels($model, &$channels) { - $relationships = ['driverAssigned', 'customer', 'facilitator', 'vendor']; - foreach ($relationships as $relationship) { + $relationships = [ + 'driverAssigned' => ['driver', 'driverAssigned'], + 'customer' => ['customer'], + 'facilitator' => ['facilitator'], + 'vendor' => ['vendor'], + ]; + foreach ($relationships as $relationship => $prefixes) { // Foreign keys are snake case (`driver_assigned_uuid`), the relationship names are camel case. $foreignKey = Str::snake($relationship) . '_uuid'; - if ($model && isset($model->{$foreignKey})) { - $channels[] = new Channel($relationship . '.' . $model->{$foreignKey}); - } - if ($model && isset($model->{$relationship})) { - $channels[] = new Channel($relationship . '.' . $model->{$relationship}->public_id); + foreach ($prefixes as $prefix) { + if ($model && isset($model->{$foreignKey})) { + $channels[] = new Channel($prefix . '.' . $model->{$foreignKey}); + } + if ($model && isset($model->{$relationship})) { + $channels[] = new Channel($prefix . '.' . $model->{$relationship}->public_id); + } } } diff --git a/tests/Unit/EventsAndExceptionsTest.php b/tests/Unit/EventsAndExceptionsTest.php index 1a1e5274..72553dbe 100644 --- a/tests/Unit/EventsAndExceptionsTest.php +++ b/tests/Unit/EventsAndExceptionsTest.php @@ -759,6 +759,8 @@ public function toArray($request): array ->and($channels)->toContain('company.company_7654321') ->and($channels)->toContain('chat_channel.chat_7654321') ->and($channels)->toContain('chat_channel.33333333-3333-4333-8333-333333333333') + ->and($channels)->toContain('driver.driver-assigned-uuid') + ->and($channels)->toContain('driver.user_7654321') ->and($channels)->toContain('driverAssigned.driver-assigned-uuid') ->and($channels)->toContain('driverAssigned.user_7654321') ->and($channels)->toContain('customer.customer-uuid') @@ -813,12 +815,80 @@ public function toArray($request): array $channels = array_map(fn ($channel) => (string) $channel, $event->broadcastOn()); - expect($channels)->toContain('driverAssigned.driver-uuid-only') + expect($channels)->toContain('driver.driver-uuid-only') + ->and($channels)->toContain('driverAssigned.driver-uuid-only') ->and($channels)->toContain('customer.customer-uuid-only') + ->and($channels)->not->toContain('driver.') ->and($channels)->not->toContain('driverAssigned.') ->and($channels)->not->toContain('customer.'); }); +test('resource lifecycle events publish each channel name once', function () { + bind_test_container(); + session()->flush(); + // The session company and the model's company are the same uuid, so `company.{uuid}` is + // produced twice before deduplication. + session(['company' => 'same-company', 'user' => 'session-user']); + + $driver = new User(); + $driver->setRawAttributes(['public_id' => 'driver_abc123'], true); + + $channel = new ChatChannel(); + $channel->setRawAttributes([ + 'uuid' => '55555555-5555-4555-8555-555555555555', + 'public_id' => 'chat_2222222', + 'company_uuid' => 'same-company', + 'created_by_uuid' => 'creator-user', + 'driver_assigned_uuid' => 'driver-uuid', + ], true); + $channel->setRelation('company', null); + $channel->setRelation('driverAssigned', $driver); + + $event = EventsAndExceptionsLifecycleEvent::fake([ + 'modelName' => 'chat_channel', + 'modelClassNamespace' => ChatChannel::class, + 'modelClassName' => 'ChatChannel', + 'modelHumanName' => 'chat channel', + 'modelRecordName' => null, + 'modelUuid' => '55555555-5555-4555-8555-555555555555', + 'namespace' => '\\Fleetbase', + 'version' => 1, + 'eventName' => 'updated', + 'sentAt' => '2026-10-15 12:00:00', + 'eventId' => 'event_unique_channels', + 'apiVersion' => 'v1', + 'requestMethod' => 'PUT', + 'apiCredential' => 'console', + 'apiSecret' => 'internal', + 'apiKey' => null, + 'apiEnvironment' => 'live', + 'isSandbox' => false, + 'data' => [], + 'userSession' => 'session-user', + 'companySession' => 'same-company', + ], $channel); + + $broadcastOn = $event->broadcastOn(); + $channels = array_map(fn ($channel) => (string) $channel, $broadcastOn); + + expect($broadcastOn)->each->toBeInstanceOf(Channel::class) + ->and($channels)->toBe(array_values(array_unique($channels))) + ->and(array_count_values($channels)['company.same-company'])->toBe(1) + ->and($channels)->toContain('driver.driver_abc123') + ->and($channels)->toContain('driver.driver-uuid') + ->and($channels)->toContain('driverAssigned.driver_abc123') + ->and($channels)->toContain('driverAssigned.driver-uuid'); +}); + +test('unique channels keeps the first channel of each name in order', function () { + $first = new Channel('driver.driver_abc123'); + $second = new Channel('order.order_abc123'); + + expect(ResourceLifecycleEvent::uniqueChannels([$first, $second, new Channel('driver.driver_abc123'), $second])) + ->toBe([$first, $second]) + ->and(ResourceLifecycleEvent::uniqueChannels([]))->toBe([]); +}); + test('resource lifecycle events prefer webhook payloads when resources provide them', function () { bind_test_container(['api.version' => 'v1']); From 777c68d9b3af8058e01972a7e32254e54e75e6e1 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 15 Oct 2026 13:38:04 +0800 Subject: [PATCH 02/12] fix: key API model caches by database connection so live and sandbox never share entries Sandbox requests switch models to the sandbox database without any credential, company or path that tells them apart, so list, model and relationship cache keys collided: test mode served cached live data and live could be served sandbox data. Every key now carries the connection the model reads. Versions stay shared, since a list key carries versions of tables other models write and a cross-connection write only over-invalidates. Co-Authored-By: Claude --- src/Support/ApiModelCache.php | 55 +++++-- tests/Unit/Support/ApiModelCacheTest.php | 178 ++++++++++++++++++++--- 2 files changed, 203 insertions(+), 30 deletions(-) diff --git a/src/Support/ApiModelCache.php b/src/Support/ApiModelCache.php index 7e598aba..5842b8e6 100644 --- a/src/Support/ApiModelCache.php +++ b/src/Support/ApiModelCache.php @@ -39,6 +39,16 @@ * an already finished scan, so RedisTaggedCache::flush() deletes only the tag * sets and leaves every tagged value in Redis until its TTL expires. Bumping the * version moves readers to new keys and leaves the orphaned entries to expire. + * + * Every query, model and relationship key also carries the connection its model + * reads (see connectionName()). Sandbox requests (`Access-Console-Sandbox`, + * `flb_test_` keys) switch models to the separate sandbox database without any + * credential, company or path that would tell the two apart, and integer ids and + * synced uuids exist in both databases, so without it live and sandbox requests + * shared entries. Versions stay shared across connections: a list key also + * carries the versions of the tables it embeds, which other models (possibly on + * other connections) write, and a version is only known by table name. A write on + * either connection retiring the other's entries only over-invalidates. */ class ApiModelCache { @@ -111,11 +121,13 @@ class ApiModelCache * endpoints (which paginate and eager load differently) and endpoints whose * callbacks read route parameters; the session scope separates API keys, stores * and networks of one company. Both are hashed, so credentials never appear in - * the key or in the X-Cache-Key debug header. + * the key or in the X-Cache-Key debug header. The model's connection separates + * live from sandbox data. */ public static function generateQueryCacheKey(Model $model, Request $request, array $additionalParams = []): string { $table = $model->getTable(); + $connection = static::connectionName($model); $companyUuid = static::getCompanyUuid($request); // Get ALL query parameters from the request @@ -164,14 +176,29 @@ public static function generateQueryCacheKey(Model $model, Request $request, arr // Add Redis hash tag {api_query} for Redis Cluster shard routing // Include version to ensure cache keys change after writes - return "{api_query}:{$table}:{$companyPart}:{$version}:{$paramsHash}"; + return "{api_query}:{$connection}:{$table}:{$companyPart}:{$version}:{$paramsHash}"; + } + + /** + * Get the database connection a model reads, as cache keys carry it. + * + * Live and sandbox models share tables, ids, synced uuids, companies and + * request paths; the connection is the only thing that tells them apart. + * Fleetbase models resolve it from `fleetbase.connection.db` at call time, + * models without a connection fall back to the default connection, both of + * which Auth::setSandboxSession() switches to the sandbox. + */ + public static function connectionName(Model $model): string + { + return $model->getConnectionName() ?? config('database.default') ?? 'default'; } /** * Cache key holding the query version for a table/company. * * A null company is the table wide version, which every company's list keys - * include, so writes that know no company invalidate all of them. + * include, so writes that know no company invalidate all of them. Versions are + * shared by all connections (see the class docblock). */ protected static function queryVersionKey(string $table, ?string $companyUuid): string { @@ -478,14 +505,15 @@ protected static function bumpVersion(string $versionKey): void */ public static function generateModelCacheKey(Model $model, $id, array $with = []): string { - $table = $model->getTable(); - $withHash = !empty($with) ? ':' . md5(json_encode($with)) : ''; - $tables = [$table, ...static::dependencyTables($model, static::relationPaths($with))]; - $version = static::versionSegment(array_map(fn ($versionTable) => static::modelVersionKey($versionTable), $tables)); + $table = $model->getTable(); + $connection = static::connectionName($model); + $withHash = !empty($with) ? ':' . md5(json_encode($with)) : ''; + $tables = [$table, ...static::dependencyTables($model, static::relationPaths($with))]; + $version = static::versionSegment(array_map(fn ($versionTable) => static::modelVersionKey($versionTable), $tables)); // Add Redis hash tag {api_model} for Redis Cluster shard routing // Include version to ensure cache keys change after writes - return "{api_model}:{$table}:{$version}:{$id}{$withHash}"; + return "{api_model}:{$connection}:{$table}:{$version}:{$id}{$withHash}"; } /** @@ -493,14 +521,15 @@ public static function generateModelCacheKey(Model $model, $id, array $with = [] */ public static function generateRelationshipCacheKey(Model $model, string $relationshipName): string { - $table = $model->getTable(); - $id = $model->getKey(); - $tables = [$table, ...static::dependencyTables($model, [$relationshipName])]; - $version = static::versionSegment(array_map(fn ($versionTable) => static::modelVersionKey($versionTable), $tables)); + $table = $model->getTable(); + $connection = static::connectionName($model); + $id = $model->getKey(); + $tables = [$table, ...static::dependencyTables($model, [$relationshipName])]; + $version = static::versionSegment(array_map(fn ($versionTable) => static::modelVersionKey($versionTable), $tables)); // Add Redis hash tag {api_relation} for Redis Cluster shard routing // Carries the parent's versions and the related tables', so a write to either side changes it - return "{api_relation}:{$table}:{$version}:{$id}:{$relationshipName}"; + return "{api_relation}:{$connection}:{$table}:{$version}:{$id}:{$relationshipName}"; } /** diff --git a/tests/Unit/Support/ApiModelCacheTest.php b/tests/Unit/Support/ApiModelCacheTest.php index d302cab1..8b880bf5 100644 --- a/tests/Unit/Support/ApiModelCacheTest.php +++ b/tests/Unit/Support/ApiModelCacheTest.php @@ -1,6 +1,8 @@ 333, 'api.cache.scope.session' => null, 'cache.default' => 'array', + 'database.default' => 'mysql', ], $config)); app()->instance('cache', $store); @@ -453,6 +476,23 @@ function api_model_cache_model(string $uuid = 'order-1', ?string $companyUuid = return $model; } +/** + * Run a callback inside a sandbox session as Auth::setSandboxSession() opens one + * for a console request with `Access-Console-Sandbox: true`, then restore live. + */ +function api_model_cache_in_sandbox(Closure $callback): mixed +{ + $request = Request::create('/int/v1/orders', 'GET', [], [], [], ['HTTP_ACCESS_CONSOLE_SANDBOX' => 'true']); + Auth::setSandboxSession($request); + + try { + return $callback(); + } finally { + config(['database.default' => 'mysql', 'fleetbase.connection.db' => 'mysql']); + session(['is_sandbox' => false]); + } +} + function api_model_cache_request(array $query = [], mixed $company = 'company-1'): Request { $request = Request::create('/int/v1/orders', 'GET', $query); @@ -624,15 +664,119 @@ public function gc(int $max_lifetime): int|false ]), ['callback' => true]); expect($first)->toBe($second) - ->and($first)->toStartWith('{api_query}:orders:company_company-1:v0.7:') + ->and($first)->toStartWith('{api_query}:mysql:orders:company_company-1:v0.7:') ->and(ApiModelCache::generateModelCacheKey($model, 'order-1', ['customer', 'payload'])) - ->toBe('{api_model}:orders:v0:order-1:' . md5(json_encode(['customer', 'payload']))) + ->toBe('{api_model}:mysql:orders:v0:order-1:' . md5(json_encode(['customer', 'payload']))) ->and(ApiModelCache::generateRelationshipCacheKey($model, 'payload')) - ->toBe('{api_relation}:orders:v0:order-1:payload') + ->toBe('{api_relation}:mysql:orders:v0:order-1:payload') ->and(ApiModelCache::generateCacheTags($model, 'company-1', true)) ->toBe(['api_cache', 'api_model:orders', 'api_query:orders', 'company:company-1']); }); +test('api model cache keys the same request apart under live and sandbox sessions', function () { + api_model_cache_fixture(['fleetbase.connection.db' => 'mysql']); + $request = fn () => api_model_cache_request(['status' => 'active', 'limit' => '1']); + + $keys = function () use ($request) { + $model = api_model_cache_model(); + $fleetbase = new ApiModelCacheFleetbaseTestModel(); + $fleetbase->setAttribute('uuid', 'order-1'); + + return [ + 'query' => ApiModelCache::generateQueryCacheKey($model, $request()), + 'model' => ApiModelCache::generateModelCacheKey($model, 'order-1', ['payload']), + 'relationship' => ApiModelCache::generateRelationshipCacheKey($model, 'payload'), + 'fleetbase_query' => ApiModelCache::generateQueryCacheKey($fleetbase, $request()), + 'fleetbase_model' => ApiModelCache::generateModelCacheKey($fleetbase, 1), + 'fleetbase_relation' => ApiModelCache::generateRelationshipCacheKey($fleetbase, 'payload'), + 'pinned_query' => ApiModelCache::generateQueryCacheKey(new ApiModelCachePinnedTestModel(), $request()), + ]; + }; + + $live = $keys(); + $sandbox = api_model_cache_in_sandbox($keys); + + foreach (['query', 'model', 'relationship', 'fleetbase_query', 'fleetbase_model', 'fleetbase_relation'] as $kind) { + expect($sandbox[$kind])->not->toBe($live[$kind]); + } + + expect($live['query'])->toStartWith('{api_query}:mysql:orders:company_company-1:v0.0:') + ->and($sandbox['query'])->toStartWith('{api_query}:sandbox:orders:company_company-1:v0.0:') + ->and($live['model'])->toBe('{api_model}:mysql:orders:v0:order-1:' . md5(json_encode(['payload']))) + ->and($sandbox['model'])->toBe('{api_model}:sandbox:orders:v0:order-1:' . md5(json_encode(['payload']))) + ->and($live['relationship'])->toBe('{api_relation}:mysql:orders:v0:order-1:payload') + ->and($sandbox['relationship'])->toBe('{api_relation}:sandbox:orders:v0:order-1:payload') + ->and($live['fleetbase_model'])->toBe('{api_model}:mysql:orders:v0:1') + ->and($sandbox['fleetbase_model'])->toBe('{api_model}:sandbox:orders:v0:1') + ->and($sandbox['fleetbase_query'])->toStartWith('{api_query}:sandbox:orders:') + ->and($sandbox['fleetbase_relation'])->toBe('{api_relation}:sandbox:orders:v0:order-1:payload') + // A model pinned to a connection reads the same data in both, so it keeps one key + ->and($live['pinned_query'])->toStartWith('{api_query}:storefront:orders:') + ->and($sandbox['pinned_query'])->toBe($live['pinned_query']); +}); + +test('api model cache never serves a live list to a sandbox request or a sandbox list to a live one', function () { + api_model_cache_fixture(['fleetbase.connection.db' => 'mysql']); + $request = fn () => api_model_cache_request(['limit' => '1', '_probe' => '1']); + + $live = ApiModelCache::cacheQueryResult(api_model_cache_model(), $request(), fn () => collect(['live-order'])); + expect($live->all())->toBe(['live-order']) + ->and(ApiModelCache::getCacheStatus())->toBe('MISS'); + $liveKey = ApiModelCache::getCacheKey(); + + [$sandbox, $sandboxStatus, $sandboxKey] = api_model_cache_in_sandbox(function () use ($request) { + ApiModelCache::resetCacheStatus(); + $result = ApiModelCache::cacheQueryResult(api_model_cache_model(), $request(), fn () => collect([])); + + return [$result, ApiModelCache::getCacheStatus(), ApiModelCache::getCacheKey()]; + }); + + expect($sandbox->all())->toBe([]) + ->and($sandboxStatus)->toBe('MISS') + ->and($sandboxKey)->not->toBe($liveKey); + + // Each environment then hits its own entry + ApiModelCache::resetCacheStatus(); + expect(ApiModelCache::cacheQueryResult(api_model_cache_model(), $request(), fn () => collect(['unexpected']))->all())->toBe(['live-order']) + ->and(ApiModelCache::getCacheStatus())->toBe('HIT'); + + $sandboxAgain = api_model_cache_in_sandbox(fn () => ApiModelCache::cacheQueryResult(api_model_cache_model(), $request(), fn () => collect(['unexpected']))); + expect($sandboxAgain->all())->toBe([]); +}); + +test('api model cache never serves a live model or relationship lookup to a sandbox request', function () { + api_model_cache_fixture(['fleetbase.connection.db' => 'mysql']); + $model = new ApiModelCacheFleetbaseTestModel(); + $model->setAttribute('uuid', 'user-1'); + + expect(ApiModelCache::cacheModel($model, 1, fn () => 'live-record'))->toBe('live-record') + ->and(ApiModelCache::cacheRelationship($model, 'payload', fn () => 'live-relation'))->toBe('live-relation'); + + [$record, $relation] = api_model_cache_in_sandbox(fn () => [ + ApiModelCache::cacheModel($model, 1, fn () => 'sandbox-record'), + ApiModelCache::cacheRelationship($model, 'payload', fn () => 'sandbox-relation'), + ]); + + expect($record)->toBe('sandbox-record') + ->and($relation)->toBe('sandbox-relation') + ->and(ApiModelCache::cacheModel($model, 1, fn () => 'unexpected'))->toBe('live-record') + ->and(ApiModelCache::cacheRelationship($model, 'payload', fn () => 'unexpected'))->toBe('live-relation'); +}); + +test('api model cache shares versions across connections so a sandbox write only over invalidates live lists', function () { + $store = api_model_cache_fixture(['fleetbase.connection.db' => 'mysql']); + $request = fn () => api_model_cache_request(['status' => 'active']); + + $liveBefore = ApiModelCache::generateQueryCacheKey(api_model_cache_model(), $request()); + api_model_cache_in_sandbox(fn () => ApiModelCache::invalidateModelCache(api_model_cache_model(), 'company-1')); + + expect($store->get('api_query_version:orders:company-1'))->toBe(1) + ->and($store->get('api_model_version:orders'))->toBe(1) + ->and(ApiModelCache::generateQueryCacheKey(api_model_cache_model(), $request())) + ->not->toBe($liveBefore) + ->toStartWith('{api_query}:mysql:orders:company_company-1:v0.1:'); +}); + test('api model cache stores query results with miss hit status and disabled bypass behavior', function () { api_model_cache_fixture(); $calls = 0; @@ -648,7 +792,7 @@ public function gc(int $max_lifetime): int|false expect($first->all())->toBe(['fresh-result']) ->and($calls)->toBe(1) ->and(ApiModelCache::getCacheStatus())->toBe('MISS') - ->and(ApiModelCache::getCacheKey())->toStartWith('{api_query}:orders:company_company-1:v0.0:'); + ->and(ApiModelCache::getCacheKey())->toStartWith('{api_query}:mysql:orders:company_company-1:v0.0:'); $second = ApiModelCache::cacheQueryResult($model, $request, function () use (&$calls) { $calls++; @@ -720,7 +864,7 @@ public function gc(int $max_lifetime): int|false ->and($secondModel)->toBe(['uuid' => 'order-1']) ->and($modelCalls)->toBe(1) ->and(ApiModelCache::getCacheStatus())->toBe('HIT') - ->and(ApiModelCache::getCacheKey())->toStartWith('{api_model}:orders:v0:order-1:'); + ->and(ApiModelCache::getCacheKey())->toStartWith('{api_model}:mysql:orders:v0:order-1:'); ApiModelCache::resetCacheStatus(); $relationshipCalls = 0; @@ -739,7 +883,7 @@ public function gc(int $max_lifetime): int|false ->and($secondRelation)->toBe(['uuid' => 'payload-1']) ->and($relationshipCalls)->toBe(1) ->and(ApiModelCache::getCacheStatus())->toBe('HIT') - ->and(ApiModelCache::getCacheKey())->toBe('{api_relation}:orders:v0:order-1:payload'); + ->and(ApiModelCache::getCacheKey())->toBe('{api_relation}:mysql:orders:v0:order-1:payload'); }); test('api model cache bypasses model relationship and invalidation work when disabled', function () { @@ -795,7 +939,7 @@ public function gc(int $max_lifetime): int|false ApiModelCache::invalidateQueryCache($model, $request); expect($store->forgotten[0]['tags'])->toBe(['api_cache', 'api_model:orders', 'api_query:orders', 'company:company-1']) - ->and($store->forgotten[0]['key'])->toStartWith('{api_query}:orders:company_company-1:v0.1:'); + ->and($store->forgotten[0]['key'])->toStartWith('{api_query}:mysql:orders:company_company-1:v0.1:'); ApiModelCache::invalidateCompanyCache('company-1'); @@ -1102,7 +1246,7 @@ public function gc(int $max_lifetime): int|false expect(ApiModelCache::cacheQueryResult($order, $request, fn () => collect(['fresher'])))->toEqual(collect(['fresher'])) ->and(ApiModelCache::getCacheStatus())->toBe('MISS') // orders, then audit_logs, order_tags, payloads, tags; each table wide then company-1 - ->and(ApiModelCache::getCacheKey())->toStartWith('{api_query}:orders:company_company-1:v0.0.0.0.1.0.0.1.0.0:'); + ->and(ApiModelCache::getCacheKey())->toStartWith('{api_query}:mysql:orders:company_company-1:v0.0.0.0.1.0.0.1.0.0:'); }); test('has api model cache invalidates a parent list when a dependency or custom pivot is written through eloquent', function () { @@ -1151,7 +1295,7 @@ public function gc(int $max_lifetime): int|false ApiModelCache::invalidateModelCache(new ApiModelCacheDependencyPayload(), 'company-1'); expect(ApiModelCache::cacheRelationship($order, 'payload', fn () => ['name' => 'fresh']))->toBe(['name' => 'fresh']) - ->and(ApiModelCache::getCacheKey())->toBe('{api_relation}:orders:v0.1:order-1:payload') + ->and(ApiModelCache::getCacheKey())->toBe('{api_relation}:mysql:orders:v0.1:order-1:payload') ->and(ApiModelCache::cacheModel($order, 'order-1', fn () => ['payload' => 'fresh'], ['payload:uuid,name']))->toBe(['payload' => 'fresh']) // A lookup that does not load the payload keeps its cache ->and(ApiModelCache::cacheModel($order, 'order-1', fn () => ['status' => 'unexpected']))->toBe(['status' => 'cached']); @@ -1195,7 +1339,7 @@ public function gc(int $max_lifetime): int|false ->and(ApiModelCache::getCacheStatus())->toBe('MISS') ->and(ApiModelCache::cacheQueryResult($model, $storeA, fn () => collect(['not reached']), $callback))->toEqual(collect(['store a product'])) ->and(ApiModelCache::getCacheStatus())->toBe('HIT') - ->and($storeAKey)->toStartWith('{api_query}:orders:company_company-1:v0.0:') + ->and($storeAKey)->toStartWith('{api_query}:mysql:orders:company_company-1:v0.0:') ->and($storeAKey)->not->toContain('store_key_a'); }); @@ -1248,7 +1392,7 @@ public function gc(int $max_lifetime): int|false 'company_uuid' => 'company-1', ])); - expect($sessionless)->toStartWith('{api_query}:orders:company_company-1:v0.0:') + expect($sessionless)->toStartWith('{api_query}:mysql:orders:company_company-1:v0.0:') ->and($sessionless)->toBe($unscoped); }); @@ -1265,9 +1409,9 @@ public function gc(int $max_lifetime): int|false ], null); expect(ApiModelCache::generateQueryCacheKey($model, $userRequest)) - ->toStartWith('{api_query}:orders:company_company-from-user:v0.0:') + ->toStartWith('{api_query}:mysql:orders:company_company-from-user:v0.0:') ->and(ApiModelCache::generateQueryCacheKey($model, $inputRequest)) - ->toStartWith('{api_query}:orders:company_company-from-input:v0.0:'); + ->toStartWith('{api_query}:mysql:orders:company_company-from-input:v0.0:'); }); test('api model cache invalidation methods swallow tagged cache backend failures', function () { @@ -1303,13 +1447,13 @@ public function gc(int $max_lifetime): int|false expect($modelResult)->toBe(['fallback' => true]) ->and(ApiModelCache::getCacheStatus())->toBe('ERROR') - ->and(ApiModelCache::getCacheKey())->toBe('{api_model}:orders:v0:order-1'); + ->and(ApiModelCache::getCacheKey())->toBe('{api_model}:mysql:orders:v0:order-1'); $relationshipResult = ApiModelCache::cacheRelationship($model, 'payload', fn () => ['relationship' => true]); expect($relationshipResult)->toBe(['relationship' => true]) ->and(ApiModelCache::getCacheStatus())->toBe('ERROR') - ->and(ApiModelCache::getCacheKey())->toBe('{api_relation}:orders:v0:order-1:payload'); + ->and(ApiModelCache::getCacheKey())->toBe('{api_relation}:mysql:orders:v0:order-1:payload'); }); test('api model cache warmup logs failures without leaking exceptions', function () { @@ -1357,7 +1501,7 @@ public function gc(int $max_lifetime): int|false ->and($second->pluck('uuid')->all())->toBe(['order-2']) ->and($callbackCalls)->toBe(1) ->and(ApiModelCache::getCacheStatus())->toBe('HIT') - ->and(ApiModelCache::getCacheKey())->toStartWith('{api_query}:orders:company_company-1:v0.0:') + ->and(ApiModelCache::getCacheKey())->toStartWith('{api_query}:mysql:orders:company_company-1:v0.0:') ->and(ApiModelCacheTraitTestModel::$mutatedRequestQueries)->toHaveCount(1) ->and(ApiModelCacheTraitTestModel::$mutatedRequestQueries[0])->toMatchArray([ 'status' => 'active', @@ -1522,7 +1666,7 @@ public function perPage(): int ->and($model->getRelation('cached_payload'))->toBe($firstRelation) ->and($store->flushedTags)->toContain(['api_cache', 'api_model:orders', 'company:company-1']) ->and($store->flushedTags)->toContain(['api_cache', 'api_model:orders', 'api_query:orders', 'company:company-1']) - ->and($store->forgotten[0]['key'])->toStartWith('{api_query}:orders:company_company-1:') + ->and($store->forgotten[0]['key'])->toStartWith('{api_query}:mysql:orders:company_company-1:') ->and(ApiModelCacheTraitTestModel::getCacheStats())->toMatchArray([ 'enabled' => true, 'driver' => 'array', From d36719642102d886b176406472640a3cb49ce9fc Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 15 Oct 2026 14:57:02 +0800 Subject: [PATCH 03/12] chore(release): v1.6.71 Co-Authored-By: Claude --- RELEASE.md | 10 +++++----- composer.json | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index 4a33fccb..47d7ff88 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,10 +1,10 @@ -# v1.6.70 — Text a verification code to the number being verified +# v1.6.71 — Keep live and test-mode API caches apart; driver-assigned events on driver channels -## Changed +## Fixed -- **`VerificationCode::generateSmsVerificationFor()` takes a `phone` option.** The code is texted to that number instead of the subject's own, and the SMS guard counts and traces the send against it. A customer changing their phone number now gets the code on the new number, including a customer with no number on file, who previously got nothing while the request reported success. Without the option nothing changes: the subject's phone is used, and a subject with no phone still gets a saved code and no text. +- **Live and test-mode data no longer share API cache entries.** List, model and relationship cache keys now carry the database connection (`{api_query}:mysql:…` / `{api_query}:sandbox:…`). Before, a console request in test mode could be served the cached live response for the same query (and the reverse), because console sessions carry no API credential to tell the two apart. (#312) +- **Driver-assigned lifecycle events reach `driver.{public_id}` and `driver.{uuid}`.** Order events for records with an assigned driver were only published on `driverAssigned.*`, which clients following the `{type}.{id}` convention never subscribe to. `driverAssigned.*` is still published and is now deprecated. (#311) ## Upgrade Steps -- None. The option is additive. -- fleetbase/storefront v0.4.25 requires this release (`fleetbase/core-api ^1.6.70`) for its phone change endpoint. +- None. Existing cache entries keep their old keys and expire within their TTL (5 minutes for lists, 1 hour for models); `deploy.sh` already runs `php artisan cache:clear`. diff --git a/composer.json b/composer.json index d3d21266..11e5c11b 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "fleetbase/core-api", - "version": "1.6.70", + "version": "1.6.71", "description": "Core Framework and Resources for Fleetbase API", "keywords": [ "fleetbase", From c6cffc491d18c4adce09fd55ec249391cecf4f7a Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 15 Oct 2026 16:15:20 +0800 Subject: [PATCH 04/12] feat(socket): admin socket settings with signed config for the socket server System administrators can now manage the socket server from the console instead of only through the environment: - system.socket.enabled / mode / token_ttl / origins / publisher_origin settings; a missing or cleared one falls back to the environment (SOCKETCLUSTER_AUTH_ENABLED, _AUTH_MODE, _TOKEN_TTL, _OPTIONS.origins, _ORIGIN), which is the behaviour before this change. - SocketSettings resolves the effective values and their sources; the stored values are cached for 60 seconds and the cache is cleared on save. An unreachable settings table reads as nothing stored. - SocketToken::enabled(), the default token TTL and the websocket publisher's Origin header follow the effective settings. - GET/POST int/v1/settings/socket (AdminRequest): effective settings plus the socket server's signed /stats (2 s timeout, short reason on failure). Switching on without SOCKETCLUSTER_AUTH_KEY is refused with 422 key_missing; saves are recorded in the activity log. - GET int/v1/socket/config, signed with the authorize key and answered while the switch is off, so the socket server can apply the switch, mode and origins at runtime. The auth key stays environment-only and is never stored or returned; only key_configured is reported. --- config/broadcasting.connections.php | 6 + .../Internal/v1/SettingController.php | 53 ++++ src/Http/Controllers/SocketAuthController.php | 23 +- src/Http/Middleware/VerifySocketSignature.php | 17 +- .../SocketCluster/SocketClusterService.php | 58 +++- src/Support/SocketCluster/SocketSettings.php | 203 +++++++++++++ src/Support/SocketCluster/SocketSignature.php | 9 +- src/Support/SocketCluster/SocketToken.php | 20 +- src/routes.php | 6 + .../Support/SocketSettingsFixtures.php | 71 +++++ .../Http/SocketSettingsControllerTest.php | 272 +++++++++++++++++ tests/Unit/RoutesContractTest.php | 21 ++ tests/Unit/Support/SocketSettingsTest.php | 273 ++++++++++++++++++ 13 files changed, 1014 insertions(+), 18 deletions(-) create mode 100644 src/Support/SocketCluster/SocketSettings.php create mode 100644 tests/Fixtures/Support/SocketSettingsFixtures.php create mode 100644 tests/Unit/Http/SocketSettingsControllerTest.php create mode 100644 tests/Unit/Support/SocketSettingsTest.php diff --git a/config/broadcasting.connections.php b/config/broadcasting.connections.php index f124df25..12b24b89 100644 --- a/config/broadcasting.connections.php +++ b/config/broadcasting.connections.php @@ -37,6 +37,12 @@ 'auth_key' => env('SOCKETCLUSTER_AUTH_KEY'), 'publish_url' => env('SOCKETCLUSTER_PUBLISH_URL', 'http://' . env('SOCKETCLUSTER_HOST', 'socket') . ':8001'), 'token_ttl' => (int) env('SOCKETCLUSTER_TOKEN_TTL', 900), + + // Environment fallbacks for the admin socket settings (Configure -> Socket). The + // socket server reads these itself; the API reads them only to report the effective + // values. Blank means "the socket server's own default". + 'auth_mode' => env('SOCKETCLUSTER_AUTH_MODE'), + 'socket_options' => env('SOCKETCLUSTER_OPTIONS'), ], // for apple apn diff --git a/src/Http/Controllers/Internal/v1/SettingController.php b/src/Http/Controllers/Internal/v1/SettingController.php index 775ea427..ead184a7 100644 --- a/src/Http/Controllers/Internal/v1/SettingController.php +++ b/src/Http/Controllers/Internal/v1/SettingController.php @@ -20,6 +20,9 @@ use Fleetbase\Services\OAuth\OAuthFlowService; use Fleetbase\Services\SmsService; use Fleetbase\Support\PlatformApi; +use Fleetbase\Support\SocketCluster\SocketClusterService; +use Fleetbase\Support\SocketCluster\SocketSettings; +use Fleetbase\Support\SocketCluster\SocketToken; use Fleetbase\Support\Utils; use Fleetbase\Twilio\Manager as TwilioManager; use Fleetbase\Twilio\Support\Laravel\Facade as TwilioFacade; @@ -1103,6 +1106,56 @@ public function testSocketcluster(AdminRequest $request) ); } + /** + * Socket server settings: the effective values with their sources, and the socket + * server's live status. + * + * The auth key is never part of the response; `settings.key_configured` says whether it is set. + */ + public function getSocketSettings(AdminRequest $request) + { + return response()->json($this->socketSettingsPayload()); + } + + /** + * Save any subset of the socket settings; a null value clears it back to the environment. + * + * Switching socket authentication on needs SOCKETCLUSTER_AUTH_KEY, which can only be set in + * the environment, so it is refused (422 `key_missing`) without one. + */ + public function saveSocketSettings(AdminRequest $request) + { + $validated = $request->validate(SocketSettings::rules()); + + if (($validated['enabled'] ?? null) !== null && filter_var($validated['enabled'], FILTER_VALIDATE_BOOLEAN) && SocketToken::key() === null) { + return response()->error('Socket authentication cannot be switched on until SOCKETCLUSTER_AUTH_KEY is set on the API and the socket server.', 422, ['code' => 'key_missing']); + } + + $change = SocketSettings::store($validated); + + activity('settings') + ->causedBy($request->user()) + ->event('socket_settings_updated') + ->withProperties(['fields' => array_keys(array_intersect_key($validated, array_flip(SocketSettings::FIELDS))), 'old' => $change['before'], 'new' => $change['after']]) + ->log('Socket settings updated'); + + return response()->json($this->socketSettingsPayload()); + } + + /** + * @return array{settings: array, status: ?array, status_error: ?string} + */ + protected function socketSettingsPayload(): array + { + $stats = SocketClusterService::stats(); + + return [ + 'settings' => SocketSettings::effective(), + 'status' => $stats['status'], + 'status_error' => $stats['error'], + ]; + } + /** * OAuth configuration, as an administrator should see it. * diff --git a/src/Http/Controllers/SocketAuthController.php b/src/Http/Controllers/SocketAuthController.php index 82315838..97329b22 100644 --- a/src/Http/Controllers/SocketAuthController.php +++ b/src/Http/Controllers/SocketAuthController.php @@ -8,6 +8,7 @@ use Fleetbase\Support\SocketCluster\ChannelDecision; use Fleetbase\Support\SocketCluster\SocketChannelRegistry; use Fleetbase\Support\SocketCluster\SocketPrincipal; +use Fleetbase\Support\SocketCluster\SocketSettings; use Fleetbase\Support\SocketCluster\SocketToken; use Fleetbase\Support\Utils; use Illuminate\Http\Request; @@ -15,8 +16,8 @@ /** * Socket token minting for console, API and platform clients, and the channel authorize - * endpoint the socket server calls. Every action answers 404 while SOCKETCLUSTER_AUTH_KEY - * is unset, which clients read as "connect anonymously". + * and config endpoints the socket server calls. Every action answers 404 while + * SOCKETCLUSTER_AUTH_KEY is unset, which clients read as "connect anonymously". */ class SocketAuthController extends Controller { @@ -106,6 +107,24 @@ public function authorizeChannel(Request $request) return response()->json($decision->toArray()); } + /** + * GET int/v1/socket/config: the effective socket settings the socket server applies at runtime. + * + * Signed with the authorize key and answered while the switch is off, so the socket + * server learns when an administrator switches socket authentication on. + */ + public function config(Request $request) + { + $settings = SocketSettings::effective(); + + return response()->json([ + 'enabled' => $settings['enabled'], + 'mode' => $settings['mode'], + 'origins' => $settings['origins'], + 'updated_at' => SocketSettings::updatedAt(), + ]); + } + protected static function disabled() { return response()->json(['error' => 'Not Found'], 404); diff --git a/src/Http/Middleware/VerifySocketSignature.php b/src/Http/Middleware/VerifySocketSignature.php index ff7ea394..49dbc307 100644 --- a/src/Http/Middleware/VerifySocketSignature.php +++ b/src/Http/Middleware/VerifySocketSignature.php @@ -9,14 +9,20 @@ /** * Admits only requests the socket server signed with the derived authorize key. * - * Used instead of session or token auth on the channel authorize endpoint, which only the - * socket server calls. + * Used instead of session or token auth on the endpoints only the socket server calls. They + * answer 404 while socket authentication is off; with the `key-only` parameter + * (`VerifySocketSignature:key-only`) a configured key is enough, whatever the switch. */ class VerifySocketSignature { - public function handle(Request $request, \Closure $next) + public const KEY_ONLY = 'key-only'; + + public function handle(Request $request, \Closure $next, ?string $requirement = null) { - if (!SocketToken::enabled()) { + $requireSwitch = $requirement !== self::KEY_ONLY; + $configured = $requireSwitch ? SocketToken::enabled() : SocketToken::key() !== null; + + if (!$configured) { return response()->json(['error' => 'Not Found'], 404); } @@ -24,7 +30,8 @@ public function handle(Request $request, \Closure $next) SocketSignature::AUTHORIZE, $request->header(SocketSignature::HEADER_TIMESTAMP), $request->header(SocketSignature::HEADER_SIGNATURE), - $request->getContent() + $request->getContent(), + $requireSwitch ); if (!$valid) { diff --git a/src/Support/SocketCluster/SocketClusterService.php b/src/Support/SocketCluster/SocketClusterService.php index af83c187..5da6f9e5 100644 --- a/src/Support/SocketCluster/SocketClusterService.php +++ b/src/Support/SocketCluster/SocketClusterService.php @@ -2,6 +2,7 @@ namespace Fleetbase\Support\SocketCluster; +use Illuminate\Http\Client\ConnectionException; use Illuminate\Support\Facades\Http; use WebSocket\Client; @@ -233,6 +234,14 @@ public static function publishesOverHttp(): bool * The socket server's internal publish endpoint. */ public static function publishUrl(): string + { + return static::internalUrl('/publish'); + } + + /** + * A path on the socket server's internal listener (SOCKETCLUSTER_PUBLISH_URL). + */ + public static function internalUrl(string $path): string { $base = config('broadcasting.connections.socketcluster.publish_url'); @@ -240,7 +249,46 @@ public static function publishUrl(): string $base = 'http://' . config('broadcasting.connections.socketcluster.options.host', 'socket') . ':8001'; } - return rtrim($base, '/') . '/publish'; + return rtrim($base, '/') . $path; + } + + /** + * The socket server's counters from its internal `/stats` endpoint, signed with the publish key. + * + * Never throws: when the key is not configured or the server cannot be reached within two + * seconds, `status` is null and `error` gives a short reason. + * + * @return array{status: ?array, error: ?string} + */ + public static function stats(): array + { + if (SocketToken::key() === null) { + return ['status' => null, 'error' => 'SOCKETCLUSTER_AUTH_KEY is not configured.']; + } + + try { + $response = Http::withHeaders(SocketSignature::headers(SocketSignature::PUBLISH, '')) + ->acceptJson() + ->connectTimeout(2) + ->timeout(2) + ->get(static::internalUrl('/stats')); + } catch (ConnectionException $e) { + return ['status' => null, 'error' => 'The socket server could not be reached or did not answer within 2 seconds.']; + } catch (\Throwable $e) { + return ['status' => null, 'error' => 'The socket server status request failed.']; + } + + if (!$response->successful()) { + return ['status' => null, 'error' => 'The socket server answered with HTTP status ' . $response->status() . '.']; + } + + $status = $response->json(); + + if (!is_array($status)) { + return ['status' => null, 'error' => 'The socket server returned an invalid status response.']; + } + + return ['status' => $status, 'error' => null]; } /** @@ -340,11 +388,19 @@ public function getOption(string $key, $default = null) /** * Get the connection options. * + * The handshake Origin is the effective publisher origin (the `system.socket.publisher_origin` + * setting, else SOCKETCLUSTER_ORIGIN); options passed in still take precedence. + * * @param array|string $options connection options or URI */ public function getOptions($options): array { $defaultConnectionOptions = config('broadcasting.connections.socketcluster.options', []); + $publisherOrigin = SocketSettings::effective()['publisher_origin']; + + if ($publisherOrigin !== null) { + $defaultConnectionOptions['headers'] = array_merge((array) ($defaultConnectionOptions['headers'] ?? []), ['Origin' => $publisherOrigin]); + } if (is_string($options)) { $options = parse_url($options); diff --git a/src/Support/SocketCluster/SocketSettings.php b/src/Support/SocketCluster/SocketSettings.php new file mode 100644 index 00000000..b2efcf04 --- /dev/null +++ b/src/Support/SocketCluster/SocketSettings.php @@ -0,0 +1,203 @@ + filter_var(config('broadcasting.connections.socketcluster.auth_enabled', false), FILTER_VALIDATE_BOOLEAN), + 'mode' => static::normalizeMode(config('broadcasting.connections.socketcluster.auth_mode')), + 'token_ttl' => $ttl > 0 ? $ttl : SocketToken::DEFAULT_TTL, + 'origins' => static::normalizeString(is_array($options) ? ($options['origins'] ?? null) : null), + 'publisher_origin' => static::normalizeString(config('broadcasting.connections.socketcluster.options.headers.Origin')), + ]; + } + + /** + * The stored settings, keyed by field, without the `system.socket.` prefix. + * + * Read on every broadcast and token, so it is cached; store() clears it. An unreachable + * settings table or cache (an install in progress, unit tests) reads as nothing stored. + */ + public static function stored(): array + { + try { + $rows = Cache::remember(static::CACHE_KEY, static::CACHE_TTL, function () { + return Setting::where('key', 'like', static::SETTING_PREFIX . '%')->get(['key', 'value'])->pluck('value', 'key')->all(); + }); + } catch (\Throwable $e) { + $rows = []; + } + + $stored = []; + foreach ((array) $rows as $key => $value) { + $stored[substr((string) $key, strlen(static::SETTING_PREFIX))] = $value; + } + + return $stored; + } + + /** + * The effective settings, where each came from, and whether the auth key is configured. + * + * @return array{enabled: bool, mode: ?string, token_ttl: int, origins: ?string, publisher_origin: ?string, key_configured: bool, sources: array} + */ + public static function effective(): array + { + $defaults = static::defaults(); + $stored = static::normalize(static::stored()); + $effective = []; + $sources = []; + + foreach (static::FIELDS as $field) { + $fromSetting = $stored[$field] !== null; + $effective[$field] = $fromSetting ? $stored[$field] : $defaults[$field]; + $sources[$field] = $fromSetting ? 'setting' : 'env'; + } + + $effective['key_configured'] = SocketToken::key() !== null; + $effective['sources'] = $sources; + + return $effective; + } + + /** + * When the stored settings last changed, or null if they never have. + */ + public static function updatedAt(): ?string + { + return static::normalizeString(static::stored()[static::UPDATED_AT] ?? null); + } + + /** + * Apply a change to some fields; a null value clears the field back to the environment. + * + * Fields not in the change are left as they are. Returns the stored values before and + * after the change, normalized, for the activity log. + * + * @return array{before: array, after: array} + */ + public static function store(array $changes): array + { + $before = static::normalize(static::stored()); + $changes = array_intersect_key(static::normalize($changes), array_flip(array_intersect(static::FIELDS, array_keys($changes)))); + + foreach ($changes as $field => $value) { + if ($value === null) { + Setting::where('key', static::SETTING_PREFIX . $field)->delete(); + } else { + Setting::configure(static::SETTING_PREFIX . $field, $value); + } + } + + Setting::configure(static::SETTING_PREFIX . static::UPDATED_AT, Carbon::now()->toIso8601ZuluString()); + static::forget(); + + return ['before' => $before, 'after' => array_merge($before, $changes)]; + } + + /** + * Drop the cached stored settings so the next read sees the database. + */ + public static function forget(): void + { + Cache::forget(static::CACHE_KEY); + } + + /** + * Coerce stored or submitted values to their canonical types; anything invalid becomes null. + */ + public static function normalize(array $values): array + { + $ttl = $values['token_ttl'] ?? null; + + return [ + 'enabled' => is_null($values['enabled'] ?? null) ? null : filter_var($values['enabled'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE), + 'mode' => static::normalizeMode($values['mode'] ?? null), + 'token_ttl' => is_numeric($ttl) && (int) $ttl >= self::MIN_TOKEN_TTL && (int) $ttl <= SocketToken::MAX_TTL ? (int) $ttl : null, + 'origins' => static::normalizeString($values['origins'] ?? null), + 'publisher_origin' => static::normalizeOrigin($values['publisher_origin'] ?? null), + ]; + } + + /** + * Validation rules for a save. Every field is optional and nullable (null = use the environment). + */ + public static function rules(): array + { + return [ + 'enabled' => ['sometimes', 'nullable', 'boolean'], + 'mode' => ['sometimes', 'nullable', 'string', 'in:' . implode(',', static::MODES)], + 'token_ttl' => ['sometimes', 'nullable', 'integer', 'min:' . self::MIN_TOKEN_TTL, 'max:' . SocketToken::MAX_TTL], + // SocketCluster's `origins` option: one or more host:port patterns separated by spaces. + 'origins' => ['sometimes', 'nullable', 'string', 'max:1024', 'regex:/^[\x21-\x7E]+( [\x21-\x7E]+)*$/'], + // An origin only: scheme, host and optional port, without a path. + 'publisher_origin' => ['sometimes', 'nullable', 'string', 'max:255', 'regex:/^https?:\/\/[^\/\s?#]+\/?$/i'], + ]; + } + + protected static function normalizeMode($mode): ?string + { + $mode = is_string($mode) ? strtolower(trim($mode)) : null; + + return in_array($mode, static::MODES, true) ? $mode : null; + } + + protected static function normalizeString($value): ?string + { + $value = is_string($value) ? trim($value) : ''; + + return $value === '' ? null : $value; + } + + protected static function normalizeOrigin($value): ?string + { + $value = static::normalizeString($value); + + return $value === null ? null : rtrim($value, '/'); + } +} diff --git a/src/Support/SocketCluster/SocketSignature.php b/src/Support/SocketCluster/SocketSignature.php index ffdae52d..0a147c57 100644 --- a/src/Support/SocketCluster/SocketSignature.php +++ b/src/Support/SocketCluster/SocketSignature.php @@ -61,10 +61,15 @@ public static function headers(string $purpose, string $body): array /** * Whether a request's timestamp and signature are valid for the raw body, compared in constant time. + * + * By default the switch must be on too. With $requireSwitch false only the key is needed, + * for the config endpoint the socket server polls to learn that the switch was turned on. */ - public static function verify(string $purpose, ?string $timestamp, ?string $signature, string $body): bool + public static function verify(string $purpose, ?string $timestamp, ?string $signature, string $body, bool $requireSwitch = true): bool { - if (!SocketToken::enabled() || !is_string($timestamp) || !ctype_digit($timestamp) || !is_string($signature) || $signature === '') { + $configured = $requireSwitch ? SocketToken::enabled() : SocketToken::key() !== null; + + if (!$configured || !is_string($timestamp) || !ctype_digit($timestamp) || !is_string($signature) || $signature === '') { return false; } diff --git a/src/Support/SocketCluster/SocketToken.php b/src/Support/SocketCluster/SocketToken.php index c9115907..a2b4dc83 100644 --- a/src/Support/SocketCluster/SocketToken.php +++ b/src/Support/SocketCluster/SocketToken.php @@ -19,8 +19,9 @@ * Mints and verifies the HS256 tokens realtime clients present to the socket server. * * The signing key is SOCKETCLUSTER_AUTH_KEY, shared with the socket server. The feature is - * on only when SOCKETCLUSTER_AUTH_ENABLED is true and that key is at least 32 bytes long; - * otherwise nothing is minted and broadcasts use the websocket publisher. + * on only when the switch is on (the `system.socket.enabled` setting, else + * SOCKETCLUSTER_AUTH_ENABLED) and that key is at least 32 bytes long; otherwise nothing is + * minted and broadcasts use the websocket publisher. */ class SocketToken { @@ -59,16 +60,21 @@ public static function key(): ?string * Whether realtime channel authentication is switched on. * * The switch is separate from the key so the key can be provisioned (for example on - * the socket server) before every socket client is ready for tokens. + * the socket server) before every socket client is ready for tokens. An administrator's + * setting overrides SOCKETCLUSTER_AUTH_ENABLED. */ public static function switchedOn(): bool { - return filter_var(config('broadcasting.connections.socketcluster.auth_enabled', false), FILTER_VALIDATE_BOOLEAN); + return SocketSettings::effective()['enabled']; } + /** + * Whether socket tokens are minted and checked: a valid key and the switch on. The key is + * checked first so installs without one never read the stored settings. + */ public static function enabled(): bool { - return static::switchedOn() && static::key() !== null; + return static::key() !== null && static::switchedOn(); } /** @@ -204,12 +210,10 @@ public static function base32(string $bytes): string public static function defaultTtl(string $kind): int { - $configured = (int) config('broadcasting.connections.socketcluster.token_ttl', self::DEFAULT_TTL); - return match ($kind) { 'tracking' => self::TRACKING_TTL, 'system' => self::SYSTEM_TTL, - default => $configured > 0 ? $configured : self::DEFAULT_TTL, + default => SocketSettings::effective()['token_ttl'], }; } diff --git a/src/routes.php b/src/routes.php index 4f325624..12d14411 100644 --- a/src/routes.php +++ b/src/routes.php @@ -173,6 +173,10 @@ function ($router) { // never by a session or user token. $router->post('socket/authorize', [Fleetbase\Http\Controllers\SocketAuthController::class, 'authorizeChannel']) ->middleware(Fleetbase\Http\Middleware\VerifySocketSignature::class); + // Also socket server only, and answered while the auth switch is off: it is how + // the socket server learns the effective settings, including the switch. + $router->get('socket/config', [Fleetbase\Http\Controllers\SocketAuthController::class, 'config']) + ->middleware(Fleetbase\Http\Middleware\VerifySocketSignature::class . ':' . Fleetbase\Http\Middleware\VerifySocketSignature::KEY_ONLY); $router->group( ['prefix' => 'two-fa', 'middleware' => [Fleetbase\Http\Middleware\ThrottleRequests::class]], function ($router) { @@ -261,6 +265,8 @@ function ($router, $controller) { $router->post('branding', $controller('saveBrandingSettings')); $router->put('branding', $controller('saveBrandingSettings')); $router->post('test-socket', $controller('testSocketcluster')); + $router->get('socket', $controller('getSocketSettings')); + $router->post('socket', $controller('saveSocketSettings')); $router->get('notification-channels-config', $controller('getNotificationChannelsConfig')); $router->post('notification-channels-config', $controller('saveNotificationChannelsConfig')); $router->post('test-notification-channels-config', $controller('testNotificationChannelsConfig')); diff --git a/tests/Fixtures/Support/SocketSettingsFixtures.php b/tests/Fixtures/Support/SocketSettingsFixtures.php new file mode 100644 index 00000000..47ce400b --- /dev/null +++ b/tests/Fixtures/Support/SocketSettingsFixtures.php @@ -0,0 +1,71 @@ + 'sqlite', 'database' => ':memory:', 'prefix' => '']; + $container = SocketAuthFixtures::container($key, array_merge([ + 'database.default' => 'mysql', + 'database.connections.mysql' => $sqlite, + 'fleetbase.connection.db' => 'mysql', + ], $config)); + + $container->instance('cache', new CacheRepository(new ArrayStore())); + Facade::clearResolvedInstance('cache'); + + $capsule = new Capsule($container); + $capsule->addConnection($sqlite, 'mysql'); + $capsule->setEventDispatcher(new Dispatcher($container)); + $capsule->setAsGlobal(); + $capsule->bootEloquent(); + $capsule->getDatabaseManager()->setDefaultConnection('mysql'); + $container->instance('db', $capsule->getDatabaseManager()); + Facade::clearResolvedInstance('db'); + + $capsule->getConnection('mysql')->getSchemaBuilder()->create('settings', function ($table) { + $table->increments('id'); + $table->string('key')->unique(); + $table->text('value')->nullable(); + }); + + $validation = new ValidationFactory(new Translator(new ArrayLoader(), 'en')); + Request::macro('validate', function (array $rules) use ($validation) { + return $validation->make($this->all(), $rules)->validate(); + }); + + return $capsule; + } + + /** + * Undo setUp(): the socket auth fixtures and the validate macro. + */ + public static function reset(): void + { + $macros = new \ReflectionProperty(Request::class, 'macros'); + $macros->setAccessible(true); + $macros->setValue(null, array_diff_key($macros->getValue(), ['validate' => true])); + + SocketAuthFixtures::reset(); + } +} diff --git a/tests/Unit/Http/SocketSettingsControllerTest.php b/tests/Unit/Http/SocketSettingsControllerTest.php new file mode 100644 index 00000000..54ef31b3 --- /dev/null +++ b/tests/Unit/Http/SocketSettingsControllerTest.php @@ -0,0 +1,272 @@ +setUserResolver(fn () => SocketAuthFixtures::user(['uuid' => 'admin-1', 'type' => 'admin'])); + + return $request; +} + +function socket_settings_stats_fake(): void +{ + Http::swap(new HttpFactory()); + Http::fake(['*' => Http::response(['connections' => 2, 'config' => ['source' => 'api']], 200)]); +} + +/** + * Every value a response could leak the auth key through: the key and each derived key. + */ +function socket_settings_secrets(): array +{ + return array_merge([SocketAuthFixtures::KEY], array_map( + fn (string $purpose) => hash_hmac('sha256', 'fleetbase-socket:' . $purpose, SocketAuthFixtures::KEY), + [SocketSignature::PUBLISH, SocketSignature::AUTHORIZE, SocketSignature::TRACKING] + )); +} + +function socket_settings_expect_no_secrets(JsonResponse $response): void +{ + foreach (socket_settings_secrets() as $secret) { + expect($response->getContent())->not->toContain($secret); + } +} + +function socket_settings_config_request(?string $timestamp, ?string $signature): Request +{ + return Request::create('/int/v1/socket/config', 'GET', [], [], [], array_filter([ + 'HTTP_X_FLEETBASE_TIMESTAMP' => $timestamp, + 'HTTP_X_FLEETBASE_SIGNATURE' => $signature, + ])); +} + +afterEach(function () { + SocketSettingsFixtures::reset(); +}); + +test('the socket settings endpoints are for system administrators only', function () { + foreach (['getSocketSettings', 'saveSocketSettings'] as $method) { + $parameter = (new ReflectionMethod(SettingController::class, $method))->getParameters()[0]; + + expect($parameter->getType()->getName())->toBe(AdminRequest::class); + } + + $admin = AdminRequest::create('/int/v1/settings/socket'); + $admin->setUserResolver(fn () => SocketAuthFixtures::user(['type' => 'admin'])); + $user = AdminRequest::create('/int/v1/settings/socket'); + $user->setUserResolver(fn () => SocketAuthFixtures::user(['type' => 'user'])); + + expect($admin->authorize())->toBeTrue() + ->and($user->authorize())->toBeFalse() + ->and((new AdminRequest())->authorize())->toBeFalse(); +}); + +test('get socket settings returns the effective settings and the socket server status', function () { + SocketSettingsFixtures::setUp(); + socket_settings_stats_fake(); + + $response = (new SettingController())->getSocketSettings(socket_settings_admin_request()); + + expect($response->getStatusCode())->toBe(200) + ->and($response->getData(true))->toBe([ + 'settings' => [ + 'enabled' => true, + 'mode' => null, + 'token_ttl' => 900, + 'origins' => null, + 'publisher_origin' => null, + 'key_configured' => true, + 'sources' => [ + 'enabled' => 'env', + 'mode' => 'env', + 'token_ttl' => 'env', + 'origins' => 'env', + 'publisher_origin' => 'env', + ], + ], + 'status' => ['connections' => 2, 'config' => ['source' => 'api']], + 'status_error' => null, + ]); + + socket_settings_expect_no_secrets($response); +}); + +test('get socket settings reports an unreachable socket server and a missing key', function () { + SocketSettingsFixtures::setUp(); + Http::fake(['*' => Http::response('bad gateway', 502)]); + + $unreachable = (new SettingController())->getSocketSettings(socket_settings_admin_request())->getData(true); + + config(['broadcasting.connections.socketcluster.auth_key' => null]); + + $keyless = (new SettingController())->getSocketSettings(socket_settings_admin_request())->getData(true); + + expect($unreachable['status'])->toBeNull() + ->and($unreachable['status_error'])->toBe('The socket server answered with HTTP status 502.') + ->and($keyless['settings']['key_configured'])->toBeFalse() + ->and($keyless['status'])->toBeNull() + ->and($keyless['status_error'])->toBe('SOCKETCLUSTER_AUTH_KEY is not configured.'); +}); + +test('save socket settings stores a subset, clears nulls, logs the change and answers like get', function () { + SocketSettingsFixtures::setUp(SocketAuthFixtures::KEY, ['broadcasting.connections.socketcluster.auth_enabled' => false]); + socket_settings_stats_fake(); + SocketSettings::store(['origins' => 'http://localhost:*']); + + $response = (new SettingController())->saveSocketSettings(socket_settings_admin_request('POST', [ + 'enabled' => true, + 'mode' => 'log', + 'token_ttl' => 300, + 'origins' => null, + 'publisher_origin' => 'https://console.example.test/', + ])); + $payload = $response->getData(true); + + expect($response->getStatusCode())->toBe(200) + ->and(array_keys($payload))->toBe(['settings', 'status', 'status_error']) + ->and($payload['settings']['enabled'])->toBeTrue() + ->and($payload['settings']['mode'])->toBe('log') + ->and($payload['settings']['token_ttl'])->toBe(300) + ->and($payload['settings']['origins'])->toBeNull() + ->and($payload['settings']['publisher_origin'])->toBe('https://console.example.test') + ->and($payload['settings']['sources'])->toBe([ + 'enabled' => 'setting', + 'mode' => 'setting', + 'token_ttl' => 'setting', + 'origins' => 'env', + 'publisher_origin' => 'setting', + ]) + ->and($payload['status'])->toBe(['connections' => 2, 'config' => ['source' => 'api']]) + ->and(Setting::where('key', 'system.socket.origins')->exists())->toBeFalse() + ->and(TestActivityLogger::$logged)->toBe([[ + 'log' => 'settings', + 'description' => 'Socket settings updated', + 'causer' => 'admin-1', + 'event' => 'socket_settings_updated', + 'properties' => [ + 'fields' => ['enabled', 'mode', 'token_ttl', 'origins', 'publisher_origin'], + 'old' => ['enabled' => null, 'mode' => null, 'token_ttl' => null, 'origins' => 'http://localhost:*', 'publisher_origin' => null], + 'new' => ['enabled' => true, 'mode' => 'log', 'token_ttl' => 300, 'origins' => null, 'publisher_origin' => 'https://console.example.test'], + ], + ]]); + + socket_settings_expect_no_secrets($response); + + // Fields left out of a save keep their stored values. + $partial = (new SettingController())->saveSocketSettings(socket_settings_admin_request('POST', ['enabled' => null]))->getData(true); + + expect($partial['settings']['enabled'])->toBeFalse() + ->and($partial['settings']['sources']['enabled'])->toBe('env') + ->and($partial['settings']['mode'])->toBe('log') + ->and(TestActivityLogger::$logged[1]['properties']['fields'])->toBe(['enabled']); +}); + +test('socket authentication cannot be switched on without a configured key', function () { + SocketSettingsFixtures::setUp(null); + socket_settings_stats_fake(); + + $refused = (new SettingController())->saveSocketSettings(socket_settings_admin_request('POST', ['enabled' => true, 'mode' => 'enforce'])); + + expect($refused->getStatusCode())->toBe(422) + ->and($refused->getData(true)['code'])->toBe('key_missing') + ->and(Setting::query()->count())->toBe(0) + ->and(TestActivityLogger::$logged)->toBe([]); + + $switchedOff = (new SettingController())->saveSocketSettings(socket_settings_admin_request('POST', ['enabled' => false, 'mode' => 'off'])); + + expect($switchedOff->getStatusCode())->toBe(200) + ->and($switchedOff->getData(true)['settings']['enabled'])->toBeFalse() + ->and($switchedOff->getData(true)['settings']['mode'])->toBe('off'); +}); + +test('save socket settings rejects invalid values', function (array $input) { + SocketSettingsFixtures::setUp(); + + expect(fn () => (new SettingController())->saveSocketSettings(socket_settings_admin_request('POST', $input))) + ->toThrow(ValidationException::class); + + expect(Setting::query()->count())->toBe(0); +})->with([ + 'non boolean switch' => [['enabled' => 'maybe']], + 'unknown mode' => [['mode' => 'strict']], + 'ttl below the minimum' => [['token_ttl' => 59]], + 'ttl above the maximum' => [['token_ttl' => 3601]], + 'non numeric ttl' => [['token_ttl' => 'soon']], + 'origins with a tab' => [['origins' => "https://a.example.test:*\thttps://b.example.test:*"]], + 'origins as a list' => [['origins' => ['https://a.example.test:*']]], + 'origin without a scheme' => [['publisher_origin' => 'console.example.test']], + 'origin with a path' => [['publisher_origin' => 'https://console.example.test/app']], + 'origin with another type' => [['publisher_origin' => 'ftp://console.example.test']], +]); + +test('the signed config endpoint answers the effective settings while the switch is off', function () { + SocketSettingsFixtures::setUp(SocketAuthFixtures::KEY, ['broadcasting.connections.socketcluster.auth_enabled' => false]); + + $middleware = new VerifySocketSignature(); + $controller = new SocketAuthController(); + $now = (string) SocketAuthFixtures::NOW; + $stale = (string) (SocketAuthFixtures::NOW - 120); + $next = fn (Request $request) => $controller->config($request); + $send = fn (?string $timestamp, ?string $signature) => $middleware->handle(socket_settings_config_request($timestamp, $signature), $next, VerifySocketSignature::KEY_ONLY); + + $before = $send($now, SocketSignature::sign(SocketSignature::AUTHORIZE, $now, '')); + + expect($before->getStatusCode())->toBe(200) + ->and($before->getData(true))->toBe(['enabled' => false, 'mode' => null, 'origins' => null, 'updated_at' => null]); + + SocketSettings::store(['enabled' => true, 'mode' => 'log', 'origins' => 'https://console.example.test:*', 'token_ttl' => 300]); + + $after = $send($now, SocketSignature::sign(SocketSignature::AUTHORIZE, $now, '')); + + expect($after->getData(true))->toBe([ + 'enabled' => true, + 'mode' => 'log', + 'origins' => 'https://console.example.test:*', + 'updated_at' => Carbon::createFromTimestampUTC(SocketAuthFixtures::NOW)->toIso8601ZuluString(), + ]); + + socket_settings_expect_no_secrets($after); + + SocketSettings::store(['enabled' => false]); + + $rejected = [ + $send($stale, SocketSignature::sign(SocketSignature::AUTHORIZE, $stale, '')), + $send($now, SocketSignature::sign(SocketSignature::PUBLISH, $now, '')), + $send($now, str_repeat('0', 64)), + $send(null, null), + ]; + + foreach ($rejected as $response) { + expect($response->getStatusCode())->toBe(401) + ->and($response->getData(true))->toBe(['error' => 'invalid_signature']); + } + + // The authorize endpoint's default still needs the switch on. + $authorize = $middleware->handle(socket_settings_config_request($now, SocketSignature::sign(SocketSignature::AUTHORIZE, $now, '')), $next); + + expect($authorize->getStatusCode())->toBe(404) + ->and(SocketSignature::verify(SocketSignature::AUTHORIZE, $now, SocketSignature::sign(SocketSignature::AUTHORIZE, $now, ''), ''))->toBeFalse() + ->and(SocketSignature::verify(SocketSignature::AUTHORIZE, $now, SocketSignature::sign(SocketSignature::AUTHORIZE, $now, ''), '', false))->toBeTrue(); + + // Without a key there is nothing to verify the request with. + config(['broadcasting.connections.socketcluster.auth_key' => null]); + + expect($send($now, str_repeat('0', 64))->getStatusCode())->toBe(404) + ->and(SocketSignature::verify(SocketSignature::AUTHORIZE, $now, str_repeat('0', 64), '', false))->toBeFalse(); +}); diff --git a/tests/Unit/RoutesContractTest.php b/tests/Unit/RoutesContractTest.php index 0d51d40c..cefee5d5 100644 --- a/tests/Unit/RoutesContractTest.php +++ b/tests/Unit/RoutesContractTest.php @@ -387,6 +387,27 @@ function routes_contract_index(array $rows, string $method, string $uri): int|fa ->and($authorize['middleware'])->toBe([Fleetbase\Http\Middleware\VerifySocketSignature::class]); }); + test('route file exposes socket settings to administrators and signed config to the socket server', function () { + $routes = routes_contract_rows(routes_contract_router()); + $settings = 'Fleetbase\\Http\\Controllers\\Internal\\v1\\SettingController'; + + foreach ([['GET', 'getSocketSettings'], ['POST', 'saveSocketSettings']] as [$method, $action]) { + $route = routes_contract_find($routes, $method, 'int/v1/settings/socket'); + + expect($route)->not->toBeNull() + ->and($route['action'])->toBe($settings . '@' . $action) + ->and($route['middleware'])->toContain('fleetbase.protected'); + } + + $config = routes_contract_find($routes, 'GET', 'int/v1/socket/config'); + + expect(routes_contract_index($routes, 'GET', 'int/v1/settings/socket')) + ->toBeLessThan(routes_contract_index($routes, 'GET', 'int/v1/settings/{setting}')) + ->and($config['action'])->toBe('Fleetbase\\Http\\Controllers\\SocketAuthController@config') + // Signature only, and answered while the switch is off. + ->and($config['middleware'])->toBe([Fleetbase\Http\Middleware\VerifySocketSignature::class . ':key-only']); + }); + 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'; diff --git a/tests/Unit/Support/SocketSettingsTest.php b/tests/Unit/Support/SocketSettingsTest.php new file mode 100644 index 00000000..83c272dd --- /dev/null +++ b/tests/Unit/Support/SocketSettingsTest.php @@ -0,0 +1,273 @@ + false, + 'broadcasting.connections.socketcluster.auth_mode' => ' LOG ', + 'broadcasting.connections.socketcluster.token_ttl' => 600, + 'broadcasting.connections.socketcluster.socket_options' => '{"origins":"https://console.example.test:*"}', + 'broadcasting.connections.socketcluster.options' => [ + 'secure' => false, + 'host' => 'socket.test', + 'port' => 8000, + 'path' => '/socketcluster/', + 'query' => [], + 'headers' => ['Origin' => 'https://console.example.test'], + ], + ]; +} + +function socket_settings_client_headers(SocketClusterService $service): array +{ + return (fn () => $this->options)->call($service->getClient())['headers'] ?? []; +} + +afterEach(function () { + SocketSettingsFixtures::reset(); +}); + +test('effective socket settings come from the environment until an administrator stores them', function () { + SocketSettingsFixtures::setUp(SocketAuthFixtures::KEY, socket_settings_env()); + + expect(SocketSettings::effective())->toBe([ + 'enabled' => false, + 'mode' => 'log', + 'token_ttl' => 600, + 'origins' => 'https://console.example.test:*', + 'publisher_origin' => 'https://console.example.test', + 'key_configured' => true, + 'sources' => [ + 'enabled' => 'env', + 'mode' => 'env', + 'token_ttl' => 'env', + 'origins' => 'env', + 'publisher_origin' => 'env', + ], + ])->and(SocketSettings::updatedAt())->toBeNull(); + + $change = SocketSettings::store([ + 'enabled' => true, + 'mode' => 'enforce', + 'token_ttl' => '120', + 'origins' => ' https://ops.example.test:* http://localhost:* ', + 'publisher_origin' => 'https://ops.example.test/', + ]); + + expect(SocketSettings::effective())->toBe([ + 'enabled' => true, + 'mode' => 'enforce', + 'token_ttl' => 120, + 'origins' => 'https://ops.example.test:* http://localhost:*', + 'publisher_origin' => 'https://ops.example.test', + 'key_configured' => true, + 'sources' => [ + 'enabled' => 'setting', + 'mode' => 'setting', + 'token_ttl' => 'setting', + 'origins' => 'setting', + 'publisher_origin' => 'setting', + ], + ]) + ->and($change['before'])->toBe(['enabled' => null, 'mode' => null, 'token_ttl' => null, 'origins' => null, 'publisher_origin' => null]) + ->and($change['after']['token_ttl'])->toBe(120) + ->and(Setting::where('key', 'system.socket.mode')->value('value'))->toBe('enforce') + ->and(SocketSettings::updatedAt())->toBe(Carbon::createFromTimestampUTC(SocketAuthFixtures::NOW)->toIso8601ZuluString()); + + // A null clears one field back to the environment and leaves the rest as they are. + $cleared = SocketSettings::store(['mode' => null, 'token_ttl' => null]); + $after = SocketSettings::effective(); + + expect($cleared['after'])->toBe(['enabled' => true, 'mode' => null, 'token_ttl' => null, 'origins' => 'https://ops.example.test:* http://localhost:*', 'publisher_origin' => 'https://ops.example.test']) + ->and($after['mode'])->toBe('log') + ->and($after['token_ttl'])->toBe(600) + ->and($after['enabled'])->toBeTrue() + ->and($after['sources'])->toBe(['enabled' => 'setting', 'mode' => 'env', 'token_ttl' => 'env', 'origins' => 'setting', 'publisher_origin' => 'setting']) + ->and(Setting::where('key', 'system.socket.mode')->exists())->toBeFalse(); +}); + +test('environment fallbacks tolerate blank, invalid and array forms', function () { + SocketSettingsFixtures::setUp(null, [ + 'broadcasting.connections.socketcluster.auth_enabled' => 'true', + 'broadcasting.connections.socketcluster.auth_mode' => 'strict', + 'broadcasting.connections.socketcluster.token_ttl' => 0, + 'broadcasting.connections.socketcluster.socket_options' => 'not json', + ]); + + expect(SocketSettings::effective())->toMatchArray([ + 'enabled' => true, + 'mode' => null, + 'token_ttl' => SocketToken::DEFAULT_TTL, + 'origins' => null, + 'publisher_origin' => null, + 'key_configured' => false, + ]); + + config(['broadcasting.connections.socketcluster.socket_options' => ['origins' => 'http://localhost:*']]); + + expect(SocketSettings::defaults()['origins'])->toBe('http://localhost:*'); + + config(['broadcasting.connections.socketcluster.socket_options' => '{"origins":" "}']); + + expect(SocketSettings::defaults()['origins'])->toBeNull(); +}); + +test('stored values that are no longer valid fall back to the environment', function () { + SocketSettingsFixtures::setUp(SocketAuthFixtures::KEY, socket_settings_env()); + + Setting::configure('system.socket.enabled', 'perhaps'); + Setting::configure('system.socket.mode', 'bogus'); + Setting::configure('system.socket.token_ttl', 10); + Setting::configure('system.socket.origins', ' '); + Setting::configure('system.socket.publisher_origin', ['not' => 'a string']); + + expect(SocketSettings::effective()['sources'])->toBe([ + 'enabled' => 'env', + 'mode' => 'env', + 'token_ttl' => 'env', + 'origins' => 'env', + 'publisher_origin' => 'env', + ]) + ->and(SocketSettings::normalize(['token_ttl' => 4000, 'enabled' => 'false', 'mode' => ' Enforce ']))->toBe([ + 'enabled' => false, + 'mode' => 'enforce', + 'token_ttl' => null, + 'origins' => null, + 'publisher_origin' => null, + ]); +}); + +test('stored socket settings are cached and the cache is busted on save', function () { + SocketSettingsFixtures::setUp(SocketAuthFixtures::KEY, socket_settings_env()); + + expect(SocketSettings::effective()['mode'])->toBe('log') + ->and(Cache::get(SocketSettings::CACHE_KEY))->toBe([]); + + // Written behind the resolver's back: the cached read does not see it yet. + Setting::configure('system.socket.mode', 'off'); + + expect(SocketSettings::effective()['mode'])->toBe('log'); + + SocketSettings::store(['token_ttl' => 300]); + + expect(SocketSettings::effective()['mode'])->toBe('off') + ->and(SocketSettings::effective()['token_ttl'])->toBe(300) + ->and(Cache::get(SocketSettings::CACHE_KEY))->toHaveKeys(['system.socket.mode', 'system.socket.token_ttl', 'system.socket.updated_at']) + ->and(SocketSettings::CACHE_TTL)->toBe(60); +}); + +test('only the documented fields can be stored, never the auth key', function () { + SocketSettingsFixtures::setUp(); + + SocketSettings::store(['auth_key' => SocketAuthFixtures::OTHER_KEY, 'key' => SocketAuthFixtures::OTHER_KEY, 'mode' => 'log']); + + expect(Setting::query()->orderBy('key')->pluck('key')->all())->toBe(['system.socket.mode', 'system.socket.updated_at']) + ->and(json_encode(SocketSettings::effective()))->not->toContain(SocketAuthFixtures::KEY) + ->and(json_encode(SocketSettings::effective()))->not->toContain(SocketAuthFixtures::OTHER_KEY) + ->and(SocketToken::key())->toBe(SocketAuthFixtures::KEY); +}); + +test('an unreachable settings table falls back to the environment silently', function () { + $capsule = SocketSettingsFixtures::setUp(SocketAuthFixtures::KEY, socket_settings_env()); + $capsule->getConnection('mysql')->getSchemaBuilder()->drop('settings'); + + expect(SocketSettings::stored())->toBe([]) + ->and(SocketSettings::effective()['sources']['mode'])->toBe('env') + ->and(SocketSettings::updatedAt())->toBeNull(); +}); + +test('socket token switch, ttl and publisher origin follow the effective settings', function () { + SocketSettingsFixtures::setUp(SocketAuthFixtures::KEY, socket_settings_env()); + + expect(SocketToken::switchedOn())->toBeFalse() + ->and(SocketToken::enabled())->toBeFalse() + ->and(SocketClusterService::publishesOverHttp())->toBeFalse() + ->and(SocketToken::defaultTtl('user'))->toBe(600) + ->and(socket_settings_client_headers(new SocketClusterService()))->toBe(['Origin' => 'https://console.example.test']); + + SocketSettings::store(['enabled' => true, 'token_ttl' => 120, 'publisher_origin' => 'https://ops.example.test']); + + expect(SocketToken::switchedOn())->toBeTrue() + ->and(SocketToken::enabled())->toBeTrue() + ->and(SocketClusterService::publishesOverHttp())->toBeTrue() + ->and(SocketToken::defaultTtl('api'))->toBe(120) + ->and(SocketToken::defaultTtl('tracking'))->toBe(SocketToken::TRACKING_TTL) + ->and(SocketToken::issue(new SocketPrincipal(kind: 'api', sub: 'cred-a'))['expires_in'])->toBe(120) + ->and(socket_settings_client_headers(new SocketClusterService()))->toBe(['Origin' => 'https://ops.example.test']) + // Options passed in still take precedence over the configured origin. + ->and(socket_settings_client_headers(new SocketClusterService(['headers' => ['Origin' => 'https://explicit.example.test']])))->toBe(['Origin' => 'https://explicit.example.test']); + + // The stored switch also turns socket auth off while the environment has it on. + config(['broadcasting.connections.socketcluster.auth_enabled' => true]); + SocketSettings::store(['enabled' => false]); + + expect(SocketToken::enabled())->toBeFalse(); + + // Without any origin at all the handshake sends none. + config(['broadcasting.connections.socketcluster.options.headers' => []]); + SocketSettings::store(['publisher_origin' => null]); + + expect(socket_settings_client_headers(new SocketClusterService()))->toBe([]); +}); + +test('socket server stats are fetched with a signed request and a two second timeout', function () { + SocketSettingsFixtures::setUp(); + Http::fake(['*' => Http::response(['connections' => 3, 'counters' => ['denied' => 1]], 200)]); + + $publishKey = hash_hmac('sha256', 'fleetbase-socket:publish', SocketAuthFixtures::KEY); + + expect(SocketClusterService::stats())->toBe(['status' => ['connections' => 3, 'counters' => ['denied' => 1]], 'error' => null]) + ->and(SocketClusterService::internalUrl('/stats'))->toBe('http://socket.test:8001/stats'); + + Http::assertSentCount(1); + Http::assertSent(function (HttpRequest $request) use ($publishKey) { + $timestamp = $request->header('X-Fleetbase-Timestamp')[0]; + + return $request->url() === 'http://socket.test:8001/stats' + && $request->method() === 'GET' + && $timestamp === (string) SocketAuthFixtures::NOW + && $request->header('X-Fleetbase-Signature')[0] === hash_hmac('sha256', $timestamp . '.', $publishKey); + }); +}); + +test('socket server stats report failures, timeouts and a missing key as a short reason', function () { + SocketSettingsFixtures::setUp(); + + $statsFor = function ($fake) { + Http::swap(new HttpFactory()); + Http::fake($fake); + + return SocketClusterService::stats(); + }; + + expect($statsFor(['*' => Http::response('unavailable', 503)]))->toBe(['status' => null, 'error' => 'The socket server answered with HTTP status 503.']) + ->and($statsFor(['*' => Http::response('not json', 200)]))->toBe(['status' => null, 'error' => 'The socket server returned an invalid status response.']) + ->and($statsFor(function () { + throw new ConnectionException('cURL error 28: Operation timed out after 2000 milliseconds'); + }))->toBe(['status' => null, 'error' => 'The socket server could not be reached or did not answer within 2 seconds.']) + ->and($statsFor(function () { + throw new RuntimeException('unexpected'); + }))->toBe(['status' => null, 'error' => 'The socket server status request failed.']); + + config(['broadcasting.connections.socketcluster.auth_key' => null]); + Http::swap(new HttpFactory()); + Http::fake(); + + expect(SocketClusterService::stats())->toBe(['status' => null, 'error' => 'SOCKETCLUSTER_AUTH_KEY is not configured.']); + + Http::assertNothingSent(); +}); From 807096c908f1198a65f3fef8e541062055767397 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 15 Oct 2026 16:19:24 +0800 Subject: [PATCH 05/12] style(socket): use the imported socket classes in SettingController --- src/Http/Controllers/Internal/v1/SettingController.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Http/Controllers/Internal/v1/SettingController.php b/src/Http/Controllers/Internal/v1/SettingController.php index ead184a7..40364756 100644 --- a/src/Http/Controllers/Internal/v1/SettingController.php +++ b/src/Http/Controllers/Internal/v1/SettingController.php @@ -1055,7 +1055,7 @@ public function testSentryConfig(AdminRequest $request) public function testSocketcluster(AdminRequest $request) { $userUuid = session('user'); - $scoped = \Fleetbase\Support\SocketCluster\SocketToken::enabled(); + $scoped = SocketToken::enabled(); if ($scoped && (!is_string($userUuid) || $userUuid === '')) { return response()->json([ @@ -1073,7 +1073,7 @@ public function testSocketcluster(AdminRequest $request) $sent = false; $response = null; - $socketClusterClient = new \Fleetbase\Support\SocketCluster\SocketClusterService(); + $socketClusterClient = new SocketClusterService(); try { $sent = $socketClusterClient->send($channel, [ From 249e0faa00d0b1336cac30540465b413e533db59 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 15 Oct 2026 16:23:52 +0800 Subject: [PATCH 06/12] test(socket): bind the test container before the admin-guard test builds users --- tests/Unit/Http/SocketSettingsControllerTest.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/Unit/Http/SocketSettingsControllerTest.php b/tests/Unit/Http/SocketSettingsControllerTest.php index 54ef31b3..01e14c78 100644 --- a/tests/Unit/Http/SocketSettingsControllerTest.php +++ b/tests/Unit/Http/SocketSettingsControllerTest.php @@ -61,6 +61,10 @@ function socket_settings_config_request(?string $timestamp, ?string $signature): }); test('the socket settings endpoints are for system administrators only', function () { + // The User models below boot their traits, which read config: bind a container first so + // this test does not depend on whatever an earlier test left behind. + SocketSettingsFixtures::setUp(); + foreach (['getSocketSettings', 'saveSocketSettings'] as $method) { $parameter = (new ReflectionMethod(SettingController::class, $method))->getParameters()[0]; From 593518a0da0c762a49e158110ffff0de3a1e1499 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 15 Oct 2026 16:35:56 +0800 Subject: [PATCH 07/12] fix(find): key the filter and request caches by the controller's own class Find::httpFilterForModel() cached its answer per model class for the life of the process, ignoring the filter a controller sets on its model and whether the request was internal. Under Octane, the first controller to list a model decided the filter for every other: Fleet-Ops' order filter was applied to Storefront's order lists, so a network's orders page listed every order in the company. The cache is now keyed by the model's own filter, and a filter found by convention is picked per request audience. httpRequestForModel() is keyed by the model's own request class too. --- RELEASE.md | 1 + src/Support/Find.php | 108 ++++++++++----------- tests/Unit/FindHttpRequestForModelTest.php | 88 +++++++++++++++++ 3 files changed, 139 insertions(+), 58 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index 47d7ff88..560ad681 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -2,6 +2,7 @@ ## Fixed +- **A controller's filter is used for every query it serves.** `Find::httpFilterForModel()` cached its answer per model class for the life of the process, ignoring the filter a controller sets on its model (`$filter`) and whether the request was internal. Under Octane, the first controller to list a model decided the filter for every other: Fleet-Ops' order filter was applied to Storefront's order lists, so a network's orders page listed every order in the company. The cache is now keyed by the model's own filter, and a filter found by convention is picked per request audience. `Find::httpRequestForModel()` is keyed by the model's own request class too. (#PR) - **Live and test-mode data no longer share API cache entries.** List, model and relationship cache keys now carry the database connection (`{api_query}:mysql:…` / `{api_query}:sandbox:…`). Before, a console request in test mode could be served the cached live response for the same query (and the reverse), because console sessions carry no API credential to tell the two apart. (#312) - **Driver-assigned lifecycle events reach `driver.{public_id}` and `driver.{uuid}`.** Order events for records with an assigned driver were only published on `driverAssigned.*`, which clients following the `{type}.{id}` convention never subscribe to. `driverAssigned.*` is still published and is now deprecated. (#311) diff --git a/src/Support/Find.php b/src/Support/Find.php index 6b9820f5..2d6f7fd3 100644 --- a/src/Support/Find.php +++ b/src/Support/Find.php @@ -105,47 +105,31 @@ protected static function httpResourceByConvention(string $baseNamespace, string */ public static function httpRequestForModel(Model $model, ?string $namespace = null, ?int $version = 1): ?string { - // Create a unique cache key based on parameters. - $cacheKey = md5(get_class($model) . '|' . ($namespace ?? '') . '|' . $version . '|' . Http::action()); + // A controller can name its model's request class (`$model->request`). It belongs in the key: + // the cache lives as long as the process, so in an Octane worker one controller's answer was + // otherwise served to every other controller of the same model. + $explicit = method_exists($model, 'getRequest') ? $model->getRequest() : null; + $cacheKey = md5(get_class($model) . '|' . ($namespace ?? '') . '|' . $version . '|' . Http::action() . '|' . (is_string($explicit) ? $explicit : '')); static $cache = []; if (isset($cache[$cacheKey])) { return $cache[$cacheKey]; } - $requestNamespace = null; + $requestNamespace = $explicit; $defaultRequestNS = '\\Fleetbase\\Http\\Requests\\'; - $requestNS = $baseNamespace = $namespace ? $namespace . '\\Http\\Requests\\' : $defaultRequestNS; + $requestNS = $namespace ? $namespace . '\\Http\\Requests\\' : $defaultRequestNS; $modelName = Utils::classBasename($model); - if (method_exists($model, 'getRequest')) { - $requestNamespace = $model->getRequest(); - } - if ($requestNamespace === null) { $requestNamespace = $requestNS . Str::studly(ucfirst(Http::action()) . ucfirst($modelName) . 'Request'); } if (!Utils::classExists($requestNamespace)) { - $internal = Http::isInternalRequest(); - if ($internal) { - $baseNamespace .= 'Internal\\'; - } - $requestNamespace = $baseNamespace . "v{$version}\\" . $modelName; - - if (!Utils::classExists($requestNamespace)) { - $requestNamespace = str_replace('Internal\\', '', $requestNamespace); - } - if (!Utils::classExists($requestNamespace)) { - $requestNamespace = str_replace("v{$version}\\", '', $requestNamespace); - } + $requestNamespace = static::versionedClass($requestNS, $modelName, $version, Http::isInternalRequest()); } - try { - if (!Utils::classExists($requestNamespace)) { - throw new \Exception('Missing resource'); - } - } catch (\Error|\Exception $e) { + if (!Utils::classExists($requestNamespace)) { $requestNamespace = $defaultRequestNS . 'FleetbaseRequest'; } @@ -168,47 +152,55 @@ public static function httpRequestForModel(Model $model, ?string $namespace = nu */ public static function httpFilterForModel(Model $model, ?string $namespace = null, ?int $version = 1): ?string { - // Create a unique cache key based on the model, namespace, version, and a filter flag. - $cacheKey = md5(get_class($model) . '|' . ($namespace ?? '') . '|' . $version . '|filter'); static $cache = []; - if (isset($cache[$cacheKey])) { - return $cache[$cacheKey]; - } - $namespaceSegments = explode('Models', get_class($model)); - $baseNS = '\\' . rtrim($namespaceSegments[0], '\\'); - $filterNamespace = null; - $defaultFilterNS = $baseNS . '\\Http\\Filter\\'; - $filterNs = $namespace ? $namespace . '\\Http\\Filter\\' : $defaultFilterNS; - $modelName = Utils::classBasename($model); - - if (method_exists($model, 'getFilter')) { - $filterNamespace = $model->getFilter(); - } + // A controller names its model's filter (`$model->filter`): Fleet-Ops and Storefront both list + // orders with different filters. It belongs in the key: the cache lives as long as the process, + // so in an Octane worker the first controller's filter was otherwise applied to every later + // controller of the same model. + $explicit = method_exists($model, 'getFilter') ? $model->getFilter() : null; + $cacheKey = md5(get_class($model) . '|' . ($namespace ?? '') . '|' . $version . '|filter|' . (is_string($explicit) ? $explicit : '')); + + if (!array_key_exists($cacheKey, $cache)) { + $namespaceSegments = explode('Models', get_class($model)); + $baseNS = '\\' . rtrim($namespaceSegments[0], '\\'); + $filterNs = $namespace ? $namespace . '\\Http\\Filter\\' : $baseNS . '\\Http\\Filter\\'; + $modelName = Utils::classBasename($model); + $filterNamespace = $explicit ?? $filterNs . Str::studly(ucfirst($modelName) . 'Filter'); + $orNull = fn (string $class) => Utils::classExists($class) ? $class : null; + + $cache[$cacheKey] = Utils::classExists($filterNamespace) + ? $filterNamespace + // The versioned fallback differs for internal routes, so both are kept and the request picks. + : [ + 'internal' => $orNull(static::versionedClass($filterNs, $modelName, $version, true)), + 'public' => $orNull(static::versionedClass($filterNs, $modelName, $version, false)), + ]; + } + + $cached = $cache[$cacheKey]; + + return is_array($cached) ? (Http::isInternalRequest() ? $cached['internal'] : $cached['public']) : $cached; + } - if ($filterNamespace === null) { - $filterNamespace = $filterNs . Str::studly(ucfirst($modelName) . 'Filter'); + /** + * The versioned class a model's request or filter falls back to: `Internal\v1\Model` for internal + * routes, then `v1\Model`, then `Model`, the first that exists (the last when none does). + */ + protected static function versionedClass(string $baseNamespace, string $modelName, ?int $version, bool $internal): string + { + $candidates = [$baseNamespace . "v{$version}\\" . $modelName, $baseNamespace . $modelName]; + if ($internal) { + array_unshift($candidates, $baseNamespace . "Internal\\v{$version}\\" . $modelName); } - if (!Utils::classExists($filterNamespace)) { - $internal = Http::isInternalRequest(); - $baseNamespace = $filterNs; - if ($internal) { - $baseNamespace = $filterNs . 'Internal\\'; - } - $filterNamespace = $baseNamespace . "v{$version}\\" . $modelName; - - if (!Utils::classExists($filterNamespace)) { - $filterNamespace = str_replace('Internal\\', '', $filterNamespace); - } - if (!Utils::classExists($filterNamespace)) { - $filterNamespace = str_replace("v{$version}\\", '', $filterNamespace); + foreach ($candidates as $candidate) { + if (Utils::classExists($candidate)) { + return $candidate; } } - $cache[$cacheKey] = Utils::classExists($filterNamespace) ? $filterNamespace : null; - - return $cache[$cacheKey]; + return end($candidates); } public static function getModelPackage(Model $model): ?string diff --git a/tests/Unit/FindHttpRequestForModelTest.php b/tests/Unit/FindHttpRequestForModelTest.php index 2028246e..88b3f97d 100644 --- a/tests/Unit/FindHttpRequestForModelTest.php +++ b/tests/Unit/FindHttpRequestForModelTest.php @@ -35,6 +35,27 @@ class FilterResolutionRecord extends \Illuminate\Database\Eloquent\Model { } + /** A model whose controller may name its filter and request, as FleetbaseController does. */ + class ControllerChosenResolutionRecord extends \Illuminate\Database\Eloquent\Model + { + public ?string $filter = null; + public ?string $request = null; + + public function getFilter(): ?string + { + return $this->filter; + } + + public function getRequest(): ?string + { + return $this->request; + } + } + + class AudienceFilterResolutionRecord extends \Illuminate\Database\Eloquent\Model + { + } + class ExplicitResourceResolutionRecord extends \Illuminate\Database\Eloquent\Model { public function getResource(): string @@ -66,6 +87,34 @@ class ExplicitResource class FilterResolutionRecordFilter { } + + /** The model's own filter, found by convention. */ + class ControllerChosenResolutionRecordFilter + { + } + + /** Another module's filter for the same model (Storefront's order filter over Fleet-Ops orders). */ + class OtherModuleFilter + { + } +} + +namespace Fleetbase\Tests\Fixtures\Http\Filter\v1 { + class AudienceFilterResolutionRecord + { + } +} + +namespace Fleetbase\Tests\Fixtures\Http\Filter\Internal\v1 { + class AudienceFilterResolutionRecord + { + } +} + +namespace Fleetbase\Tests\Fixtures\Http\Requests { + class OtherModuleRequest + { + } } namespace Fleetbase\Tests\Fixtures\Http\Requests\Internal\v1 { @@ -83,14 +132,21 @@ class PackageResolutionRecord extends \Illuminate\Database\Eloquent\Model namespace { use Fleetbase\Http\Requests\FleetbaseRequest; use Fleetbase\Support\Find; + use Fleetbase\Tests\Fixtures\Http\Filter\ControllerChosenResolutionRecordFilter; use Fleetbase\Tests\Fixtures\Http\Filter\FilterResolutionRecordFilter; + use Fleetbase\Tests\Fixtures\Http\Filter\Internal\v1\AudienceFilterResolutionRecord as AudienceFilterInternal; + use Fleetbase\Tests\Fixtures\Http\Filter\OtherModuleFilter; + use Fleetbase\Tests\Fixtures\Http\Filter\v1\AudienceFilterResolutionRecord as AudienceFilterPublic; use Fleetbase\Tests\Fixtures\Http\Requests\CreateAssetValidationRecordRequest; use Fleetbase\Tests\Fixtures\Http\Requests\Internal\v1\InternalRequestResolutionRecord as InternalRequestResolutionRecordRequest; + use Fleetbase\Tests\Fixtures\Http\Requests\OtherModuleRequest; use Fleetbase\Tests\Fixtures\Http\Requests\UpdateAssetValidationRecordRequest; use Fleetbase\Tests\Fixtures\Http\Resources\ExplicitResource; use Fleetbase\Tests\Fixtures\Http\Resources\Internal\v1\InternalResourceResolutionRecord as InternalResourceResolutionRecordResource; use Fleetbase\Tests\Fixtures\Http\Resources\v1\ResourceResolutionRecord as ResourceResolutionRecordResource; use Fleetbase\Tests\Fixtures\Models\AssetValidationRecord; + use Fleetbase\Tests\Fixtures\Models\AudienceFilterResolutionRecord; + use Fleetbase\Tests\Fixtures\Models\ControllerChosenResolutionRecord; use Fleetbase\Tests\Fixtures\Models\ExplicitResourceResolutionRecord; use Fleetbase\Tests\Fixtures\Models\FilterResolutionRecord; use Fleetbase\Tests\Fixtures\Models\InternalRequestResolutionRecord; @@ -189,4 +245,36 @@ function find_test_request(string $path, string $routeUri, string $namespace = ' ->and(Find::getModelPackage(new Fleetbase\Storefront\Models\PackageResolutionRecord())) ->toBe('Storefront'); }); + + test('a filter a controller sets on its model is used even after the model resolved another filter', function () { + $fleetOps = new ControllerChosenResolutionRecord(); + + $storefront = new ControllerChosenResolutionRecord(); + $storefront->filter = OtherModuleFilter::class; + + // The first controller's answer used to be cached for the model class and served to the second. + expect(Find::httpFilterForModel($fleetOps))->toBe('\\' . ControllerChosenResolutionRecordFilter::class) + ->and(Find::httpFilterForModel($storefront))->toBe(OtherModuleFilter::class) + ->and(Find::httpFilterForModel(new ControllerChosenResolutionRecord()))->toBe('\\' . ControllerChosenResolutionRecordFilter::class); + }); + + test('a convention filter is resolved for the request audience, not the first audience asked', function () { + app()->instance('request', find_test_request('/v1/records', 'v1/records')); + $public = Find::httpFilterForModel(new AudienceFilterResolutionRecord()); + + app()->instance('request', find_test_request('/int/v1/records', 'int/v1/records')); + $internal = Find::httpFilterForModel(new AudienceFilterResolutionRecord()); + + expect($public)->toBe('\\' . AudienceFilterPublic::class) + ->and($internal)->toBe('\\' . AudienceFilterInternal::class); + }); + + test('a request class a controller sets on its model is used even after the model resolved another', function () { + $_SERVER['REQUEST_METHOD'] = 'POST'; + $named = new ControllerChosenResolutionRecord(); + $named->request = OtherModuleRequest::class; + + expect(Find::httpRequestForModel(new ControllerChosenResolutionRecord(), '\Fleetbase\Tests\Fixtures'))->toBe('\\' . FleetbaseRequest::class) + ->and(Find::httpRequestForModel($named, '\Fleetbase\Tests\Fixtures'))->toBe(OtherModuleRequest::class); + }); } From aa47cc7703cc15b59bfb1e4a8088568fa4864cce Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 15 Oct 2026 16:36:26 +0800 Subject: [PATCH 08/12] docs(release): link the filter cache fix --- RELEASE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RELEASE.md b/RELEASE.md index 560ad681..5a041461 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -2,7 +2,7 @@ ## Fixed -- **A controller's filter is used for every query it serves.** `Find::httpFilterForModel()` cached its answer per model class for the life of the process, ignoring the filter a controller sets on its model (`$filter`) and whether the request was internal. Under Octane, the first controller to list a model decided the filter for every other: Fleet-Ops' order filter was applied to Storefront's order lists, so a network's orders page listed every order in the company. The cache is now keyed by the model's own filter, and a filter found by convention is picked per request audience. `Find::httpRequestForModel()` is keyed by the model's own request class too. (#PR) +- **A controller's filter is used for every query it serves.** `Find::httpFilterForModel()` cached its answer per model class for the life of the process, ignoring the filter a controller sets on its model (`$filter`) and whether the request was internal. Under Octane, the first controller to list a model decided the filter for every other: Fleet-Ops' order filter was applied to Storefront's order lists, so a network's orders page listed every order in the company. The cache is now keyed by the model's own filter, and a filter found by convention is picked per request audience. `Find::httpRequestForModel()` is keyed by the model's own request class too. (#315) - **Live and test-mode data no longer share API cache entries.** List, model and relationship cache keys now carry the database connection (`{api_query}:mysql:…` / `{api_query}:sandbox:…`). Before, a console request in test mode could be served the cached live response for the same query (and the reverse), because console sessions carry no API credential to tell the two apart. (#312) - **Driver-assigned lifecycle events reach `driver.{public_id}` and `driver.{uuid}`.** Order events for records with an assigned driver were only published on `driverAssigned.*`, which clients following the `{type}.{id}` convention never subscribe to. `driverAssigned.*` is still published and is now deprecated. (#311) From 0d3877c2aec1768109c9c4f5352460e35d2d661f Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 15 Oct 2026 19:05:14 +0800 Subject: [PATCH 09/12] feat(categories): include parent_uuid in internal category responses A console building a category tree from a flat list (Storefront's nested product categories) needs each category's parent without loading it. The internal Category resource now sends parent_uuid; public responses do not. --- RELEASE.md | 4 ++++ src/Http/Resources/Category.php | 2 ++ tests/Unit/Http/ResourceResponseContractsTest.php | 6 ++++-- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index 5a041461..becabdd7 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,5 +1,9 @@ # v1.6.71 — Keep live and test-mode API caches apart; driver-assigned events on driver channels +## Added + +- **Category `parent_uuid` on internal responses.** The internal Category resource now includes `parent_uuid`, so a console can build a category tree from a flat list (Storefront's nested product categories). (#PR) + ## Fixed - **A controller's filter is used for every query it serves.** `Find::httpFilterForModel()` cached its answer per model class for the life of the process, ignoring the filter a controller sets on its model (`$filter`) and whether the request was internal. Under Octane, the first controller to list a model decided the filter for every other: Fleet-Ops' order filter was applied to Storefront's order lists, so a network's orders page listed every order in the company. The cache is now keyed by the model's own filter, and a filter found by convention is picked per request audience. `Find::httpRequestForModel()` is keyed by the model's own request class too. (#315) diff --git a/src/Http/Resources/Category.php b/src/Http/Resources/Category.php index 5ade4f5c..d4c43cbb 100644 --- a/src/Http/Resources/Category.php +++ b/src/Http/Resources/Category.php @@ -34,6 +34,8 @@ public function toArray($request) 'company_uuid' => $this->when(Http::isInternalRequest(), $this->company_uuid), 'owner_uuid' => $this->when(Http::isInternalRequest(), $this->owner_uuid), 'owner_type' => $this->when(Http::isInternalRequest(), $this->owner_type), + // Lets a console build a category tree from a flat list without loading each parent. + 'parent_uuid' => $this->when(Http::isInternalRequest(), $this->parent_uuid), 'icon' => $this->icon, 'icon_url' => $this->icon_url, 'name' => $this->name, diff --git a/tests/Unit/Http/ResourceResponseContractsTest.php b/tests/Unit/Http/ResourceResponseContractsTest.php index d9ee4550..418d9264 100644 --- a/tests/Unit/Http/ResourceResponseContractsTest.php +++ b/tests/Unit/Http/ResourceResponseContractsTest.php @@ -258,7 +258,7 @@ function schedule_template_resource_model(array $attributes = []): ScheduleTempl ])); expect($payload['id'])->toBe('cat_1') - ->and($payload)->not->toHaveKeys(['uuid', 'company_uuid', 'owner_uuid', 'owner_type', 'public_id']) + ->and($payload)->not->toHaveKeys(['uuid', 'company_uuid', 'owner_uuid', 'owner_type', 'public_id', 'parent_uuid']) ->and($payload['name'])->toBe('Operations') ->and($payload['icon_url'])->toBe('https://flb-assets.s3.ap-southeast-1.amazonaws.com/images/fallback-placeholder-1.png') ->and($payload['tags'])->toBe(['ops', 'dispatch']) @@ -283,7 +283,7 @@ function schedule_template_resource_model(array $attributes = []): ScheduleTempl 'public_id' => 'cat_child', 'name' => 'Child Category', ]); - $category = category_resource_model([]); + $category = category_resource_model(['parent_uuid' => 'category-parent']); $category->setRelation('parentCategory', $parent); $category->setRelation('subCategories', collect([$child])); @@ -301,6 +301,8 @@ function schedule_template_resource_model(array $attributes = []): ScheduleTempl ->and($payload['company_uuid'])->toBe('company-1') ->and($payload['owner_uuid'])->toBe('owner-1') ->and($payload['owner_type'])->toBe('company') + // A console builds its tree from parent_uuid without loading each parent. + ->and($payload['parent_uuid'])->toBe('category-parent') ->and($payload)->not->toHaveKeys(['parent', 'subcategories']); $withParent = (new CategoryResource($category))->resolve(resource_contract_request('/int/v1/categories/category-1', [ From c31a3f0b6f7a8c8a68e3869eed92d56fc36ab2ac Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 15 Oct 2026 19:05:38 +0800 Subject: [PATCH 10/12] docs(release): link the category parent_uuid change --- RELEASE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RELEASE.md b/RELEASE.md index becabdd7..d018dfd2 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -2,7 +2,7 @@ ## Added -- **Category `parent_uuid` on internal responses.** The internal Category resource now includes `parent_uuid`, so a console can build a category tree from a flat list (Storefront's nested product categories). (#PR) +- **Category `parent_uuid` on internal responses.** The internal Category resource now includes `parent_uuid`, so a console can build a category tree from a flat list (Storefront's nested product categories). (#316) ## Fixed From 8749fea202c19fa5435e01c622acf0ef78557735 Mon Sep 17 00:00:00 2001 From: Ron Date: Fri, 16 Oct 2026 12:00:08 +0800 Subject: [PATCH 11/12] docs(release): v1.6.71 notes cover every PR in the release branch --- RELEASE.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index d018dfd2..6798690d 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,7 +1,8 @@ -# v1.6.71 — Keep live and test-mode API caches apart; driver-assigned events on driver channels +# v1.6.71 — Socket settings from the console; live and test-mode API caches kept apart; driver-assigned events on driver channels ## Added +- **Socket settings from the console.** System administrators manage realtime channel authentication without touching the environment: `GET`/`POST int/v1/settings/socket` read and save the switch, the mode (`off`, `log`, `enforce`), the token TTL, the allowed origins and the publisher origin. Each value falls back to the environment when unset, so nothing changes until an administrator saves something. A signed `GET int/v1/socket/config` lets the socket server (fleetbase/fleetbase v0.7.70) poll the effective config and apply it at runtime, no restart. `SOCKETCLUSTER_AUTH_KEY` stays in the environment and is never stored or returned; turning the switch on without a key is refused. Every save is written to the activity log. (#314) - **Category `parent_uuid` on internal responses.** The internal Category resource now includes `parent_uuid`, so a console can build a category tree from a flat list (Storefront's nested product categories). (#316) ## Fixed @@ -12,4 +13,5 @@ ## Upgrade Steps -- None. Existing cache entries keep their old keys and expire within their TTL (5 minutes for lists, 1 hour for models); `deploy.sh` already runs `php artisan cache:clear`. +- Socket settings: nothing changes until an administrator saves one; the environment values stay the defaults. A socket server older than fleetbase/fleetbase v0.7.70 keeps reading its own environment. +- Otherwise none. Existing cache entries keep their old keys and expire within their TTL (5 minutes for lists, 1 hour for models); `deploy.sh` already runs `php artisan cache:clear`. From 997a5eb349eff95d45a6b4633cdc0329911c7985 Mon Sep 17 00:00:00 2001 From: Ron Date: Fri, 16 Oct 2026 13:08:14 +0800 Subject: [PATCH 12/12] ci: retrigger the checks that the Actions billing lock refused to start